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