python_proto_importer/
doctor.rs

1use crate::config::AppConfig;
2use crate::verification::{determine_package_structure, determine_package_structure_legacy};
3use anyhow::{Result, bail};
4use std::path::Path;
5use std::process::Command;
6use which::which;
7
8/// Check if a command is available in PATH and return its path.
9///
10/// # Arguments
11///
12/// * `cmd` - Command name to check for
13///
14/// # Returns
15///
16/// Returns `Some(String)` with the full path if the command is found,
17/// or `None` if it's not available.
18fn check(cmd: &str) -> Option<String> {
19    which(cmd)
20        .ok()
21        .and_then(|p| p.to_str().map(|s| s.to_string()))
22}
23
24/// Run environment diagnostics and display system information.
25///
26/// This function performs a comprehensive check of the development environment,
27/// reporting on the availability and versions of tools needed for proto-to-Python
28/// code generation. It checks for:
29///
30/// - Python interpreters (uv, python3, python)
31/// - Required Python packages (grpcio-tools)
32/// - Optional Python packages (mypy-protobuf, mypy-grpc)
33/// - Type checkers (mypy, pyright)
34/// - System tools (protoc, buf)
35///
36/// The function also attempts to load and validate a pyproject.toml configuration
37/// to provide targeted recommendations based on the current project setup.
38///
39/// # Returns
40///
41/// Returns `Ok(())` on successful completion of all checks, or an error
42/// if critical issues prevent the diagnostic from running.
43///
44/// # Example
45///
46/// ```no_run
47/// use python_proto_importer::doctor;
48///
49/// fn main() -> anyhow::Result<()> {
50///     doctor::run()
51/// }
52/// ```
53pub fn run() -> Result<()> {
54    println!("== Tool presence ==");
55
56    let py_runner = check("uv")
57        .or_else(|| check("python3"))
58        .or_else(|| check("python"))
59        .unwrap_or_default();
60
61    if let Some(uv_path) = check("uv") {
62        let uv_ver = cmd_version(&uv_path, &["--version"]).unwrap_or_else(|| "unknown".into());
63        println!("{:<14}: {} ({})", "uv", uv_path, uv_ver.trim());
64    } else {
65        println!("{:<14}: not found", "uv");
66    }
67    if let Some(py3) = check("python3") {
68        let py_ver = cmd_version(&py3, &["--version"]).unwrap_or_else(|| "unknown".into());
69        println!("{:<14}: {} ({})", "python3", py3, py_ver.trim());
70    } else if let Some(py) = check("python") {
71        let py_ver = cmd_version(&py, &["--version"]).unwrap_or_else(|| "unknown".into());
72        println!("{:<14}: {} ({})", "python", py, py_ver.trim());
73    } else {
74        println!("{:<14}: not found", "python");
75    }
76
77    let (grpc_tools_found, grpc_tools_ver) = probe_python_pkg(&py_runner, "grpcio-tools");
78    println!(
79        "{:<14}: {}{}",
80        "grpc_tools",
81        if grpc_tools_found {
82            "found"
83        } else {
84            "not found"
85        },
86        grpc_tools_ver
87            .as_deref()
88            .map(|v| format!(" ({})", v))
89            .unwrap_or_default()
90    );
91
92    let (mypy_protobuf_found, mypy_protobuf_ver) = probe_python_pkg(&py_runner, "mypy-protobuf");
93    println!(
94        "{:<14}: {}{}",
95        "mypy-protobuf",
96        if mypy_protobuf_found {
97            "found"
98        } else {
99            "not found"
100        },
101        mypy_protobuf_ver
102            .as_deref()
103            .map(|v| format!(" ({})", v))
104            .unwrap_or_default()
105    );
106    let (mypy_grpc_found, mypy_grpc_ver) = probe_python_pkg(&py_runner, "mypy-grpc");
107    println!(
108        "{:<14}: {}{}",
109        "mypy-grpc",
110        if mypy_grpc_found {
111            "found"
112        } else {
113            "not found"
114        },
115        mypy_grpc_ver
116            .as_deref()
117            .map(|v| format!(" ({})", v))
118            .unwrap_or_default()
119    );
120
121    if let Some(p) = check("protoc") {
122        let v = cmd_version(&p, &["--version"]).unwrap_or_else(|| "unknown".into());
123        println!("{:<14}: {} ({})", "protoc", p, v.trim());
124    } else {
125        println!("{:<14}: not found", "protoc");
126    }
127    if let Some(p) = check("buf") {
128        let v = cmd_version(&p, &["--version"]).unwrap_or_else(|| "unknown".into());
129        println!("{:<14}: {} ({})", "buf", p, v.trim());
130    } else {
131        println!("{:<14}: not found", "buf");
132    }
133
134    if let Some(p) = check("mypy") {
135        let v = cmd_version(&p, &["--version"]).unwrap_or_else(|| "unknown".into());
136        println!("{:<14}: {} ({})", "mypy", p, v.trim());
137    } else {
138        println!("{:<14}: not found", "mypy");
139    }
140    if let Some(p) = check("pyright") {
141        let v = cmd_version(&p, &["--version"]).unwrap_or_else(|| "unknown".into());
142        println!("{:<14}: {} ({})", "pyright", p, v.trim());
143    } else {
144        println!("{:<14}: not found", "pyright");
145    }
146
147    if let Ok(cfg) = AppConfig::load(Some(Path::new("pyproject.toml"))) {
148        println!("\n== Based on pyproject.toml ==");
149        if cfg.generate_mypy && !mypy_protobuf_found {
150            println!(
151                "hint: mypy-protobuf is required (install via 'uv add mypy-protobuf' or 'pip install mypy-protobuf')"
152            );
153        }
154        if cfg.generate_mypy_grpc && !mypy_grpc_found {
155            println!(
156                "hint: mypy-grpc is required (install via 'uv add mypy-grpc' or 'pip install mypy-grpc')"
157            );
158        }
159        if let Some(v) = &cfg.verify {
160            if v.mypy_cmd.is_some() && check("mypy").is_none() {
161                println!(
162                    "hint: mypy CLI not found (install via 'uv add mypy' or 'pip install mypy')"
163                );
164            }
165            if v.pyright_cmd.is_some() && check("pyright").is_none() {
166                println!(
167                    "hint: pyright CLI not found (install via 'uv add pyright' or 'npm i -g pyright')"
168                );
169            }
170        }
171    }
172
173    if !grpc_tools_found {
174        bail!(
175            "grpc_tools.protoc not found. Install with 'uv add grpcio-tools' or 'pip install grpcio-tools'"
176        );
177    }
178
179    // Check package structure if pyproject.toml is found
180    if let Ok(cfg) = AppConfig::load(Some(Path::new("pyproject.toml"))) {
181        println!("\n== Package structure analysis ==");
182
183        let out_abs = cfg.out.canonicalize().unwrap_or_else(|_| cfg.out.clone());
184        println!("Output directory: {}", out_abs.display());
185
186        if !out_abs.exists() {
187            println!("  ❌ Output directory does not exist. Run 'build' first to generate files.");
188        } else {
189            println!("  ✅ Output directory exists");
190
191            // Analyze current package structure determination
192            let (parent_path, package_name) =
193                determine_package_structure(&out_abs).unwrap_or_else(|e| {
194                    println!("  ❌ Failed to determine package structure: {}", e);
195                    (std::path::PathBuf::new(), String::new())
196                });
197
198            let (legacy_parent_path, legacy_package_name) =
199                determine_package_structure_legacy(&out_abs).unwrap_or_else(|e| {
200                    println!("  ❌ Failed to determine legacy package structure: {}", e);
201                    (std::path::PathBuf::new(), String::new())
202                });
203
204            println!("  Current implementation:");
205            println!("    PYTHONPATH: {}", parent_path.display());
206            println!(
207                "    Package name: {}",
208                if package_name.is_empty() {
209                    "<empty>"
210                } else {
211                    &package_name
212                }
213            );
214
215            if parent_path != legacy_parent_path || package_name != legacy_package_name {
216                println!("  Legacy implementation (fallback):");
217                println!("    PYTHONPATH: {}", legacy_parent_path.display());
218                println!(
219                    "    Package name: {}",
220                    if legacy_package_name.is_empty() {
221                        "<empty>"
222                    } else {
223                        &legacy_package_name
224                    }
225                );
226            }
227
228            // Check parent directory package status
229            if let Some(parent_dir) = out_abs.parent() {
230                let parent_init = parent_dir.join("__init__.py");
231                if parent_init.exists() {
232                    println!("  ✅ Parent directory is a Python package (has __init__.py)");
233                    if let Some(parent_name) = parent_dir.file_name().and_then(|n| n.to_str()) {
234                        println!("    Package name: {}", parent_name);
235                    }
236                    if let Some(grandparent) = parent_dir.parent() {
237                        println!("    Recommended PYTHONPATH: {}", grandparent.display());
238                    }
239                } else {
240                    println!("  ℹ️  Parent directory is not a Python package (no __init__.py)");
241                    println!("    This is fine for simple structures");
242                }
243            }
244
245            // Count generated files
246            if let Ok(entries) = std::fs::read_dir(&out_abs) {
247                let py_files: Vec<_> = entries
248                    .filter_map(Result::ok)
249                    .filter(|entry| {
250                        entry.path().extension().and_then(|ext| ext.to_str()) == Some("py")
251                    })
252                    .collect();
253                println!("  Generated files: {} Python files found", py_files.len());
254            }
255        }
256    }
257
258    Ok(())
259}
260
261fn cmd_version(bin: &str, args: &[&str]) -> Option<String> {
262    let out = Command::new(bin).args(args).output().ok()?;
263    if out.status.success() {
264        let s = String::from_utf8_lossy(&out.stdout);
265        let txt = if s.trim().is_empty() {
266            String::from_utf8_lossy(&out.stderr).into_owned()
267        } else {
268            s.into_owned()
269        };
270        Some(txt)
271    } else {
272        None
273    }
274}
275
276fn probe_python_pkg(py_runner: &str, dist_name: &str) -> (bool, Option<String>) {
277    if py_runner.is_empty() {
278        return (false, None);
279    }
280    let code = format!(
281        "import sys\ntry:\n import importlib.metadata as m\n print('1 ' + m.version('{0}'))\nexcept Exception:\n try:\n  import pkg_resources as pr\n  print('1 ' + pr.get_distribution('{0}').version)\n except Exception:\n  print('0')\n",
282        dist_name
283    );
284    let mut cmd = Command::new(py_runner);
285    if py_runner.ends_with("uv") || py_runner == "uv" {
286        cmd.arg("run").arg("python").arg("-c").arg(&code);
287    } else {
288        cmd.arg("-c").arg(&code);
289    }
290    let out_opt = match cmd.output() {
291        Ok(o) if o.status.success() => Some(o),
292        _ => None,
293    };
294    if let Some(out) = out_opt {
295        let s = String::from_utf8_lossy(&out.stdout);
296        let t = s.trim();
297        if let Some(rest) = t.strip_prefix('1') {
298            let ver = rest.trim();
299            let ver = ver.strip_prefix(' ').unwrap_or(ver);
300            return (
301                true,
302                if ver.is_empty() {
303                    None
304                } else {
305                    Some(ver.to_string())
306                },
307            );
308        }
309    }
310    (false, None)
311}