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 = if cwd.join("build").exists() {
174            "build"
175        } else {
176            "build"
177        };
178        match action {
179            "build" => (
180                "C++/CMake",
181                format!("cmake -B {build_dir} -DCMAKE_BUILD_TYPE=Release && cmake --build {build_dir} --parallel"),
182            ),
183            "test" => (
184                "C++/CMake",
185                format!("ctest --test-dir {build_dir} --output-on-failure"),
186            ),
187            "lint" => return Err(missing_profile_msg("C++/CMake", action)),
188            "fix"  => return Err(missing_profile_msg("C++/CMake", action)),
189            _ => return Err(unknown_action(action)),
190        }
191    } else if cwd.join("package.json").exists() {
192        // Detect package manager: pnpm > yarn > bun > npm
193        let pm = if cwd.join("pnpm-lock.yaml").exists()
194            || cwd.join(".npmrc").exists() && {
195                let rc = std::fs::read_to_string(cwd.join(".npmrc")).unwrap_or_default();
196                rc.contains("pnpm")
197            } {
198            "pnpm"
199        } else if cwd.join("yarn.lock").exists() {
200            "yarn"
201        } else if cwd.join("bun.lockb").exists() {
202            "bun"
203        } else {
204            "npm"
205        };
206        // Detect TypeScript project for better label
207        let label: &'static str = if cwd.join("tsconfig.json").exists() {
208            match pm {
209                "pnpm" => "TypeScript/pnpm",
210                "yarn" => "TypeScript/yarn",
211                "bun" => "TypeScript/bun",
212                _ => "TypeScript/npm",
213            }
214        } else {
215            match pm {
216                "pnpm" => "Node/pnpm",
217                "yarn" => "Node/yarn",
218                "bun" => "Node/bun",
219                _ => "Node/npm",
220            }
221        };
222        match action {
223            "build" => (label, format!("{pm} run build")),
224            "test" => (label, format!("{pm} test")),
225            "lint" => (label, format!("{pm} run lint")),
226            "fix" => (label, format!("{pm} run format")),
227            _ => return Err(unknown_action(action)),
228        }
229    } else if cwd.join("pyproject.toml").exists()
230        || cwd.join("setup.py").exists()
231        || cwd.join("requirements.txt").exists()
232        || cwd.join(".venv").is_dir()
233        || cwd.join("venv").is_dir()
234        || cwd.join("env").is_dir()
235    {
236        // Python — prefer ruff when available, fall back to flake8/black/pytest
237        // Prioritize local environment (Poetry, Pipenv, .venv)
238        let py = resolve_python_cmd(cwd);
239        match action {
240            "build" => ("Python", format!("{py} -m compileall -q .")),
241            "test" => ("Python", format!("{py} -m pytest -q")),
242            "lint" => (
243                "Python",
244                format!("{py} -m ruff check . || {py} -m flake8 ."),
245            ),
246            "fix" => (
247                "Python",
248                format!("{py} -m ruff format . || {py} -m black ."),
249            ),
250            _ => return Err(unknown_action(action)),
251        }
252    } else if cwd.join("tsconfig.json").exists() {
253        // TypeScript without package.json — bare tsc check
254        match action {
255            "build" => ("TypeScript/tsc", "tsc --noEmit".to_string()),
256            "test" => return Err(missing_profile_msg("TypeScript/tsc", action)),
257            "lint" => return Err(missing_profile_msg("TypeScript/tsc", action)),
258            "fix" => return Err(missing_profile_msg("TypeScript/tsc", action)),
259            _ => return Err(unknown_action(action)),
260        }
261    } else if cwd.join("index.html").exists() {
262        match action {
263            "build" => ("Static Web", "echo \"BUILD OK (Static assets ready)\"".to_string()),
264            "test" => (
265                "Static Web",
266                "echo \"TEST OK (No test runner found; manual visual check and link verification suggested)\"".to_string(),
267            ),
268            "lint" => ("Static Web", "echo \"LINT OK (Basic structure verified)\"".to_string()),
269            "fix" => ("Static Web", "echo \"FIX OK (No auto-formatter found for static assets)\"".to_string()),
270            _ => return Err(unknown_action(action)),
271        }
272    } else {
273        return Err(format!(
274            "No recognized project root (Cargo.toml, package.json, go.mod, CMakeLists.txt, pyproject.toml, etc.) \
275             found in {}.\nUse an explicit profile or configure a default verify profile in `.hematite/settings.json`.",
276            cwd.display()
277        ));
278    };
279
280    Ok((command.0, command.1, timeout_secs))
281}
282
283fn resolve_python_cmd(cwd: &std::path::Path) -> String {
284    let config = config::load_config();
285
286    if let Some(path) = config.python_path {
287        if std::path::Path::new(&path).exists() {
288            return path;
289        }
290    }
291
292    if cwd.join("poetry.lock").exists() {
293        return "poetry run python".to_string();
294    }
295    if cwd.join("Pipfile.lock").exists() || cwd.join("Pipfile").exists() {
296        return "pipenv run python".to_string();
297    }
298    let venv_folders = [".venv", "venv", "env"];
299    for folder in venv_folders {
300        if cwd.join(folder).is_dir() {
301            let rel_path = if cfg!(windows) {
302                format!("{}\\Scripts\\python.exe", folder)
303            } else {
304                format!("{}/bin/python", folder)
305            };
306            if cwd.join(&rel_path).exists() {
307                return format!(".{}{}", if cfg!(windows) { "\\" } else { "/" }, rel_path);
308            }
309        }
310    }
311
312    if cfg!(windows) {
313        let check = Command::new("where").arg("py").output();
314        if check.map(|o| o.status.success()).unwrap_or(false) {
315            return "py -3".to_string();
316        }
317    }
318    "python".to_string()
319}
320
321fn missing_profile_msg(stack: &str, action: &str) -> String {
322    format!(
323        "No auto-detected `{action}` command for [{stack}].\n\
324         Add a verify profile in `.hematite/settings.json` if you want Hematite to run `{action}` for this project."
325    )
326}
327
328fn unknown_action(action: &str) -> String {
329    format!(
330        "Unknown verify_build action `{}`. Use one of: build, test, lint, fix.",
331        action
332    )
333}
334
335async fn run_profile_command(
336    profile_name: &str,
337    action: &str,
338    command: &str,
339    timeout_secs: u64,
340) -> Result<String, String> {
341    let output = crate::tools::shell::execute(
342        &serde_json::json!({
343            "command": command,
344            "timeout_secs": timeout_secs,
345            "reason": format!("verify_build:{}:{}", profile_name, action),
346        }),
347        16384,
348    )
349    .await?;
350
351    if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
352        Ok(format!(
353            "BUILD OK [{}:{}]\ncommand: {}\n{}",
354            profile_name,
355            action,
356            command,
357            output.trim()
358        ))
359    } else if should_fallback_to_cargo_check(action, command, &output) {
360        run_windows_self_hosted_check_fallback(profile_name, action, command, timeout_secs, &output)
361            .await
362    } else {
363        Err(format!(
364            "BUILD FAILED [{}:{}]\ncommand: {}\n{}",
365            profile_name,
366            action,
367            command,
368            output.trim()
369        ))
370    }
371}
372
373async fn run_profile_command_streaming(
374    profile_name: &str,
375    action: &str,
376    command: &str,
377    timeout_secs: u64,
378    tx: mpsc::Sender<InferenceEvent>,
379) -> Result<String, String> {
380    let output = crate::tools::shell::execute_streaming(
381        &serde_json::json!({
382            "command": command,
383            "timeout_secs": timeout_secs,
384            "reason": format!("verify_build:{}:{}", profile_name, action),
385        }),
386        tx.clone(),
387        16384,
388    )
389    .await?;
390
391    if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
392        Ok(format!(
393            "BUILD OK [{}:{}]\ncommand: {}\n{}",
394            profile_name,
395            action,
396            command,
397            output.trim()
398        ))
399    } else if should_fallback_to_cargo_check(action, command, &output) {
400        run_windows_self_hosted_check_fallback_streaming(
401            profile_name,
402            action,
403            command,
404            timeout_secs,
405            &output,
406            tx,
407        )
408        .await
409    } else {
410        Err(format!(
411            "BUILD FAILED [{}:{}]\ncommand: {}\n{}",
412            profile_name,
413            action,
414            command,
415            output.trim()
416        ))
417    }
418}
419
420async fn run_windows_self_hosted_check_fallback_streaming(
421    profile_name: &str,
422    action: &str,
423    original_command: &str,
424    timeout_secs: u64,
425    original_output: &str,
426    tx: mpsc::Sender<InferenceEvent>,
427) -> Result<String, String> {
428    let fallback_command = "cargo check --color never";
429    let fallback_output = crate::tools::shell::execute_streaming(
430        &serde_json::json!({
431            "command": fallback_command,
432            "timeout_secs": timeout_secs,
433            "reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
434        }),
435        tx,
436        16384,
437    )
438    .await?;
439
440    if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
441        Ok(format!(
442            "BUILD OK [{}:{}]\ncommand: {}\n\
443             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\
444             original build output:\n{}\n\
445             fallback command: {}\n{}",
446            profile_name,
447            action,
448            original_command,
449            original_output.trim(),
450            fallback_command,
451            fallback_output.trim()
452        ))
453    } else {
454        Err(format!(
455            "BUILD FAILED [{}:{}]\ncommand: {}\n\
456             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
457             original build output:\n{}\n\
458             fallback command: {}\n{}",
459            profile_name,
460            action,
461            original_command,
462            original_output.trim(),
463            fallback_command,
464            fallback_output.trim()
465        ))
466    }
467}
468
469fn should_fallback_to_cargo_check(action: &str, command: &str, output: &str) -> bool {
470    if action != "build" || command.trim() != "cargo build --color never" {
471        return false;
472    }
473
474    if cfg!(windows) {
475        looks_like_windows_self_hosted_build_lock(output)
476    } else {
477        false
478    }
479}
480
481fn looks_like_windows_self_hosted_build_lock(output: &str) -> bool {
482    let lower = output.to_ascii_lowercase();
483    lower.contains("failed to remove file")
484        && lower.contains("target\\debug\\hematite.exe")
485        && (lower.contains("access is denied")
486            || lower.contains("being used by another process")
487            || lower.contains("permission denied"))
488}
489
490async fn run_windows_self_hosted_check_fallback(
491    profile_name: &str,
492    action: &str,
493    original_command: &str,
494    timeout_secs: u64,
495    original_output: &str,
496) -> Result<String, String> {
497    let fallback_command = "cargo check --color never";
498    let fallback_output = crate::tools::shell::execute(&serde_json::json!({
499        "command": fallback_command,
500        "timeout_secs": timeout_secs,
501        "reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
502    }), 16384)
503    .await?;
504
505    if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
506        Ok(format!(
507            "BUILD OK [{}:{}]\ncommand: {}\n\
508             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\
509             original build output:\n{}\n\
510             fallback command: {}\n{}",
511            profile_name,
512            action,
513            original_command,
514            original_output.trim(),
515            fallback_command,
516            fallback_output.trim()
517        ))
518    } else {
519        Err(format!(
520            "BUILD FAILED [{}:{}]\ncommand: {}\n\
521             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
522             original build output:\n{}\n\
523             fallback command: {}\n{}",
524            profile_name,
525            action,
526            original_command,
527            original_output.trim(),
528            fallback_command,
529            fallback_output.trim()
530        ))
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn detects_windows_self_hosted_build_lock_pattern() {
540        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)";
541        assert!(looks_like_windows_self_hosted_build_lock(sample));
542    }
543
544    #[test]
545    fn ignores_unrelated_build_failures() {
546        let sample = "[stderr] error[E0425]: cannot find value `foo` in this scope";
547        assert!(!looks_like_windows_self_hosted_build_lock(sample));
548        assert!(!should_fallback_to_cargo_check(
549            "build",
550            "cargo build --color never",
551            sample
552        ));
553    }
554
555    #[test]
556    fn autodetect_rust_stack() {
557        let dir = tempfile::tempdir().unwrap();
558        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
559        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
560        assert_eq!(label, "Rust/Cargo");
561        assert!(cmd.contains("cargo build"));
562        let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
563        assert!(test_cmd.contains("cargo test"));
564        let (_, lint_cmd, _) = autodetect_command(dir.path(), "lint", None).unwrap();
565        assert!(lint_cmd.contains("clippy"));
566    }
567
568    #[test]
569    fn autodetect_go_stack() {
570        let dir = tempfile::tempdir().unwrap();
571        std::fs::write(
572            dir.path().join("go.mod"),
573            "module example.com/foo\ngo 1.21\n",
574        )
575        .unwrap();
576        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
577        assert_eq!(label, "Go");
578        assert!(cmd.contains("go build"));
579        let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
580        assert!(test_cmd.contains("go test"));
581        let (_, lint_cmd, _) = autodetect_command(dir.path(), "lint", None).unwrap();
582        assert!(lint_cmd.contains("go vet"));
583    }
584
585    #[test]
586    fn autodetect_cmake_stack() {
587        let dir = tempfile::tempdir().unwrap();
588        std::fs::write(
589            dir.path().join("CMakeLists.txt"),
590            "cmake_minimum_required(VERSION 3.20)\n",
591        )
592        .unwrap();
593        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
594        assert_eq!(label, "C++/CMake");
595        assert!(cmd.contains("cmake"));
596        assert!(cmd.contains("--build"));
597    }
598
599    #[test]
600    fn autodetect_node_npm_stack() {
601        let dir = tempfile::tempdir().unwrap();
602        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
603        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
604        assert!(label.contains("Node") || label.contains("TypeScript"));
605        assert!(cmd.contains("npm run build"));
606    }
607
608    #[test]
609    fn autodetect_node_yarn_stack() {
610        let dir = tempfile::tempdir().unwrap();
611        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
612        std::fs::write(dir.path().join("yarn.lock"), "").unwrap();
613        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
614        assert!(label.contains("yarn"));
615        assert!(cmd.contains("yarn run build"));
616    }
617
618    #[test]
619    fn autodetect_node_pnpm_stack() {
620        let dir = tempfile::tempdir().unwrap();
621        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
622        std::fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
623        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
624        assert!(label.contains("pnpm"));
625        assert!(cmd.contains("pnpm run build"));
626    }
627
628    #[test]
629    fn autodetect_python_stack_pyproject() {
630        let dir = tempfile::tempdir().unwrap();
631        std::fs::write(dir.path().join("pyproject.toml"), "[build-system]\n").unwrap();
632        let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
633        assert_eq!(label, "Python");
634        assert!(cmd.contains("compileall"));
635        let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
636        assert!(test_cmd.contains("pytest"));
637    }
638
639    #[test]
640    fn autodetect_python_stack_requirements() {
641        let dir = tempfile::tempdir().unwrap();
642        std::fs::write(dir.path().join("requirements.txt"), "fastapi\n").unwrap();
643        let (label, _, _) = autodetect_command(dir.path(), "build", None).unwrap();
644        assert_eq!(label, "Python");
645    }
646
647    #[test]
648    fn resolves_local_venv_python() {
649        let dir = tempfile::tempdir().unwrap();
650        let venv = dir.path().join(".venv");
651        std::fs::create_dir(&venv).unwrap();
652
653        // Mock the python executable
654        let bin_sub = if cfg!(windows) { "Scripts" } else { "bin" };
655        let exe_name = if cfg!(windows) {
656            "python.exe"
657        } else {
658            "python"
659        };
660        let bin_dir = venv.join(bin_sub);
661        std::fs::create_dir(&bin_dir).unwrap();
662        std::fs::write(bin_dir.join(exe_name), "").unwrap();
663
664        let cmd = resolve_python_cmd(dir.path());
665        assert!(cmd.contains(".venv"));
666        assert!(cmd.contains(bin_sub));
667    }
668
669    #[test]
670    fn resolves_poetry_run() {
671        let dir = tempfile::tempdir().unwrap();
672        std::fs::write(dir.path().join("poetry.lock"), "").unwrap();
673        let cmd = resolve_python_cmd(dir.path());
674        assert_eq!(cmd, "poetry run python");
675    }
676
677    #[test]
678    fn autodetect_no_project_returns_err() {
679        let dir = tempfile::tempdir().unwrap();
680        let result = autodetect_command(dir.path(), "build", None);
681        assert!(result.is_err());
682        let msg = result.unwrap_err();
683        assert!(msg.contains("No recognized project root"));
684        assert!(msg.contains("Cargo.toml"));
685        assert!(msg.contains("CMakeLists.txt"));
686    }
687}