Skip to main content

hematite/tools/
verify_build.rs

1use crate::agent::config;
2use crate::agent::inference::InferenceEvent;
3use serde_json::Value;
4use tokio::sync::mpsc;
5
6const BUILD_TIMEOUT_SECS: u64 = 120;
7
8/// Streaming variant — emits live shell lines to the SPECULAR panel while buffering
9/// the final combined output for the tool result returned to the model.
10pub async fn execute_streaming(
11    args: &Value,
12    tx: mpsc::Sender<InferenceEvent>,
13) -> Result<String, String> {
14    let cwd =
15        std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
16    let action = args
17        .get("action")
18        .and_then(|v| v.as_str())
19        .unwrap_or("build");
20    let explicit_profile = args.get("profile").and_then(|v| v.as_str());
21    let timeout_override = args.get("timeout_secs").and_then(|v| v.as_u64());
22
23    let config = config::load_config();
24    if let Some(profile_name) = explicit_profile {
25        let profile = config.verify.profiles.get(profile_name).ok_or_else(|| {
26            format!(
27                "Unknown verify profile `{}`. Define it in `.hematite/settings.json` or omit the profile argument.",
28                profile_name
29            )
30        })?;
31        if let Some(command) = profile_command(profile, action) {
32            let timeout_secs = timeout_override
33                .or(profile.timeout_secs)
34                .unwrap_or(BUILD_TIMEOUT_SECS);
35            return run_profile_command_streaming(profile_name, action, command, timeout_secs, tx)
36                .await;
37        }
38
39        return Err(format!(
40            "VERIFY PROFILE MISSING [{profile_name}] action `{action}`.\n\
41             Configure `.hematite/settings.json` with a `{action}` command for this profile, \
42             or call `verify_build` with a different action/profile."
43        ));
44    }
45
46    if let Some(default_profile) = config.verify.default_profile.as_deref() {
47        let profile = config.verify.profiles.get(default_profile).ok_or_else(|| {
48            format!(
49                "Configured default verify profile `{}` was not found in `.hematite/settings.json`.",
50                default_profile
51            )
52        })?;
53        if let Some(command) = profile_command(profile, action) {
54            let timeout_secs = timeout_override
55                .or(profile.timeout_secs)
56                .unwrap_or(BUILD_TIMEOUT_SECS);
57            return run_profile_command_streaming(
58                default_profile,
59                action,
60                command,
61                timeout_secs,
62                tx,
63            )
64            .await;
65        }
66
67        return Err(format!(
68            "VERIFY PROFILE MISSING [{default_profile}] action `{action}`.\n\
69             Configure `.hematite/settings.json` with a `{action}` command for the default profile, \
70             or call `verify_build` with an explicit profile."
71        ));
72    }
73
74    let (label, command, timeout_secs) = autodetect_command(&cwd, action, timeout_override)?;
75    run_profile_command_streaming(label, action, &command, timeout_secs, tx).await
76}
77
78pub async fn execute(args: &Value) -> Result<String, String> {
79    let cwd =
80        std::env::current_dir().map_err(|e| format!("Cannot determine working directory: {e}"))?;
81    let action = args
82        .get("action")
83        .and_then(|v| v.as_str())
84        .unwrap_or("build");
85    let explicit_profile = args.get("profile").and_then(|v| v.as_str());
86    let timeout_override = args.get("timeout_secs").and_then(|v| v.as_u64());
87
88    let config = config::load_config();
89    if let Some(profile_name) = explicit_profile {
90        let profile = config.verify.profiles.get(profile_name).ok_or_else(|| {
91            format!(
92                "Unknown verify profile `{}`. Define it in `.hematite/settings.json` or omit the profile argument.",
93                profile_name
94            )
95        })?;
96        if let Some(command) = profile_command(profile, action) {
97            let timeout_secs = timeout_override
98                .or(profile.timeout_secs)
99                .unwrap_or(BUILD_TIMEOUT_SECS);
100            return run_profile_command(profile_name, action, command, timeout_secs).await;
101        }
102
103        return Err(format!(
104            "VERIFY PROFILE MISSING [{profile_name}] action `{action}`.\n\
105             Configure `.hematite/settings.json` with a `{action}` command for this profile, \
106             or call `verify_build` with a different action/profile."
107        ));
108    }
109
110    if let Some(default_profile) = config.verify.default_profile.as_deref() {
111        let profile = config.verify.profiles.get(default_profile).ok_or_else(|| {
112            format!(
113                "Configured default verify profile `{}` was not found in `.hematite/settings.json`.",
114                default_profile
115            )
116        })?;
117        if let Some(command) = profile_command(profile, action) {
118            let timeout_secs = timeout_override
119                .or(profile.timeout_secs)
120                .unwrap_or(BUILD_TIMEOUT_SECS);
121            return run_profile_command(default_profile, action, command, timeout_secs).await;
122        }
123
124        return Err(format!(
125            "VERIFY PROFILE MISSING [{default_profile}] action `{action}`.\n\
126             Configure `.hematite/settings.json` with a `{action}` command for the default profile, \
127             or call `verify_build` with an explicit profile."
128        ));
129    }
130
131    let (label, command, timeout_secs) = autodetect_command(&cwd, action, timeout_override)?;
132    run_profile_command(label, action, &command, timeout_secs).await
133}
134
135fn profile_command<'a>(profile: &'a config::VerifyProfile, action: &str) -> Option<&'a str> {
136    match action {
137        "build" => profile.build.as_deref(),
138        "test" => profile.test.as_deref(),
139        "lint" => profile.lint.as_deref(),
140        "fix" => profile.fix.as_deref(),
141        _ => None,
142    }
143}
144
145fn autodetect_command(
146    cwd: &std::path::Path,
147    action: &str,
148    timeout_override: Option<u64>,
149) -> Result<(&'static str, String, u64), String> {
150    let timeout_secs = timeout_override.unwrap_or(BUILD_TIMEOUT_SECS);
151    let command = if cwd.join("Cargo.toml").exists() {
152        match action {
153            "build" => ("Rust/Cargo", "cargo build --color never".to_string()),
154            "test" => ("Rust/Cargo", "cargo test --color never".to_string()),
155            "lint" => (
156                "Rust/Cargo",
157                "cargo clippy --all-targets --all-features -- -D warnings".to_string(),
158            ),
159            "fix" => ("Rust/Cargo", "cargo fmt".to_string()),
160            _ => return Err(unknown_action(action)),
161        }
162    } else if cwd.join("package.json").exists() {
163        match action {
164            "build" => ("Node/npm", "npm run build --if-present".to_string()),
165            "test" => ("Node/npm", "npm test --if-present".to_string()),
166            "lint" => ("Node/npm", "npm run lint --if-present".to_string()),
167            "fix" => return Err(missing_profile_msg("Node/npm", action)),
168            _ => return Err(unknown_action(action)),
169        }
170    } else if cwd.join("pyproject.toml").exists() || cwd.join("setup.py").exists() {
171        match action {
172            "build" => ("Python", "python -m compileall .".to_string()),
173            "test" => return Err(missing_profile_msg("Python", action)),
174            "lint" => return Err(missing_profile_msg("Python", action)),
175            "fix" => return Err(missing_profile_msg("Python", action)),
176            _ => return Err(unknown_action(action)),
177        }
178    } else if cwd.join("go.mod").exists() {
179        match action {
180            "build" => ("Go", "go build ./...".to_string()),
181            "test" => ("Go", "go test ./...".to_string()),
182            "lint" => return Err(missing_profile_msg("Go", action)),
183            "fix" => return Err(missing_profile_msg("Go", action)),
184            _ => return Err(unknown_action(action)),
185        }
186    } else {
187        return Err(
188            "No recognized project root found.\n\
189             Expected one of: Cargo.toml, package.json, pyproject.toml, go.mod\n\
190             Ensure you are in the project root directory or configure `.hematite/settings.json` verify profiles."
191                .into(),
192        );
193    };
194
195    Ok((command.0, command.1, timeout_secs))
196}
197
198fn missing_profile_msg(stack: &str, action: &str) -> String {
199    format!(
200        "No auto-detected `{action}` command for [{stack}].\n\
201         Add a verify profile in `.hematite/settings.json` if you want Hematite to run `{action}` for this project."
202    )
203}
204
205fn unknown_action(action: &str) -> String {
206    format!(
207        "Unknown verify_build action `{}`. Use one of: build, test, lint, fix.",
208        action
209    )
210}
211
212async fn run_profile_command(
213    profile_name: &str,
214    action: &str,
215    command: &str,
216    timeout_secs: u64,
217) -> Result<String, String> {
218    let output = crate::tools::shell::execute(&serde_json::json!({
219        "command": command,
220        "timeout_secs": timeout_secs,
221        "reason": format!("verify_build:{}:{}", profile_name, action),
222    }))
223    .await?;
224
225    if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
226        Ok(format!(
227            "BUILD OK [{}:{}]\ncommand: {}\n{}",
228            profile_name,
229            action,
230            command,
231            output.trim()
232        ))
233    } else if should_fallback_to_cargo_check(action, command, &output) {
234        run_windows_self_hosted_check_fallback(profile_name, action, command, timeout_secs, &output)
235            .await
236    } else {
237        Err(format!(
238            "BUILD FAILED [{}:{}]\ncommand: {}\n{}",
239            profile_name,
240            action,
241            command,
242            output.trim()
243        ))
244    }
245}
246
247async fn run_profile_command_streaming(
248    profile_name: &str,
249    action: &str,
250    command: &str,
251    timeout_secs: u64,
252    tx: mpsc::Sender<InferenceEvent>,
253) -> Result<String, String> {
254    let output = crate::tools::shell::execute_streaming(
255        &serde_json::json!({
256            "command": command,
257            "timeout_secs": timeout_secs,
258            "reason": format!("verify_build:{}:{}", profile_name, action),
259        }),
260        tx.clone(),
261    )
262    .await?;
263
264    if output.contains("[exit code: 0]") || !output.contains("[exit code:") {
265        Ok(format!(
266            "BUILD OK [{}:{}]\ncommand: {}\n{}",
267            profile_name,
268            action,
269            command,
270            output.trim()
271        ))
272    } else if should_fallback_to_cargo_check(action, command, &output) {
273        run_windows_self_hosted_check_fallback_streaming(
274            profile_name,
275            action,
276            command,
277            timeout_secs,
278            &output,
279            tx,
280        )
281        .await
282    } else {
283        Err(format!(
284            "BUILD FAILED [{}:{}]\ncommand: {}\n{}",
285            profile_name,
286            action,
287            command,
288            output.trim()
289        ))
290    }
291}
292
293async fn run_windows_self_hosted_check_fallback_streaming(
294    profile_name: &str,
295    action: &str,
296    original_command: &str,
297    timeout_secs: u64,
298    original_output: &str,
299    tx: mpsc::Sender<InferenceEvent>,
300) -> Result<String, String> {
301    let fallback_command = "cargo check --color never";
302    let fallback_output = crate::tools::shell::execute_streaming(
303        &serde_json::json!({
304            "command": fallback_command,
305            "timeout_secs": timeout_secs,
306            "reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
307        }),
308        tx,
309    )
310    .await?;
311
312    if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
313        Ok(format!(
314            "BUILD OK [{}:{}]\ncommand: {}\n\
315             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\
316             original build output:\n{}\n\
317             fallback command: {}\n{}",
318            profile_name,
319            action,
320            original_command,
321            original_output.trim(),
322            fallback_command,
323            fallback_output.trim()
324        ))
325    } else {
326        Err(format!(
327            "BUILD FAILED [{}:{}]\ncommand: {}\n\
328             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
329             original build output:\n{}\n\
330             fallback command: {}\n{}",
331            profile_name,
332            action,
333            original_command,
334            original_output.trim(),
335            fallback_command,
336            fallback_output.trim()
337        ))
338    }
339}
340
341fn should_fallback_to_cargo_check(action: &str, command: &str, output: &str) -> bool {
342    if action != "build" || command.trim() != "cargo build --color never" {
343        return false;
344    }
345
346    if cfg!(windows) {
347        looks_like_windows_self_hosted_build_lock(output)
348    } else {
349        false
350    }
351}
352
353fn looks_like_windows_self_hosted_build_lock(output: &str) -> bool {
354    let lower = output.to_ascii_lowercase();
355    lower.contains("failed to remove file")
356        && lower.contains("target\\debug\\hematite.exe")
357        && (lower.contains("access is denied")
358            || lower.contains("being used by another process")
359            || lower.contains("permission denied"))
360}
361
362async fn run_windows_self_hosted_check_fallback(
363    profile_name: &str,
364    action: &str,
365    original_command: &str,
366    timeout_secs: u64,
367    original_output: &str,
368) -> Result<String, String> {
369    let fallback_command = "cargo check --color never";
370    let fallback_output = crate::tools::shell::execute(&serde_json::json!({
371        "command": fallback_command,
372        "timeout_secs": timeout_secs,
373        "reason": format!("verify_build:{}:{}:self_hosted_windows_fallback", profile_name, action),
374    }))
375    .await?;
376
377    if fallback_output.contains("[exit code: 0]") || !fallback_output.contains("[exit code:") {
378        Ok(format!(
379            "BUILD OK [{}:{}]\ncommand: {}\n\
380             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\
381             original build output:\n{}\n\
382             fallback command: {}\n{}",
383            profile_name,
384            action,
385            original_command,
386            original_output.trim(),
387            fallback_command,
388            fallback_output.trim()
389        ))
390    } else {
391        Err(format!(
392            "BUILD FAILED [{}:{}]\ncommand: {}\n\
393             Windows self-hosted note: `cargo build` could not replace the running `target\\\\debug\\\\hematite.exe`, and the fallback `cargo check` also failed.\n\
394             original build output:\n{}\n\
395             fallback command: {}\n{}",
396            profile_name,
397            action,
398            original_command,
399            original_output.trim(),
400            fallback_command,
401            fallback_output.trim()
402        ))
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn detects_windows_self_hosted_build_lock_pattern() {
412        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)";
413        assert!(looks_like_windows_self_hosted_build_lock(sample));
414    }
415
416    #[test]
417    fn ignores_unrelated_build_failures() {
418        let sample = "[stderr] error[E0425]: cannot find value `foo` in this scope";
419        assert!(!looks_like_windows_self_hosted_build_lock(sample));
420        assert!(!should_fallback_to_cargo_check(
421            "build",
422            "cargo build --color never",
423            sample
424        ));
425    }
426}