1use serde_json::Value;
2use std::fs;
3use std::path::PathBuf;
4use std::time::Duration;
5
6const DEFAULT_TIMEOUT_SECS: u64 = 300;
7const MAX_OUTPUT_BYTES: usize = 131_072;
8
9pub async fn run_hematite_maintainer_workflow(args: &Value) -> Result<String, String> {
10 let workflow = args
11 .get("workflow")
12 .and_then(|value| value.as_str())
13 .ok_or_else(|| "Missing required argument: 'workflow'".to_string())?;
14 let invocation = ScriptInvocation::from_args(workflow, args)?;
15 let output = execute_powershell_file(
16 &invocation.script_path,
17 &invocation.file_args,
18 invocation.timeout_secs,
19 )
20 .await?;
21
22 Ok(format!(
23 "Hematite maintainer workflow: {}\nScript: {}\nCommand: {}\n\n{}",
24 invocation.workflow_label,
25 invocation.script_path.display(),
26 invocation.display_command,
27 output.trim()
28 ))
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32struct ScriptInvocation {
33 workflow_label: &'static str,
34 script_path: PathBuf,
35 file_args: Vec<String>,
36 display_command: String,
37 timeout_secs: u64,
38}
39
40impl ScriptInvocation {
41 fn from_args(workflow: &str, args: &Value) -> Result<Self, String> {
42 match workflow {
43 "clean" => build_clean_invocation(args),
44 "package_windows" => build_package_windows_invocation(args),
45 "release" => build_release_invocation(args),
46 other => Err(format!(
47 "Unknown workflow '{}'. Use one of: clean, package_windows, release.",
48 other
49 )),
50 }
51 }
52}
53
54fn build_clean_invocation(args: &Value) -> Result<ScriptInvocation, String> {
55 let repo_root = require_repo_root()?;
56 let mut file_args = Vec::new();
57 if bool_arg(args, "deep") {
58 file_args.push("-Deep".to_string());
59 }
60 if bool_arg(args, "reset") {
61 file_args.push("-Reset".to_string());
62 }
63 if bool_arg(args, "prune_dist") {
64 file_args.push("-PruneDist".to_string());
65 }
66
67 Ok(ScriptInvocation {
68 workflow_label: "clean",
69 script_path: repo_root.join("clean.ps1"),
70 display_command: render_display_command(".\\clean.ps1", &file_args),
71 file_args,
72 timeout_secs: 180,
73 })
74}
75
76fn build_package_windows_invocation(args: &Value) -> Result<ScriptInvocation, String> {
77 ensure_windows("package_windows")?;
78 let repo_root = require_repo_root()?;
79
80 let mut file_args = Vec::new();
81 if bool_arg(args, "installer") {
82 file_args.push("-Installer".to_string());
83 }
84 if bool_arg(args, "add_to_path") {
85 file_args.push("-AddToPath".to_string());
86 }
87
88 Ok(ScriptInvocation {
89 workflow_label: "package_windows",
90 script_path: repo_root.join("scripts").join("package-windows.ps1"),
91 display_command: render_display_command(".\\scripts\\package-windows.ps1", &file_args),
92 file_args,
93 timeout_secs: 1800,
94 })
95}
96
97fn build_release_invocation(args: &Value) -> Result<ScriptInvocation, String> {
98 let repo_root = require_repo_root()?;
99 let version = string_arg(args, "version");
100 let bump = string_arg(args, "bump");
101 if version.is_none() == bump.is_none() {
102 return Err("workflow=release requires exactly one of: 'version' or 'bump'.".to_string());
103 }
104
105 let mut file_args = Vec::new();
106 if let Some(version) = version {
107 file_args.push("-Version".to_string());
108 file_args.push(version);
109 }
110 if let Some(bump) = bump {
111 match bump.as_str() {
112 "patch" | "minor" | "major" => {
113 file_args.push("-Bump".to_string());
114 file_args.push(bump);
115 }
116 other => {
117 return Err(format!(
118 "Invalid bump '{}'. Use one of: patch, minor, major.",
119 other
120 ))
121 }
122 }
123 }
124
125 for (field, flag) in [
126 ("push", "-Push"),
127 ("add_to_path", "-AddToPath"),
128 ("skip_installer", "-SkipInstaller"),
129 ("publish_crates", "-PublishCrates"),
130 ("publish_voice_crate", "-PublishVoiceCrate"),
131 ] {
132 if bool_arg(args, field) {
133 file_args.push(flag.to_string());
134 }
135 }
136
137 Ok(ScriptInvocation {
138 workflow_label: "release",
139 script_path: repo_root.join("release.ps1"),
140 display_command: render_display_command(".\\release.ps1", &file_args),
141 file_args,
142 timeout_secs: 3600,
143 })
144}
145
146fn bool_arg(args: &Value, key: &str) -> bool {
147 args.get(key)
148 .and_then(|value| value.as_bool())
149 .unwrap_or(false)
150}
151
152fn string_arg(args: &Value, key: &str) -> Option<String> {
153 args.get(key)
154 .and_then(|value| value.as_str())
155 .map(str::trim)
156 .filter(|value| !value.is_empty())
157 .map(|value| value.to_string())
158}
159
160fn require_repo_root() -> Result<PathBuf, String> {
161 find_hematite_repo_root().ok_or_else(|| {
162 "Could not locate a Hematite source checkout for this maintainer workflow. Run Hematite from the Hematite repo, launch it from a portable that still lives under that repo's dist/ directory, or switch into the Hematite source workspace before retrying."
163 .to_string()
164 })
165}
166
167fn find_hematite_repo_root() -> Option<PathBuf> {
168 let cwd_root = crate::tools::file_ops::workspace_root();
169 if is_hematite_repo_root(&cwd_root) {
170 return Some(cwd_root);
171 }
172
173 let exe = std::env::current_exe().ok()?;
174 for ancestor in exe.ancestors() {
175 let candidate = ancestor.to_path_buf();
176 if is_hematite_repo_root(&candidate) {
177 return Some(candidate);
178 }
179 }
180
181 None
182}
183
184fn is_hematite_repo_root(path: &std::path::Path) -> bool {
185 let cargo_toml = path.join("Cargo.toml");
186 let clean = path.join("clean.ps1");
187 let release = path.join("release.ps1");
188 let package_windows = path.join("scripts").join("package-windows.ps1");
189 if !cargo_toml.exists() || !clean.exists() || !release.exists() || !package_windows.exists() {
190 return false;
191 }
192
193 let cargo_text = match fs::read_to_string(cargo_toml) {
194 Ok(text) => text,
195 Err(_) => return false,
196 };
197
198 cargo_text.contains("name = \"hematite-cli\"") || cargo_text.contains("name = \"hematite\"")
199}
200
201fn ensure_windows(workflow: &str) -> Result<(), String> {
202 if cfg!(target_os = "windows") {
203 Ok(())
204 } else {
205 Err(format!(
206 "workflow={} is Windows-only because it depends on scripts/package-windows.ps1.",
207 workflow
208 ))
209 }
210}
211
212fn render_display_command(script: &str, args: &[String]) -> String {
213 if args.is_empty() {
214 format!("pwsh {}", script)
215 } else {
216 format!("pwsh {} {}", script, args.join(" "))
217 }
218}
219
220async fn execute_powershell_file(
221 script_path: &std::path::Path,
222 file_args: &[String],
223 timeout_secs: u64,
224) -> Result<String, String> {
225 let cwd = require_repo_root()?;
226 let shell = resolve_powershell_binary().await;
227 let mut command = tokio::process::Command::new(&shell);
228 command
229 .arg("-NoProfile")
230 .arg("-NonInteractive")
231 .arg("-ExecutionPolicy")
232 .arg("Bypass")
233 .arg("-File")
234 .arg(script_path)
235 .args(file_args)
236 .current_dir(&cwd)
237 .stdout(std::process::Stdio::piped())
238 .stderr(std::process::Stdio::piped());
239
240 let child_future = command.output();
241 let output = match tokio::time::timeout(
242 Duration::from_secs(timeout_secs.max(DEFAULT_TIMEOUT_SECS)),
243 child_future,
244 )
245 .await
246 {
247 Ok(Ok(output)) => output,
248 Ok(Err(err)) => {
249 return Err(format!(
250 "Failed to execute {}: {err}",
251 script_path.display()
252 ))
253 }
254 Err(_) => {
255 return Err(format!(
256 "Repo workflow timed out after {} seconds: {}",
257 timeout_secs.max(DEFAULT_TIMEOUT_SECS),
258 script_path.display()
259 ))
260 }
261 };
262
263 let stdout = cap_bytes(&output.stdout, MAX_OUTPUT_BYTES / 2);
264 let stderr = cap_bytes(&output.stderr, MAX_OUTPUT_BYTES / 2);
265 let exit_info = match output.status.code() {
266 Some(0) => String::new(),
267 Some(code) => format!("\n[exit code: {code}]"),
268 None => "\n[process terminated by signal]".to_string(),
269 };
270
271 let mut result = String::new();
272 if !stdout.is_empty() {
273 result.push_str(&stdout);
274 }
275 if !stderr.is_empty() {
276 if !result.is_empty() {
277 result.push('\n');
278 }
279 result.push_str("[stderr]\n");
280 result.push_str(&stderr);
281 }
282 if result.is_empty() {
283 result.push_str("(no output)");
284 }
285 result.push_str(&exit_info);
286 Ok(crate::agent::utils::strip_ansi(&result))
287}
288
289async fn resolve_powershell_binary() -> String {
290 if cfg!(target_os = "windows") && command_exists("pwsh").await {
291 "pwsh".to_string()
292 } else if cfg!(target_os = "windows") {
293 "powershell".to_string()
294 } else {
295 "pwsh".to_string()
296 }
297}
298
299async fn command_exists(name: &str) -> bool {
300 let locator = if cfg!(target_os = "windows") {
301 "where"
302 } else {
303 "which"
304 };
305 tokio::process::Command::new(locator)
306 .arg(name)
307 .stdout(std::process::Stdio::null())
308 .stderr(std::process::Stdio::null())
309 .status()
310 .await
311 .map(|status| status.success())
312 .unwrap_or(false)
313}
314
315fn cap_bytes(bytes: &[u8], max: usize) -> String {
316 if bytes.len() <= max {
317 String::from_utf8_lossy(bytes).into_owned()
318 } else {
319 let mut s = String::from_utf8_lossy(&bytes[..max]).into_owned();
320 s.push_str(&format!("\n... [truncated - {} bytes total]", bytes.len()));
321 s
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn clean_invocation_supports_deep_prune_dist() {
331 let invocation = ScriptInvocation::from_args(
332 "clean",
333 &serde_json::json!({
334 "workflow": "clean",
335 "deep": true,
336 "prune_dist": true
337 }),
338 )
339 .expect("invocation");
340
341 assert!(invocation.file_args.contains(&"-Deep".to_string()));
342 assert!(invocation.file_args.contains(&"-PruneDist".to_string()));
343 assert!(invocation.display_command.contains("clean.ps1"));
344 }
345
346 #[test]
347 fn repo_root_detection_finds_the_hematite_checkout() {
348 let root = require_repo_root().expect("repo root");
349 assert!(root.join("Cargo.toml").exists());
350 assert!(root.join("clean.ps1").exists());
351 }
352
353 #[test]
354 fn release_invocation_requires_version_or_bump() {
355 let err = ScriptInvocation::from_args(
356 "release",
357 &serde_json::json!({
358 "workflow": "release"
359 }),
360 )
361 .unwrap_err();
362 assert!(err.contains("requires exactly one"));
363 }
364
365 #[test]
366 fn release_invocation_builds_publish_flags() {
367 let invocation = ScriptInvocation::from_args(
368 "release",
369 &serde_json::json!({
370 "workflow": "release",
371 "bump": "patch",
372 "push": true,
373 "add_to_path": true,
374 "publish_crates": true
375 }),
376 )
377 .expect("invocation");
378
379 assert!(invocation.file_args.contains(&"-Bump".to_string()));
380 assert!(invocation.file_args.contains(&"patch".to_string()));
381 assert!(invocation.file_args.contains(&"-Push".to_string()));
382 assert!(invocation.file_args.contains(&"-AddToPath".to_string()));
383 assert!(invocation.file_args.contains(&"-PublishCrates".to_string()));
384 }
385}