Skip to main content

hematite/tools/
workspace_workflow.rs

1use serde_json::Value;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5pub async fn run_workspace_workflow(args: &Value) -> Result<String, String> {
6    let root = require_project_workspace_root()?;
7    let invocation = WorkspaceInvocation::from_args(args, &root)?;
8    let output = crate::tools::shell::execute_command_in_dir(
9        &invocation.command,
10        &root,
11        invocation.timeout_ms,
12        false,
13    )
14    .await?;
15
16    Ok(format!(
17        "Workspace workflow: {}\nWorkspace root: {}\nCommand: {}\n\n{}",
18        invocation.workflow_label,
19        root.display(),
20        invocation.command,
21        output.trim()
22    ))
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26struct WorkspaceInvocation {
27    workflow_label: String,
28    command: String,
29    timeout_ms: u64,
30}
31
32impl WorkspaceInvocation {
33    fn from_args(args: &Value, root: &Path) -> Result<Self, String> {
34        let workflow = args
35            .get("workflow")
36            .and_then(|value| value.as_str())
37            .ok_or_else(|| "Missing required argument: 'workflow'".to_string())?;
38        let timeout_ms = args
39            .get("timeout_ms")
40            .and_then(|value| value.as_u64())
41            .unwrap_or(default_timeout_ms(workflow));
42
43        let command = match workflow {
44            "build" => default_command_for_action(root, "build")?,
45            "test" => default_command_for_action(root, "test")?,
46            "lint" => default_command_for_action(root, "lint")?,
47            "fix" => default_command_for_action(root, "fix")?,
48            "package_script" => build_package_script_command(root, required_string(args, "name")?)?,
49            "task" => format!("task {}", required_string(args, "name")?),
50            "just" => format!("just {}", required_string(args, "name")?),
51            "make" => format!("make {}", required_string(args, "name")?),
52            "script_path" => build_script_path_command(root, required_string(args, "path")?)?,
53            "command" => required_string(args, "command")?.to_string(),
54            other => {
55                return Err(format!(
56                    "Unknown workflow '{}'. Use one of: build, test, lint, fix, package_script, task, just, make, script_path, command.",
57                    other
58                ))
59            }
60        };
61
62        Ok(Self {
63            workflow_label: workflow.to_string(),
64            command,
65            timeout_ms,
66        })
67    }
68}
69
70fn require_project_workspace_root() -> Result<PathBuf, String> {
71    Ok(crate::tools::file_ops::workspace_root())
72}
73
74fn required_string<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
75    args.get(key)
76        .and_then(|value| value.as_str())
77        .map(str::trim)
78        .filter(|value| !value.is_empty())
79        .ok_or_else(|| format!("Missing required argument: '{}'", key))
80}
81
82fn default_timeout_ms(workflow: &str) -> u64 {
83    match workflow {
84        "build" | "test" | "lint" | "fix" => 1_800_000,
85        _ => 600_000,
86    }
87}
88
89fn default_command_for_action(root: &Path, action: &str) -> Result<String, String> {
90    let profile = crate::agent::workspace_profile::load_workspace_profile(root)
91        .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(root));
92
93    match action {
94        "build" => profile
95            .build_hint
96            .ok_or_else(|| missing_workspace_command_message(action, root)),
97        "test" => profile
98            .test_hint
99            .ok_or_else(|| missing_workspace_command_message(action, root)),
100        "lint" => detect_lint_command(root),
101        "fix" => detect_fix_command(root),
102        other => Err(format!("Unsupported workspace action '{}'.", other)),
103    }
104}
105
106fn missing_workspace_command_message(action: &str, root: &Path) -> String {
107    format!(
108        "Hematite could not infer a `{}` command for the locked workspace at {}. Add a workspace verify profile in `.hematite/settings.json`, or ask for an explicit command/script instead.",
109        action,
110        root.display()
111    )
112}
113
114fn detect_lint_command(root: &Path) -> Result<String, String> {
115    if root.join("Cargo.toml").exists() {
116        Ok("cargo clippy --all-targets --all-features -- -D warnings".to_string())
117    } else if root.join("package.json").exists() {
118        Ok(format!(
119            "{} run lint --if-present",
120            detect_node_package_manager(root)
121        ))
122    } else if root.join("go.mod").exists() {
123        Err(missing_workspace_command_message("lint", root))
124    } else {
125        Err(missing_workspace_command_message("lint", root))
126    }
127}
128
129fn detect_fix_command(root: &Path) -> Result<String, String> {
130    if root.join("Cargo.toml").exists() {
131        Ok("cargo fmt".to_string())
132    } else if root.join("package.json").exists() {
133        Ok(format!(
134            "{} run fix --if-present",
135            detect_node_package_manager(root)
136        ))
137    } else {
138        Err(missing_workspace_command_message("fix", root))
139    }
140}
141
142fn build_package_script_command(root: &Path, name: &str) -> Result<String, String> {
143    let package_json = root.join("package.json");
144    if !package_json.exists() {
145        return Err(format!(
146            "workflow=package_script requires package.json in the locked workspace root ({}).",
147            root.display()
148        ));
149    }
150
151    let content = fs::read_to_string(&package_json)
152        .map_err(|e| format!("Failed to read {}: {}", package_json.display(), e))?;
153    let package: serde_json::Value = serde_json::from_str(&content)
154        .map_err(|e| format!("Failed to parse {}: {}", package_json.display(), e))?;
155    let has_script = package
156        .get("scripts")
157        .and_then(|value| value.get(name))
158        .is_some();
159    if !has_script {
160        return Err(format!(
161            "package.json does not define a script named `{}` in {}.",
162            name,
163            root.display()
164        ));
165    }
166
167    let package_manager = detect_node_package_manager(root);
168    let command = match package_manager.as_str() {
169        "yarn" => format!("yarn {}", name),
170        "bun" => format!("bun run {}", name),
171        manager => format!("{} run {}", manager, name),
172    };
173    Ok(command)
174}
175
176fn build_script_path_command(root: &Path, relative_path: &str) -> Result<String, String> {
177    let candidate = root.join(relative_path);
178    let canonical_root = root
179        .canonicalize()
180        .map_err(|e| format!("Failed to resolve workspace root {}: {}", root.display(), e))?;
181    let canonical_path = candidate.canonicalize().map_err(|e| {
182        format!(
183            "Could not resolve script path `{}` from workspace root {}: {}",
184            relative_path,
185            root.display(),
186            e
187        )
188    })?;
189    if !canonical_path.starts_with(&canonical_root) {
190        return Err(format!(
191            "Script path `{}` resolves outside the locked workspace root {}.",
192            relative_path,
193            root.display()
194        ));
195    }
196
197    let display_path = normalize_relative_path(&canonical_path, &canonical_root)?;
198    let lower = display_path.to_ascii_lowercase();
199    if lower.ends_with(".ps1") {
200        Ok(format!(
201            "pwsh -ExecutionPolicy Bypass -File {}",
202            quote_command_arg(&display_path)
203        ))
204    } else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
205        Ok(format!("cmd /C {}", quote_command_arg(&display_path)))
206    } else if lower.ends_with(".sh") {
207        Ok(format!("bash {}", quote_command_arg(&display_path)))
208    } else if lower.ends_with(".py") {
209        Ok(format!("python {}", quote_command_arg(&display_path)))
210    } else if lower.ends_with(".js") || lower.ends_with(".cjs") || lower.ends_with(".mjs") {
211        Ok(format!("node {}", quote_command_arg(&display_path)))
212    } else {
213        Ok(display_path)
214    }
215}
216
217fn normalize_relative_path(path: &Path, root: &Path) -> Result<String, String> {
218    let relative = path
219        .strip_prefix(root)
220        .map_err(|e| format!("Failed to normalize script path: {}", e))?;
221    Ok(format!(
222        ".{}{}",
223        std::path::MAIN_SEPARATOR,
224        relative.display()
225    ))
226}
227
228fn quote_command_arg(value: &str) -> String {
229    format!("\"{}\"", value.replace('"', "\\\""))
230}
231
232fn detect_node_package_manager(root: &Path) -> String {
233    if root.join("pnpm-lock.yaml").exists() {
234        "pnpm".to_string()
235    } else if root.join("yarn.lock").exists() {
236        "yarn".to_string()
237    } else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
238        "bun".to_string()
239    } else {
240        "npm".to_string()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn package_script_uses_detected_package_manager() {
250        let package_root = std::env::temp_dir().join(format!(
251            "hematite-workspace-workflow-node-{}",
252            std::process::id()
253        ));
254        std::fs::create_dir_all(&package_root).unwrap();
255        std::fs::write(
256            package_root.join("package.json"),
257            r#"{ "scripts": { "dev": "vite" } }"#,
258        )
259        .unwrap();
260        std::fs::write(package_root.join("pnpm-lock.yaml"), "").unwrap();
261
262        let command = build_package_script_command(&package_root, "dev").unwrap();
263        assert_eq!(command, "pnpm run dev");
264
265        let _ = std::fs::remove_file(package_root.join("package.json"));
266        let _ = std::fs::remove_file(package_root.join("pnpm-lock.yaml"));
267        let _ = std::fs::remove_dir(package_root);
268    }
269
270    #[test]
271    fn script_path_stays_inside_workspace_root() {
272        let script_dir = std::env::temp_dir().join(format!(
273            "hematite-workspace-workflow-scripts-{}",
274            std::process::id()
275        ));
276        std::fs::create_dir_all(script_dir.join("scripts")).unwrap();
277        std::fs::write(script_dir.join("scripts").join("dev.ps1"), "Write-Host hi").unwrap();
278
279        let command = build_script_path_command(&script_dir, "scripts/dev.ps1").unwrap();
280        assert!(command.contains("pwsh -ExecutionPolicy Bypass -File"));
281
282        let _ = std::fs::remove_file(script_dir.join("scripts").join("dev.ps1"));
283        let _ = std::fs::remove_dir(script_dir.join("scripts"));
284        let _ = std::fs::remove_dir(script_dir);
285    }
286}