Skip to main content

hematite/tools/
verify_build.rs

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