Skip to main content

hematite/tools/
verify_build.rs

1use crate::agent::config;
2use crate::agent::inference::InferenceEvent;
3use serde_json::Value;
4use std::process::Command;
5use tokio::sync::mpsc;
6
7const BUILD_TIMEOUT_SECS: u64 = 120;
8
9/// Streaming variant — emits live shell lines to the SPECULAR panel while buffering
10/// the final combined output for the tool result returned to the model.
11pub async fn execute_streaming(
12    args: &Value,
13    tx: mpsc::Sender<InferenceEvent>,
14) -> Result<String, String> {
15    let cwd =
16        std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
17    let action = args
18        .get("action")
19        .and_then(|v| v.as_str())
20        .unwrap_or("build");
21    let explicit_profile = args.get("profile").and_then(|v| v.as_str());
22    let timeout_override = args.get("timeout_secs").and_then(|v| v.as_u64());
23
24    let config = config::load_config();
25    if let Some(profile_name) = explicit_profile {
26        let profile = config.verify.profiles.get(profile_name).ok_or_else(|| {
27            format!(
28                "Unknown verify profile `{}`. Define it in `.hematite/settings.json` or omit the profile argument.",
29                profile_name
30            )
31        })?;
32        if let Some(command) = profile_command(profile, action) {
33            let timeout_secs = timeout_override
34                .or(profile.timeout_secs)
35                .unwrap_or(BUILD_TIMEOUT_SECS);
36            return run_profile_command_streaming(profile_name, action, command, timeout_secs, tx)
37                .await;
38        }
39
40        return Err(format!(
41            "VERIFY PROFILE MISSING [{profile_name}] action `{action}`.\n\
42             Configure `.hematite/settings.json` with a `{action}` command for this profile, \
43             or call `verify_build` with a different action/profile."
44        ));
45    }
46
47    if let Some(default_profile) = config.verify.default_profile.as_deref() {
48        let profile = config.verify.profiles.get(default_profile).ok_or_else(|| {
49            format!(
50                "Configured default verify profile `{}` was not found in `.hematite/settings.json`.",
51                default_profile
52            )
53        })?;
54        if let Some(command) = profile_command(profile, action) {
55            let timeout_secs = timeout_override
56                .or(profile.timeout_secs)
57                .unwrap_or(BUILD_TIMEOUT_SECS);
58            return run_profile_command_streaming(
59                default_profile,
60                action,
61                command,
62                timeout_secs,
63                tx,
64            )
65            .await;
66        }
67
68        return Err(format!(
69            "VERIFY PROFILE MISSING [{default_profile}] action `{action}`.\n\
70             Configure `.hematite/settings.json` with a `{action}` command for the default profile, \
71             or call `verify_build` with an explicit profile."
72        ));
73    }
74
75    let (label, command, timeout_secs) = autodetect_command(&cwd, action, timeout_override)?;
76    run_profile_command_streaming(label, action, &command, timeout_secs, tx).await
77}
78
79pub async fn execute(args: &Value) -> Result<String, String> {
80    let cwd =
81        std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
82    let action = args
83        .get("action")
84        .and_then(|v| v.as_str())
85        .unwrap_or("build");
86    let explicit_profile = args.get("profile").and_then(|v| v.as_str());
87    let timeout_override = args.get("timeout_secs").and_then(|v| v.as_u64());
88
89    let config = config::load_config();
90    if let Some(profile_name) = explicit_profile {
91        let profile = config.verify.profiles.get(profile_name).ok_or_else(|| {
92            format!(
93                "Unknown verify profile `{}`. Define it in `.hematite/settings.json` or omit the profile argument.",
94                profile_name
95            )
96        })?;
97        if let Some(command) = profile_command(profile, action) {
98            let timeout_secs = timeout_override
99                .or(profile.timeout_secs)
100                .unwrap_or(BUILD_TIMEOUT_SECS);
101            return run_profile_command(profile_name, action, command, timeout_secs).await;
102        }
103
104        return Err(format!(
105            "VERIFY PROFILE MISSING [{profile_name}] action `{action}`.\n\
106             Configure `.hematite/settings.json` with a `{action}` command for this profile, \
107             or call `verify_build` with a different action/profile."
108        ));
109    }
110
111    if let Some(default_profile) = config.verify.default_profile.as_deref() {
112        let profile = config.verify.profiles.get(default_profile).ok_or_else(|| {
113            format!(
114                "Configured default verify profile `{}` was not found in `.hematite/settings.json`.",
115                default_profile
116            )
117        })?;
118        if let Some(command) = profile_command(profile, action) {
119            let timeout_secs = timeout_override
120                .or(profile.timeout_secs)
121                .unwrap_or(BUILD_TIMEOUT_SECS);
122            return run_profile_command(default_profile, action, command, timeout_secs).await;
123        }
124
125        return Err(format!(
126            "VERIFY PROFILE MISSING [{default_profile}] action `{action}`.\n\
127             Configure `.hematite/settings.json` with a `{action}` command for the default profile, \
128             or call `verify_build` with an explicit profile."
129        ));
130    }
131
132    let (label, command, timeout_secs) = autodetect_command(&cwd, action, timeout_override)?;
133    run_profile_command(label, action, &command, timeout_secs).await
134}
135
136fn profile_command<'a>(profile: &'a config::VerifyProfile, action: &str) -> Option<&'a str> {
137    match action {
138        "build" => profile.build.as_deref(),
139        "test" => profile.test.as_deref(),
140        "lint" => profile.lint.as_deref(),
141        "fix" => profile.fix.as_deref(),
142        _ => None,
143    }
144}
145
146fn autodetect_command(
147    cwd: &std::path::Path,
148    action: &str,
149    timeout_override: Option<u64>,
150) -> Result<(&'static str, String, u64), String> {
151    let timeout_secs = timeout_override.unwrap_or(BUILD_TIMEOUT_SECS);
152    let command = if cwd.join("Cargo.toml").exists() {
153        match action {
154            "build" => ("Rust/Cargo", "cargo build --color never".to_string()),
155            "test" => ("Rust/Cargo", "cargo test --color never".to_string()),
156            "lint" => (
157                "Rust/Cargo",
158                "cargo clippy --all-targets --all-features -- -D warnings".to_string(),
159            ),
160            "fix" => ("Rust/Cargo", "cargo fmt".to_string()),
161            _ => return Err(unknown_action(action)),
162        }
163    } else if cwd.join("go.mod").exists() {
164        match action {
165            "build" => ("Go", "go build ./...".to_string()),
166            "test" => ("Go", "go test ./...".to_string()),
167            "lint" => ("Go", "go vet ./...".to_string()),
168            "fix" => ("Go", "gofmt -w .".to_string()),
169            _ => return Err(unknown_action(action)),
170        }
171    } else if cwd.join("CMakeLists.txt").exists() {
172        // C / C++ (CMake) — create build dir if missing, configure + build
173        let build_dir = "build";
174        match action {
175            "build" => (
176                "C++/CMake",
177                format!("cmake -B {build_dir} -DCMAKE_BUILD_TYPE=Release && cmake --build {build_dir} --parallel"),
178            ),
179            "test" => (
180                "C++/CMake",
181                format!("ctest --test-dir {build_dir} --output-on-failure"),
182            ),
183            "lint" => return Err(missing_profile_msg("C++/CMake", action)),
184            "fix"  => return Err(missing_profile_msg("C++/CMake", action)),
185            _ => return Err(unknown_action(action)),
186        }
187    } else if cwd.join("package.json").exists() {
188        // Detect package manager: pnpm > yarn > bun > npm
189        let pm = if cwd.join("pnpm-lock.yaml").exists()
190            || cwd.join(".npmrc").exists() && {
191                let rc = std::fs::read_to_string(cwd.join(".npmrc")).unwrap_or_default();
192                rc.contains("pnpm")
193            } {
194            "pnpm"
195        } else if cwd.join("yarn.lock").exists() {
196            "yarn"
197        } else if cwd.join("bun.lockb").exists() {
198            "bun"
199        } else {
200            "npm"
201        };
202        // Detect TypeScript project for better label
203        let label: &'static str = if cwd.join("tsconfig.json").exists() {
204            match pm {
205                "pnpm" => "TypeScript/pnpm",
206                "yarn" => "TypeScript/yarn",
207                "bun" => "TypeScript/bun",
208                _ => "TypeScript/npm",
209            }
210        } else {
211            match pm {
212                "pnpm" => "Node/pnpm",
213                "yarn" => "Node/yarn",
214                "bun" => "Node/bun",
215                _ => "Node/npm",
216            }
217        };
218        match action {
219            "build" => (label, format!("{pm} run build")),
220            "test" => (label, format!("{pm} test")),
221            "lint" => (label, format!("{pm} run lint")),
222            "fix" => (label, format!("{pm} run format")),
223            _ => return Err(unknown_action(action)),
224        }
225    } else if cwd.join("pyproject.toml").exists()
226        || cwd.join("setup.py").exists()
227        || cwd.join("requirements.txt").exists()
228        || cwd.join(".venv").is_dir()
229        || cwd.join("venv").is_dir()
230        || cwd.join("env").is_dir()
231    {
232        // Python — prefer ruff when available, fall back to flake8/black/pytest
233        // Prioritize local environment (Poetry, Pipenv, .venv)
234        let py = resolve_python_cmd(cwd);
235        match action {
236            "build" => ("Python", format!("{py} -m compileall -q .")),
237            "test" => ("Python", format!("{py} -m pytest -q")),
238            "lint" => (
239                "Python",
240                format!("{py} -m ruff check . || {py} -m flake8 ."),
241            ),
242            "fix" => (
243                "Python",
244                format!("{py} -m ruff format . || {py} -m black ."),
245            ),
246            _ => return Err(unknown_action(action)),
247        }
248    } else if cwd.join("tsconfig.json").exists() {
249        // TypeScript without package.json — bare tsc check
250        match action {
251            "build" => ("TypeScript/tsc", "tsc --noEmit".to_string()),
252            "test" => return Err(missing_profile_msg("TypeScript/tsc", action)),
253            "lint" => return Err(missing_profile_msg("TypeScript/tsc", action)),
254            "fix" => return Err(missing_profile_msg("TypeScript/tsc", action)),
255            _ => return Err(unknown_action(action)),
256        }
257    } else if cwd.join("index.html").exists() {
258        match action {
259            "build" => ("Static Web", "echo \"BUILD OK (Static assets ready)\"".to_string()),
260            "test" => (
261                "Static Web",
262                "echo \"TEST OK (No test runner found; manual visual check and link verification suggested)\"".to_string(),
263            ),
264            "lint" => ("Static Web", "echo \"LINT OK (Basic structure verified)\"".to_string()),
265            "fix" => ("Static Web", "echo \"FIX OK (No auto-formatter found for static assets)\"".to_string()),
266            _ => return Err(unknown_action(action)),
267        }
268    } else {
269        return Err(format!(
270            "No recognized project root (Cargo.toml, package.json, go.mod, CMakeLists.txt, pyproject.toml, etc.) \
271             found in {}.\nUse an explicit profile or configure a default verify profile in `.hematite/settings.json`.",
272            cwd.display()
273        ));
274    };
275
276    Ok((command.0, command.1, timeout_secs))
277}
278
279fn resolve_python_cmd(cwd: &std::path::Path) -> String {
280    let config = config::load_config();
281
282    if let Some(path) = config.python_path {
283        if std::path::Path::new(&path).exists() {
284            return path;
285        }
286    }
287
288    if cwd.join("poetry.lock").exists() {
289        return "poetry run python".to_string();
290    }
291    if cwd.join("Pipfile.lock").exists() || cwd.join("Pipfile").exists() {
292        return "pipenv run python".to_string();
293    }
294    let venv_folders = [".venv", "venv", "env"];
295    for folder in venv_folders {
296        if cwd.join(folder).is_dir() {
297            let rel_path = if cfg!(windows) {
298                format!("{}\\Scripts\\python.exe", folder)
299            } else {
300                format!("{}/bin/python", folder)
301            };
302            if cwd.join(&rel_path).exists() {
303                return format!(".{}{}", if cfg!(windows) { "\\" } else { "/" }, rel_path);
304            }
305        }
306    }
307
308    if cfg!(windows) {
309        let check = Command::new("where").arg("py").output();
310        if check.map(|o| o.status.success()).unwrap_or(false) {
311            return "py -3".to_string();
312        }
313    }
314    "python".to_string()
315}
316
317fn missing_profile_msg(stack: &str, action: &str) -> String {
318    format!(
319        "No auto-detected `{action}` command for [{stack}].\n\
320         Add a verify profile in `.hematite/settings.json` if you want Hematite to run `{action}` for this project."
321    )
322}
323
324fn unknown_action(action: &str) -> String {
325    format!(
326        "Unknown verify_build action `{}`. Use one of: build, test, lint, fix.",
327        action
328    )
329}
330
331async fn run_profile_command(
332    profile_name: &str,
333    action: &str,
334    command: &str,
335    timeout_secs: u64,
336) -> Result<String, String> {
337    let output = crate::tools::shell::execute(
338        &serde_json::json!({
339            "command": command,
340            "timeout_secs": timeout_secs,
341            "reason": format!("verify_build:{}:{}", profile_name, action),
342        }),
343        16384,
344    )
345    .await?;
346
347    if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
348        Ok(format!(
349            "BUILD OK [{}:{}]\ncommand: {}\n{}",
350            profile_name,
351            action,
352            command,
353            output.trim()
354        ))
355    } else if should_fallback_to_cargo_check(action, command, &output) {
356        run_windows_self_hosted_check_fallback(profile_name, action, command, timeout_secs, &output)
357            .await
358    } else {
359        Err(format!(
360            "BUILD FAILED [{}:{}]\ncommand: {}\n{}",
361            profile_name,
362            action,
363            command,
364            output.trim()
365        ))
366    }
367}
368
369async fn run_profile_command_streaming(
370    profile_name: &str,
371    action: &str,
372    command: &str,
373    timeout_secs: u64,
374    tx: mpsc::Sender<InferenceEvent>,
375) -> Result<String, String> {
376    let output = crate::tools::shell::execute_streaming(
377        &serde_json::json!({
378            "command": command,
379            "timeout_secs": timeout_secs,
380            "reason": format!("verify_build:{}:{}", profile_name, action),
381        }),
382        tx.clone(),
383        16384,
384    )
385    .await?;
386
387    if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
388        Ok(format!(
389            "BUILD OK [{}:{}]\ncommand: {}\n{}",
390            profile_name,
391            action,
392            command,
393            output.trim()
394        ))
395    } else if should_fallback_to_cargo_check(action, command, &output) {
396        run_windows_self_hosted_check_fallback_streaming(
397            profile_name,
398            action,
399            command,
400            timeout_secs,
401            &output,
402            tx,
403        )
404        .await
405    } else {
406        Err(format!(
407            "BUILD FAILED [{}:{}]\ncommand: {}\n{}",
408            profile_name,
409            action,
410            command,
411            output.trim()
412        ))
413    }
414}
415
416async fn run_windows_self_hosted_check_fallback_streaming(
417    profile_name: &str,
418    action: &str,
419    original_command: &str,
420    timeout_secs: u64,
421    original_output: &str,
422    tx: mpsc::Sender<InferenceEvent>,
423) -> Result<String, String> {
424    let fallback_command = "cargo check --color never";
425    let fallback_output = crate::tools::shell::execute_streaming(
426        &serde_json::json!({
427            "command": fallback_command,
428            "timeout_secs": timeout_secs,
429            "reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
430        }),
431        tx,
432        16384,
433    )
434    .await?;
435
436    if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
437        Ok(format!(
438            "BUILD OK [{}:{}]\ncommand: {}\n\
439             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, so Hematite fell back to `cargo check` to verify code health without deleting the live binary.\n\
440             original build output:\n{}\n\
441             fallback command: {}\n{}",
442            profile_name,
443            action,
444            original_command,
445            original_output.trim(),
446            fallback_command,
447            fallback_output.trim()
448        ))
449    } else {
450        Err(format!(
451            "BUILD FAILED [{}:{}]\ncommand: {}\n\
452             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
453             original build output:\n{}\n\
454             fallback command: {}\n{}",
455            profile_name,
456            action,
457            original_command,
458            original_output.trim(),
459            fallback_command,
460            fallback_output.trim()
461        ))
462    }
463}
464
465fn should_fallback_to_cargo_check(action: &str, command: &str, output: &str) -> bool {
466    if action != "build" || command.trim() != "cargo build --color never" {
467        return false;
468    }
469
470    if cfg!(windows) {
471        looks_like_windows_self_hosted_build_lock(output)
472    } else {
473        false
474    }
475}
476
477fn looks_like_windows_self_hosted_build_lock(output: &str) -> bool {
478    let lower = output.to_ascii_lowercase();
479    lower.contains("failed to remove file")
480        && lower.contains("target\\debug\\hematite.exe")
481        && (lower.contains("access is denied")
482            || lower.contains("being used by another process")
483            || lower.contains("permission denied"))
484}
485
486async fn run_windows_self_hosted_check_fallback(
487    profile_name: &str,
488    action: &str,
489    original_command: &str,
490    timeout_secs: u64,
491    original_output: &str,
492) -> Result<String, String> {
493    let fallback_command = "cargo check --color never";
494    let fallback_output = crate::tools::shell::execute(&serde_json::json!({
495        "command": fallback_command,
496        "timeout_secs": timeout_secs,
497        "reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
498    }), 16384)
499    .await?;
500
501    if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
502        Ok(format!(
503            "BUILD OK [{}:{}]\ncommand: {}\n\
504             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, so Hematite fell back to `cargo check` to verify code health without deleting the live binary.\n\
505             original build output:\n{}\n\
506             fallback command: {}\n{}",
507            profile_name,
508            action,
509            original_command,
510            original_output.trim(),
511            fallback_command,
512            fallback_output.trim()
513        ))
514    } else {
515        Err(format!(
516            "BUILD FAILED [{}:{}]\ncommand: {}\n\
517             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
518             original build output:\n{}\n\
519             fallback command: {}\n{}",
520            profile_name,
521            action,
522            original_command,
523            original_output.trim(),
524            fallback_command,
525            fallback_output.trim()
526        ))
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn detects_windows_self_hosted_build_lock_pattern() {
536        let sample = "[stderr] error: failed to remove file `C:\\Users\\ocean\\AntigravityProjects\\Hematite-CLI\\target\\debug\\hematite.exe`\r\nAccess is denied. (os error 5)";
537        assert!(looks_like_windows_self_hosted_build_lock(sample));
538    }
539
540    #[test]
541    fn ignores_unrelated_build_failures() {
542        let sample = "[stderr] error[E0425]: cannot find value `foo` in this scope";
543        assert!(!looks_like_windows_self_hosted_build_lock(sample));
544        assert!(!should_fallback_to_cargo_check(
545            "build",
546            "cargo build --color never",
547            sample
548        ));
549    }
550
551    #[test]
552    fn autodetect_rust_stack() {
553        let dir = tempfile::tempdir().unwrap();
554        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
555        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
556        assert_eq!(label, "Rust/Cargo");
557        assert!(cmd.contains("cargo build"));
558        let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
559        assert!(test_cmd.contains("cargo test"));
560        let (_, lint_cmd, _) = autodetect_command(dir.path(), "lint", None).unwrap();
561        assert!(lint_cmd.contains("clippy"));
562    }
563
564    #[test]
565    fn autodetect_go_stack() {
566        let dir = tempfile::tempdir().unwrap();
567        std::fs::write(
568            dir.path().join("go.mod"),
569            "module example.com/foo\ngo 1.21\n",
570        )
571        .unwrap();
572        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
573        assert_eq!(label, "Go");
574        assert!(cmd.contains("go build"));
575        let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
576        assert!(test_cmd.contains("go test"));
577        let (_, lint_cmd, _) = autodetect_command(dir.path(), "lint", None).unwrap();
578        assert!(lint_cmd.contains("go vet"));
579    }
580
581    #[test]
582    fn autodetect_cmake_stack() {
583        let dir = tempfile::tempdir().unwrap();
584        std::fs::write(
585            dir.path().join("CMakeLists.txt"),
586            "cmake_minimum_required(VERSION 3.20)\n",
587        )
588        .unwrap();
589        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
590        assert_eq!(label, "C++/CMake");
591        assert!(cmd.contains("cmake"));
592        assert!(cmd.contains("--build"));
593    }
594
595    #[test]
596    fn autodetect_node_npm_stack() {
597        let dir = tempfile::tempdir().unwrap();
598        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
599        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
600        assert!(label.contains("Node") || label.contains("TypeScript"));
601        assert!(cmd.contains("npm run build"));
602    }
603
604    #[test]
605    fn autodetect_node_yarn_stack() {
606        let dir = tempfile::tempdir().unwrap();
607        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
608        std::fs::write(dir.path().join("yarn.lock"), "").unwrap();
609        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
610        assert!(label.contains("yarn"));
611        assert!(cmd.contains("yarn run build"));
612    }
613
614    #[test]
615    fn autodetect_node_pnpm_stack() {
616        let dir = tempfile::tempdir().unwrap();
617        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
618        std::fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
619        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
620        assert!(label.contains("pnpm"));
621        assert!(cmd.contains("pnpm run build"));
622    }
623
624    #[test]
625    fn autodetect_python_stack_pyproject() {
626        let dir = tempfile::tempdir().unwrap();
627        std::fs::write(dir.path().join("pyproject.toml"), "[build-system]\n").unwrap();
628        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
629        assert_eq!(label, "Python");
630        assert!(cmd.contains("compileall"));
631        let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
632        assert!(test_cmd.contains("pytest"));
633    }
634
635    #[test]
636    fn autodetect_python_stack_requirements() {
637        let dir = tempfile::tempdir().unwrap();
638        std::fs::write(dir.path().join("requirements.txt"), "fastapi\n").unwrap();
639        let (label, _, _) = autodetect_command(dir.path(), "build", None).unwrap();
640        assert_eq!(label, "Python");
641    }
642
643    #[test]
644    fn resolves_local_venv_python() {
645        let dir = tempfile::tempdir().unwrap();
646        let venv = dir.path().join(".venv");
647        std::fs::create_dir(&venv).unwrap();
648
649        // Mock the python executable
650        let bin_sub = if cfg!(windows) { "Scripts" } else { "bin" };
651        let exe_name = if cfg!(windows) {
652            "python.exe"
653        } else {
654            "python"
655        };
656        let bin_dir = venv.join(bin_sub);
657        std::fs::create_dir(&bin_dir).unwrap();
658        std::fs::write(bin_dir.join(exe_name), "").unwrap();
659
660        let cmd = resolve_python_cmd(dir.path());
661        assert!(cmd.contains(".venv"));
662        assert!(cmd.contains(bin_sub));
663    }
664
665    #[test]
666    fn resolves_poetry_run() {
667        let dir = tempfile::tempdir().unwrap();
668        std::fs::write(dir.path().join("poetry.lock"), "").unwrap();
669        let cmd = resolve_python_cmd(dir.path());
670        assert_eq!(cmd, "poetry run python");
671    }
672
673    #[test]
674    fn autodetect_no_project_returns_err() {
675        let dir = tempfile::tempdir().unwrap();
676        let result = autodetect_command(dir.path(), "build", None);
677        assert!(result.is_err());
678        let msg = result.unwrap_err();
679        assert!(msg.contains("No recognized project root"));
680        assert!(msg.contains("Cargo.toml"));
681        assert!(msg.contains("CMakeLists.txt"));
682    }
683}