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
9pub 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 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 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 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 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 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 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}