use crate::agent::config;
use crate::agent::inference::InferenceEvent;
use serde_json::Value;
use std::process::Command;
use tokio::sync::mpsc;
const BUILD_TIMEOUT_SECS: u64 = 120;
pub async fn execute_streaming(
args: &Value,
tx: mpsc::Sender<InferenceEvent>,
) -> Result<String, String> {
let cwd =
std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
let action = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("build");
let explicit_profile = args.get("profile").and_then(|v| v.as_str());
let timeout_override = args.get("timeout_secs").and_then(|v| v.as_u64());
let config = config::load_config();
if let Some(profile_name) = explicit_profile {
let profile = config.verify.profiles.get(profile_name).ok_or_else(|| {
format!(
"Unknown verify profile `{}`. Define it in `.hematite/settings.json` or omit the profile argument.",
profile_name
)
})?;
if let Some(command) = profile_command(profile, action) {
let timeout_secs = timeout_override
.or(profile.timeout_secs)
.unwrap_or(BUILD_TIMEOUT_SECS);
return run_profile_command_streaming(profile_name, action, command, timeout_secs, tx)
.await;
}
return Err(format!(
"VERIFY PROFILE MISSING [{profile_name}] action `{action}`.\n\
Configure `.hematite/settings.json` with a `{action}` command for this profile, \
or call `verify_build` with a different action/profile."
));
}
if let Some(default_profile) = config.verify.default_profile.as_deref() {
let profile = config.verify.profiles.get(default_profile).ok_or_else(|| {
format!(
"Configured default verify profile `{}` was not found in `.hematite/settings.json`.",
default_profile
)
})?;
if let Some(command) = profile_command(profile, action) {
let timeout_secs = timeout_override
.or(profile.timeout_secs)
.unwrap_or(BUILD_TIMEOUT_SECS);
return run_profile_command_streaming(
default_profile,
action,
command,
timeout_secs,
tx,
)
.await;
}
return Err(format!(
"VERIFY PROFILE MISSING [{default_profile}] action `{action}`.\n\
Configure `.hematite/settings.json` with a `{action}` command for the default profile, \
or call `verify_build` with an explicit profile."
));
}
let (label, command, timeout_secs) = autodetect_command(&cwd, action, timeout_override)?;
run_profile_command_streaming(label, action, &command, timeout_secs, tx).await
}
pub async fn execute(args: &Value) -> Result<String, String> {
let cwd =
std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
let action = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("build");
let explicit_profile = args.get("profile").and_then(|v| v.as_str());
let timeout_override = args.get("timeout_secs").and_then(|v| v.as_u64());
let config = config::load_config();
if let Some(profile_name) = explicit_profile {
let profile = config.verify.profiles.get(profile_name).ok_or_else(|| {
format!(
"Unknown verify profile `{}`. Define it in `.hematite/settings.json` or omit the profile argument.",
profile_name
)
})?;
if let Some(command) = profile_command(profile, action) {
let timeout_secs = timeout_override
.or(profile.timeout_secs)
.unwrap_or(BUILD_TIMEOUT_SECS);
return run_profile_command(profile_name, action, command, timeout_secs).await;
}
return Err(format!(
"VERIFY PROFILE MISSING [{profile_name}] action `{action}`.\n\
Configure `.hematite/settings.json` with a `{action}` command for this profile, \
or call `verify_build` with a different action/profile."
));
}
if let Some(default_profile) = config.verify.default_profile.as_deref() {
let profile = config.verify.profiles.get(default_profile).ok_or_else(|| {
format!(
"Configured default verify profile `{}` was not found in `.hematite/settings.json`.",
default_profile
)
})?;
if let Some(command) = profile_command(profile, action) {
let timeout_secs = timeout_override
.or(profile.timeout_secs)
.unwrap_or(BUILD_TIMEOUT_SECS);
return run_profile_command(default_profile, action, command, timeout_secs).await;
}
return Err(format!(
"VERIFY PROFILE MISSING [{default_profile}] action `{action}`.\n\
Configure `.hematite/settings.json` with a `{action}` command for the default profile, \
or call `verify_build` with an explicit profile."
));
}
let (label, command, timeout_secs) = autodetect_command(&cwd, action, timeout_override)?;
run_profile_command(label, action, &command, timeout_secs).await
}
fn profile_command<'a>(profile: &'a config::VerifyProfile, action: &str) -> Option<&'a str> {
match action {
"build" => profile.build.as_deref(),
"test" => profile.test.as_deref(),
"lint" => profile.lint.as_deref(),
"fix" => profile.fix.as_deref(),
_ => None,
}
}
fn autodetect_command(
cwd: &std::path::Path,
action: &str,
timeout_override: Option<u64>,
) -> Result<(&'static str, String, u64), String> {
let timeout_secs = timeout_override.unwrap_or(BUILD_TIMEOUT_SECS);
let command = if cwd.join("Cargo.toml").exists() {
match action {
"build" => ("Rust/Cargo", "cargo build --color never".to_string()),
"test" => ("Rust/Cargo", "cargo test --color never".to_string()),
"lint" => (
"Rust/Cargo",
"cargo clippy --all-targets --all-features -- -D warnings".to_string(),
),
"fix" => ("Rust/Cargo", "cargo fmt".to_string()),
_ => return Err(unknown_action(action)),
}
} else if cwd.join("go.mod").exists() {
match action {
"build" => ("Go", "go build ./...".to_string()),
"test" => ("Go", "go test ./...".to_string()),
"lint" => ("Go", "go vet ./...".to_string()),
"fix" => ("Go", "gofmt -w .".to_string()),
_ => return Err(unknown_action(action)),
}
} else if cwd.join("CMakeLists.txt").exists() {
let build_dir = "build";
match action {
"build" => (
"C++/CMake",
format!("cmake -B {build_dir} -DCMAKE_BUILD_TYPE=Release && cmake --build {build_dir} --parallel"),
),
"test" => (
"C++/CMake",
format!("ctest --test-dir {build_dir} --output-on-failure"),
),
"lint" => return Err(missing_profile_msg("C++/CMake", action)),
"fix" => return Err(missing_profile_msg("C++/CMake", action)),
_ => return Err(unknown_action(action)),
}
} else if cwd.join("package.json").exists() {
let pm = if cwd.join("pnpm-lock.yaml").exists()
|| cwd.join(".npmrc").exists() && {
let rc = std::fs::read_to_string(cwd.join(".npmrc")).unwrap_or_default();
rc.contains("pnpm")
} {
"pnpm"
} else if cwd.join("yarn.lock").exists() {
"yarn"
} else if cwd.join("bun.lockb").exists() {
"bun"
} else {
"npm"
};
let label: &'static str = if cwd.join("tsconfig.json").exists() {
match pm {
"pnpm" => "TypeScript/pnpm",
"yarn" => "TypeScript/yarn",
"bun" => "TypeScript/bun",
_ => "TypeScript/npm",
}
} else {
match pm {
"pnpm" => "Node/pnpm",
"yarn" => "Node/yarn",
"bun" => "Node/bun",
_ => "Node/npm",
}
};
match action {
"build" => (label, format!("{pm} run build")),
"test" => (label, format!("{pm} test")),
"lint" => (label, format!("{pm} run lint")),
"fix" => (label, format!("{pm} run format")),
_ => return Err(unknown_action(action)),
}
} else if cwd.join("pyproject.toml").exists()
|| cwd.join("setup.py").exists()
|| cwd.join("requirements.txt").exists()
|| cwd.join(".venv").is_dir()
|| cwd.join("venv").is_dir()
|| cwd.join("env").is_dir()
{
let py = resolve_python_cmd(cwd);
match action {
"build" => ("Python", format!("{py} -m compileall -q .")),
"test" => ("Python", format!("{py} -m pytest -q")),
"lint" => (
"Python",
format!("{py} -m ruff check . || {py} -m flake8 ."),
),
"fix" => (
"Python",
format!("{py} -m ruff format . || {py} -m black ."),
),
_ => return Err(unknown_action(action)),
}
} else if cwd.join("tsconfig.json").exists() {
match action {
"build" => ("TypeScript/tsc", "tsc --noEmit".to_string()),
"test" => return Err(missing_profile_msg("TypeScript/tsc", action)),
"lint" => return Err(missing_profile_msg("TypeScript/tsc", action)),
"fix" => return Err(missing_profile_msg("TypeScript/tsc", action)),
_ => return Err(unknown_action(action)),
}
} else if cwd.join("index.html").exists() {
match action {
"build" => ("Static Web", "echo \"BUILD OK (Static assets ready)\"".to_string()),
"test" => (
"Static Web",
"echo \"TEST OK (No test runner found; manual visual check and link verification suggested)\"".to_string(),
),
"lint" => ("Static Web", "echo \"LINT OK (Basic structure verified)\"".to_string()),
"fix" => ("Static Web", "echo \"FIX OK (No auto-formatter found for static assets)\"".to_string()),
_ => return Err(unknown_action(action)),
}
} else {
return Err(format!(
"No recognized project root (Cargo.toml, package.json, go.mod, CMakeLists.txt, pyproject.toml, etc.) \
found in {}.\nUse an explicit profile or configure a default verify profile in `.hematite/settings.json`.",
cwd.display()
));
};
Ok((command.0, command.1, timeout_secs))
}
fn resolve_python_cmd(cwd: &std::path::Path) -> String {
let config = config::load_config();
if let Some(path) = config.python_path {
if std::path::Path::new(&path).exists() {
return path;
}
}
if cwd.join("poetry.lock").exists() {
return "poetry run python".to_string();
}
if cwd.join("Pipfile.lock").exists() || cwd.join("Pipfile").exists() {
return "pipenv run python".to_string();
}
let venv_folders = [".venv", "venv", "env"];
for folder in venv_folders {
if cwd.join(folder).is_dir() {
let rel_path = if cfg!(windows) {
format!("{}\\Scripts\\python.exe", folder)
} else {
format!("{}/bin/python", folder)
};
if cwd.join(&rel_path).exists() {
return format!(".{}{}", if cfg!(windows) { "\\" } else { "/" }, rel_path);
}
}
}
if cfg!(windows) {
let check = Command::new("where").arg("py").output();
if check.map(|o| o.status.success()).unwrap_or(false) {
return "py -3".to_string();
}
}
"python".to_string()
}
fn missing_profile_msg(stack: &str, action: &str) -> String {
format!(
"No auto-detected `{action}` command for [{stack}].\n\
Add a verify profile in `.hematite/settings.json` if you want Hematite to run `{action}` for this project."
)
}
fn unknown_action(action: &str) -> String {
format!(
"Unknown verify_build action `{}`. Use one of: build, test, lint, fix.",
action
)
}
async fn run_profile_command(
profile_name: &str,
action: &str,
command: &str,
timeout_secs: u64,
) -> Result<String, String> {
let output = crate::tools::shell::execute(
&serde_json::json!({
"command": command,
"timeout_secs": timeout_secs,
"reason": format!("verify_build:{}:{}", profile_name, action),
}),
16384,
)
.await?;
if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
Ok(format!(
"BUILD OK [{}:{}]\ncommand: {}\n{}",
profile_name,
action,
command,
output.trim()
))
} else if should_fallback_to_cargo_check(action, command, &output) {
run_windows_self_hosted_check_fallback(profile_name, action, command, timeout_secs, &output)
.await
} else {
Err(format!(
"BUILD FAILED [{}:{}]\ncommand: {}\n{}",
profile_name,
action,
command,
output.trim()
))
}
}
async fn run_profile_command_streaming(
profile_name: &str,
action: &str,
command: &str,
timeout_secs: u64,
tx: mpsc::Sender<InferenceEvent>,
) -> Result<String, String> {
let output = crate::tools::shell::execute_streaming(
&serde_json::json!({
"command": command,
"timeout_secs": timeout_secs,
"reason": format!("verify_build:{}:{}", profile_name, action),
}),
tx.clone(),
16384,
)
.await?;
if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
Ok(format!(
"BUILD OK [{}:{}]\ncommand: {}\n{}",
profile_name,
action,
command,
output.trim()
))
} else if should_fallback_to_cargo_check(action, command, &output) {
run_windows_self_hosted_check_fallback_streaming(
profile_name,
action,
command,
timeout_secs,
&output,
tx,
)
.await
} else {
Err(format!(
"BUILD FAILED [{}:{}]\ncommand: {}\n{}",
profile_name,
action,
command,
output.trim()
))
}
}
async fn run_windows_self_hosted_check_fallback_streaming(
profile_name: &str,
action: &str,
original_command: &str,
timeout_secs: u64,
original_output: &str,
tx: mpsc::Sender<InferenceEvent>,
) -> Result<String, String> {
let fallback_command = "cargo check --color never";
let fallback_output = crate::tools::shell::execute_streaming(
&serde_json::json!({
"command": fallback_command,
"timeout_secs": timeout_secs,
"reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
}),
tx,
16384,
)
.await?;
if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
Ok(format!(
"BUILD OK [{}:{}]\ncommand: {}\n\
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\
original build output:\n{}\n\
fallback command: {}\n{}",
profile_name,
action,
original_command,
original_output.trim(),
fallback_command,
fallback_output.trim()
))
} else {
Err(format!(
"BUILD FAILED [{}:{}]\ncommand: {}\n\
Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
original build output:\n{}\n\
fallback command: {}\n{}",
profile_name,
action,
original_command,
original_output.trim(),
fallback_command,
fallback_output.trim()
))
}
}
fn should_fallback_to_cargo_check(action: &str, command: &str, output: &str) -> bool {
if action != "build" || command.trim() != "cargo build --color never" {
return false;
}
if cfg!(windows) {
looks_like_windows_self_hosted_build_lock(output)
} else {
false
}
}
fn looks_like_windows_self_hosted_build_lock(output: &str) -> bool {
let lower = output.to_ascii_lowercase();
lower.contains("failed to remove file")
&& lower.contains("target\\debug\\hematite.exe")
&& (lower.contains("access is denied")
|| lower.contains("being used by another process")
|| lower.contains("permission denied"))
}
async fn run_windows_self_hosted_check_fallback(
profile_name: &str,
action: &str,
original_command: &str,
timeout_secs: u64,
original_output: &str,
) -> Result<String, String> {
let fallback_command = "cargo check --color never";
let fallback_output = crate::tools::shell::execute(&serde_json::json!({
"command": fallback_command,
"timeout_secs": timeout_secs,
"reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
}), 16384)
.await?;
if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
Ok(format!(
"BUILD OK [{}:{}]\ncommand: {}\n\
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\
original build output:\n{}\n\
fallback command: {}\n{}",
profile_name,
action,
original_command,
original_output.trim(),
fallback_command,
fallback_output.trim()
))
} else {
Err(format!(
"BUILD FAILED [{}:{}]\ncommand: {}\n\
Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
original build output:\n{}\n\
fallback command: {}\n{}",
profile_name,
action,
original_command,
original_output.trim(),
fallback_command,
fallback_output.trim()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_windows_self_hosted_build_lock_pattern() {
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)";
assert!(looks_like_windows_self_hosted_build_lock(sample));
}
#[test]
fn ignores_unrelated_build_failures() {
let sample = "[stderr] error[E0425]: cannot find value `foo` in this scope";
assert!(!looks_like_windows_self_hosted_build_lock(sample));
assert!(!should_fallback_to_cargo_check(
"build",
"cargo build --color never",
sample
));
}
#[test]
fn autodetect_rust_stack() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert_eq!(label, "Rust/Cargo");
assert!(cmd.contains("cargo build"));
let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
assert!(test_cmd.contains("cargo test"));
let (_, lint_cmd, _) = autodetect_command(dir.path(), "lint", None).unwrap();
assert!(lint_cmd.contains("clippy"));
}
#[test]
fn autodetect_go_stack() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("go.mod"),
"module example.com/foo\ngo 1.21\n",
)
.unwrap();
let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert_eq!(label, "Go");
assert!(cmd.contains("go build"));
let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
assert!(test_cmd.contains("go test"));
let (_, lint_cmd, _) = autodetect_command(dir.path(), "lint", None).unwrap();
assert!(lint_cmd.contains("go vet"));
}
#[test]
fn autodetect_cmake_stack() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("CMakeLists.txt"),
"cmake_minimum_required(VERSION 3.20)\n",
)
.unwrap();
let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert_eq!(label, "C++/CMake");
assert!(cmd.contains("cmake"));
assert!(cmd.contains("--build"));
}
#[test]
fn autodetect_node_npm_stack() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert!(label.contains("Node") || label.contains("TypeScript"));
assert!(cmd.contains("npm run build"));
}
#[test]
fn autodetect_node_yarn_stack() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("yarn.lock"), "").unwrap();
let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert!(label.contains("yarn"));
assert!(cmd.contains("yarn run build"));
}
#[test]
fn autodetect_node_pnpm_stack() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert!(label.contains("pnpm"));
assert!(cmd.contains("pnpm run build"));
}
#[test]
fn autodetect_python_stack_pyproject() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "[build-system]\n").unwrap();
let (label, cmd, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert_eq!(label, "Python");
assert!(cmd.contains("compileall"));
let (_, test_cmd, _) = autodetect_command(dir.path(), "test", None).unwrap();
assert!(test_cmd.contains("pytest"));
}
#[test]
fn autodetect_python_stack_requirements() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("requirements.txt"), "fastapi\n").unwrap();
let (label, _, _) = autodetect_command(dir.path(), "build", None).unwrap();
assert_eq!(label, "Python");
}
#[test]
fn resolves_local_venv_python() {
let dir = tempfile::tempdir().unwrap();
let venv = dir.path().join(".venv");
std::fs::create_dir(&venv).unwrap();
let bin_sub = if cfg!(windows) { "Scripts" } else { "bin" };
let exe_name = if cfg!(windows) {
"python.exe"
} else {
"python"
};
let bin_dir = venv.join(bin_sub);
std::fs::create_dir(&bin_dir).unwrap();
std::fs::write(bin_dir.join(exe_name), "").unwrap();
let cmd = resolve_python_cmd(dir.path());
assert!(cmd.contains(".venv"));
assert!(cmd.contains(bin_sub));
}
#[test]
fn resolves_poetry_run() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("poetry.lock"), "").unwrap();
let cmd = resolve_python_cmd(dir.path());
assert_eq!(cmd, "poetry run python");
}
#[test]
fn autodetect_no_project_returns_err() {
let dir = tempfile::tempdir().unwrap();
let result = autodetect_command(dir.path(), "build", None);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("No recognized project root"));
assert!(msg.contains("Cargo.toml"));
assert!(msg.contains("CMakeLists.txt"));
}
}