Skip to main content

hematite/tools/
repo_script.rs

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}