Skip to main content

hematite/tools/
verify_build.rs

1use crate::agent::config;
2use serde_json::Value;
3
4const BUILD_TIMEOUT_SECS: u64 = 120;
5
6pub async fn execute(args: &Value) -> Result<String, String> {
7    let cwd =
8        std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
9    let action = args
10        .get("action")
11        .and_then(|v| v.as_str())
12        .unwrap_or("build");
13    let explicit_profile = args.get("profile").and_then(|v| v.as_str());
14    let timeout_override = args.get("timeout_secs").and_then(|v| v.as_u64());
15
16    let config = config::load_config();
17    if let Some(profile_name) = explicit_profile {
18        let profile = config.verify.profiles.get(profile_name).ok_or_else(|| {
19            format!(
20                "Unknown verify profile `{}`. Define it in `.hematite/settings.json` or omit the profile argument.",
21                profile_name
22            )
23        })?;
24        if let Some(command) = profile_command(profile, action) {
25            let timeout_secs = timeout_override
26                .or(profile.timeout_secs)
27                .unwrap_or(BUILD_TIMEOUT_SECS);
28            return run_profile_command(profile_name, action, command, timeout_secs).await;
29        }
30
31        return Err(format!(
32            "VERIFY PROFILE MISSING [{profile_name}] action `{action}`.\n\
33             Configure `.hematite/settings.json` with a `{action}` command for this profile, \
34             or call `verify_build` with a different action/profile."
35        ));
36    }
37
38    if let Some(default_profile) = config.verify.default_profile.as_deref() {
39        let profile = config.verify.profiles.get(default_profile).ok_or_else(|| {
40            format!(
41                "Configured default verify profile `{}` was not found in `.hematite/settings.json`.",
42                default_profile
43            )
44        })?;
45        if let Some(command) = profile_command(profile, action) {
46            let timeout_secs = timeout_override
47                .or(profile.timeout_secs)
48                .unwrap_or(BUILD_TIMEOUT_SECS);
49            return run_profile_command(default_profile, action, command, timeout_secs).await;
50        }
51
52        return Err(format!(
53            "VERIFY PROFILE MISSING [{default_profile}] action `{action}`.\n\
54             Configure `.hematite/settings.json` with a `{action}` command for the default profile, \
55             or call `verify_build` with an explicit profile."
56        ));
57    }
58
59    let (label, command, timeout_secs) = autodetect_command(&cwd, action, timeout_override)?;
60    run_profile_command(label, action, &command, timeout_secs).await
61}
62
63fn profile_command<'a>(profile: &'a config::VerifyProfile, action: &str) -> Option<&'a str> {
64    match action {
65        "build" => profile.build.as_deref(),
66        "test" => profile.test.as_deref(),
67        "lint" => profile.lint.as_deref(),
68        "fix" => profile.fix.as_deref(),
69        _ => None,
70    }
71}
72
73fn autodetect_command(
74    cwd: &std::path::Path,
75    action: &str,
76    timeout_override: Option<u64>,
77) -> Result<(&'static str, String, u64), String> {
78    let timeout_secs = timeout_override.unwrap_or(BUILD_TIMEOUT_SECS);
79    let command = if cwd.join("Cargo.toml").exists() {
80        match action {
81            "build" => ("Rust/Cargo", "cargo build --color never".to_string()),
82            "test" => ("Rust/Cargo", "cargo test --color never".to_string()),
83            "lint" => (
84                "Rust/Cargo",
85                "cargo clippy --all-targets --all-features -- -D warnings".to_string(),
86            ),
87            "fix" => ("Rust/Cargo", "cargo fmt".to_string()),
88            _ => return Err(unknown_action(action)),
89        }
90    } else if cwd.join("package.json").exists() {
91        match action {
92            "build" => ("Node/npm", "npm run build --if-present".to_string()),
93            "test" => ("Node/npm", "npm test --if-present".to_string()),
94            "lint" => ("Node/npm", "npm run lint --if-present".to_string()),
95            "fix" => return Err(missing_profile_msg("Node/npm", action)),
96            _ => return Err(unknown_action(action)),
97        }
98    } else if cwd.join("pyproject.toml").exists() || cwd.join("setup.py").exists() {
99        match action {
100            "build" => ("Python", "python -m compileall .".to_string()),
101            "test" => return Err(missing_profile_msg("Python", action)),
102            "lint" => return Err(missing_profile_msg("Python", action)),
103            "fix" => return Err(missing_profile_msg("Python", action)),
104            _ => return Err(unknown_action(action)),
105        }
106    } else if cwd.join("go.mod").exists() {
107        match action {
108            "build" => ("Go", "go build ./...".to_string()),
109            "test" => ("Go", "go test ./...".to_string()),
110            "lint" => return Err(missing_profile_msg("Go", action)),
111            "fix" => return Err(missing_profile_msg("Go", action)),
112            _ => return Err(unknown_action(action)),
113        }
114    } else {
115        return Err(
116            "No recognized project root found.\n\
117             Expected one of: Cargo.toml, package.json, pyproject.toml, go.mod\n\
118             Ensure you are in the project root directory or configure `.hematite/settings.json` verify profiles."
119                .into(),
120        );
121    };
122
123    Ok((command.0, command.1, timeout_secs))
124}
125
126fn missing_profile_msg(stack: &str, action: &str) -> String {
127    format!(
128        "No auto-detected `{action}` command for [{stack}].\n\
129         Add a verify profile in `.hematite/settings.json` if you want Hematite to run `{action}` for this project."
130    )
131}
132
133fn unknown_action(action: &str) -> String {
134    format!(
135        "Unknown verify_build action `{}`. Use one of: build, test, lint, fix.",
136        action
137    )
138}
139
140async fn run_profile_command(
141    profile_name: &str,
142    action: &str,
143    command: &str,
144    timeout_secs: u64,
145) -> Result<String, String> {
146    let output = crate::tools::shell::execute(&serde_json::json!({
147        "command": command,
148        "timeout_secs": timeout_secs,
149        "reason": format!("verify_build:{}:{}", profile_name, action),
150    }))
151    .await?;
152
153    if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
154        Ok(format!(
155            "BUILD OK [{}:{}]\ncommand: {}\n{}",
156            profile_name,
157            action,
158            command,
159            output.trim()
160        ))
161    } else if should_fallback_to_cargo_check(action, command, &output) {
162        run_windows_self_hosted_check_fallback(profile_name, action, command, timeout_secs, &output)
163            .await
164    } else {
165        Err(format!(
166            "BUILD FAILED [{}:{}]\ncommand: {}\n{}",
167            profile_name,
168            action,
169            command,
170            output.trim()
171        ))
172    }
173}
174
175fn should_fallback_to_cargo_check(action: &str, command: &str, output: &str) -> bool {
176    if action != "build" || command.trim() != "cargo build --color never" {
177        return false;
178    }
179
180    if cfg!(windows) {
181        looks_like_windows_self_hosted_build_lock(output)
182    } else {
183        false
184    }
185}
186
187fn looks_like_windows_self_hosted_build_lock(output: &str) -> bool {
188    let lower = output.to_ascii_lowercase();
189    lower.contains("failed to remove file")
190        && lower.contains("target\\debug\\hematite.exe")
191        && (lower.contains("access is denied")
192            || lower.contains("being used by another process")
193            || lower.contains("permission denied"))
194}
195
196async fn run_windows_self_hosted_check_fallback(
197    profile_name: &str,
198    action: &str,
199    original_command: &str,
200    timeout_secs: u64,
201    original_output: &str,
202) -> Result<String, String> {
203    let fallback_command = "cargo check --color never";
204    let fallback_output = crate::tools::shell::execute(&serde_json::json!({
205        "command": fallback_command,
206        "timeout_secs": timeout_secs,
207        "reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
208    }))
209    .await?;
210
211    if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
212        Ok(format!(
213            "BUILD OK [{}:{}]\ncommand: {}\n\
214             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\
215             original build output:\n{}\n\
216             fallback command: {}\n{}",
217            profile_name,
218            action,
219            original_command,
220            original_output.trim(),
221            fallback_command,
222            fallback_output.trim()
223        ))
224    } else {
225        Err(format!(
226            "BUILD FAILED [{}:{}]\ncommand: {}\n\
227             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
228             original build output:\n{}\n\
229             fallback command: {}\n{}",
230            profile_name,
231            action,
232            original_command,
233            original_output.trim(),
234            fallback_command,
235            fallback_output.trim()
236        ))
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn detects_windows_self_hosted_build_lock_pattern() {
246        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)";
247        assert!(looks_like_windows_self_hosted_build_lock(sample));
248    }
249
250    #[test]
251    fn ignores_unrelated_build_failures() {
252        let sample = "[stderr] error[E0425]: cannot find value `foo` in this scope";
253        assert!(!looks_like_windows_self_hosted_build_lock(sample));
254        assert!(!should_fallback_to_cargo_check(
255            "build",
256            "cargo build --color never",
257            sample
258        ));
259    }
260}