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}