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 = "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 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 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 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 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 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}