Skip to main content

hematite/tools/
repo_script.rs

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