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 if !crate::tools::file_ops::is_project_workspace() {
72 let root = crate::tools::file_ops::workspace_root();
73 return Err(format!(
74 "No project workspace is locked right now. Hematite is currently rooted at {}. Launch Hematite in the target project directory before asking it to run project-specific scripts or commands.",
75 root.display()
76 ));
77 }
78 Ok(crate::tools::file_ops::workspace_root())
79}
80
81fn required_string<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
82 args.get(key)
83 .and_then(|value| value.as_str())
84 .map(str::trim)
85 .filter(|value| !value.is_empty())
86 .ok_or_else(|| format!("Missing required argument: '{}'", key))
87}
88
89fn default_timeout_ms(workflow: &str) -> u64 {
90 match workflow {
91 "build" | "test" | "lint" | "fix" => 1_800_000,
92 _ => 600_000,
93 }
94}
95
96fn default_command_for_action(root: &Path, action: &str) -> Result<String, String> {
97 let profile = crate::agent::workspace_profile::load_workspace_profile(root)
98 .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(root));
99
100 match action {
101 "build" => profile
102 .build_hint
103 .ok_or_else(|| missing_workspace_command_message(action, root)),
104 "test" => profile
105 .test_hint
106 .ok_or_else(|| missing_workspace_command_message(action, root)),
107 "lint" => detect_lint_command(root),
108 "fix" => detect_fix_command(root),
109 other => Err(format!("Unsupported workspace action '{}'.", other)),
110 }
111}
112
113fn missing_workspace_command_message(action: &str, root: &Path) -> String {
114 format!(
115 "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.",
116 action,
117 root.display()
118 )
119}
120
121fn detect_lint_command(root: &Path) -> Result<String, String> {
122 if root.join("Cargo.toml").exists() {
123 Ok("cargo clippy --all-targets --all-features -- -D warnings".to_string())
124 } else if root.join("package.json").exists() {
125 Ok(format!(
126 "{} run lint --if-present",
127 detect_node_package_manager(root)
128 ))
129 } else if root.join("go.mod").exists() {
130 Err(missing_workspace_command_message("lint", root))
131 } else {
132 Err(missing_workspace_command_message("lint", root))
133 }
134}
135
136fn detect_fix_command(root: &Path) -> Result<String, String> {
137 if root.join("Cargo.toml").exists() {
138 Ok("cargo fmt".to_string())
139 } else if root.join("package.json").exists() {
140 Ok(format!(
141 "{} run fix --if-present",
142 detect_node_package_manager(root)
143 ))
144 } else {
145 Err(missing_workspace_command_message("fix", root))
146 }
147}
148
149fn build_package_script_command(root: &Path, name: &str) -> Result<String, String> {
150 let package_json = root.join("package.json");
151 if !package_json.exists() {
152 return Err(format!(
153 "workflow=package_script requires package.json in the locked workspace root ({}).",
154 root.display()
155 ));
156 }
157
158 let content = fs::read_to_string(&package_json)
159 .map_err(|e| format!("Failed to read {}: {}", package_json.display(), e))?;
160 let package: serde_json::Value = serde_json::from_str(&content)
161 .map_err(|e| format!("Failed to parse {}: {}", package_json.display(), e))?;
162 let has_script = package
163 .get("scripts")
164 .and_then(|value| value.get(name))
165 .is_some();
166 if !has_script {
167 return Err(format!(
168 "package.json does not define a script named `{}` in {}.",
169 name,
170 root.display()
171 ));
172 }
173
174 let package_manager = detect_node_package_manager(root);
175 let command = match package_manager.as_str() {
176 "yarn" => format!("yarn {}", name),
177 "bun" => format!("bun run {}", name),
178 manager => format!("{} run {}", manager, name),
179 };
180 Ok(command)
181}
182
183fn build_script_path_command(root: &Path, relative_path: &str) -> Result<String, String> {
184 let candidate = root.join(relative_path);
185 let canonical_root = root
186 .canonicalize()
187 .map_err(|e| format!("Failed to resolve workspace root {}: {}", root.display(), e))?;
188 let canonical_path = candidate.canonicalize().map_err(|e| {
189 format!(
190 "Could not resolve script path `{}` from workspace root {}: {}",
191 relative_path,
192 root.display(),
193 e
194 )
195 })?;
196 if !canonical_path.starts_with(&canonical_root) {
197 return Err(format!(
198 "Script path `{}` resolves outside the locked workspace root {}.",
199 relative_path,
200 root.display()
201 ));
202 }
203
204 let display_path = normalize_relative_path(&canonical_path, &canonical_root)?;
205 let lower = display_path.to_ascii_lowercase();
206 if lower.ends_with(".ps1") {
207 Ok(format!(
208 "pwsh -ExecutionPolicy Bypass -File {}",
209 quote_command_arg(&display_path)
210 ))
211 } else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
212 Ok(format!("cmd /C {}", quote_command_arg(&display_path)))
213 } else if lower.ends_with(".sh") {
214 Ok(format!("bash {}", quote_command_arg(&display_path)))
215 } else if lower.ends_with(".py") {
216 Ok(format!("python {}", quote_command_arg(&display_path)))
217 } else if lower.ends_with(".js") || lower.ends_with(".cjs") || lower.ends_with(".mjs") {
218 Ok(format!("node {}", quote_command_arg(&display_path)))
219 } else {
220 Ok(display_path)
221 }
222}
223
224fn normalize_relative_path(path: &Path, root: &Path) -> Result<String, String> {
225 let relative = path
226 .strip_prefix(root)
227 .map_err(|e| format!("Failed to normalize script path: {}", e))?;
228 Ok(format!(
229 ".{}{}",
230 std::path::MAIN_SEPARATOR,
231 relative.display()
232 ))
233}
234
235fn quote_command_arg(value: &str) -> String {
236 format!("\"{}\"", value.replace('"', "\\\""))
237}
238
239fn detect_node_package_manager(root: &Path) -> String {
240 if root.join("pnpm-lock.yaml").exists() {
241 "pnpm".to_string()
242 } else if root.join("yarn.lock").exists() {
243 "yarn".to_string()
244 } else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
245 "bun".to_string()
246 } else {
247 "npm".to_string()
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn package_script_uses_detected_package_manager() {
257 let package_root = std::env::temp_dir().join(format!(
258 "hematite-workspace-workflow-node-{}",
259 std::process::id()
260 ));
261 std::fs::create_dir_all(&package_root).unwrap();
262 std::fs::write(
263 package_root.join("package.json"),
264 r#"{ "scripts": { "dev": "vite" } }"#,
265 )
266 .unwrap();
267 std::fs::write(package_root.join("pnpm-lock.yaml"), "").unwrap();
268
269 let command = build_package_script_command(&package_root, "dev").unwrap();
270 assert_eq!(command, "pnpm run dev");
271
272 let _ = std::fs::remove_file(package_root.join("package.json"));
273 let _ = std::fs::remove_file(package_root.join("pnpm-lock.yaml"));
274 let _ = std::fs::remove_dir(package_root);
275 }
276
277 #[test]
278 fn script_path_stays_inside_workspace_root() {
279 let script_dir = std::env::temp_dir().join(format!(
280 "hematite-workspace-workflow-scripts-{}",
281 std::process::id()
282 ));
283 std::fs::create_dir_all(script_dir.join("scripts")).unwrap();
284 std::fs::write(script_dir.join("scripts").join("dev.ps1"), "Write-Host hi").unwrap();
285
286 let command = build_script_path_command(&script_dir, "scripts/dev.ps1").unwrap();
287 assert!(command.contains("pwsh -ExecutionPolicy Bypass -File"));
288
289 let _ = std::fs::remove_file(script_dir.join("scripts").join("dev.ps1"));
290 let _ = std::fs::remove_dir(script_dir.join("scripts"));
291 let _ = std::fs::remove_dir(script_dir);
292 }
293}