machi_runtime/
side_effects.rs1use std::collections::HashMap;
11use std::path::PathBuf;
12use std::process::Command;
13use std::sync::{Arc, Mutex};
14
15use machi_workflow::HostError;
16
17#[derive(Debug, Default)]
19pub struct WorkflowSideEffects {
20 scratch: Mutex<HashMap<String, String>>,
21 templates: Mutex<HashMap<String, String>>,
22 git_cwd: Mutex<Option<PathBuf>>,
24}
25
26impl WorkflowSideEffects {
27 #[must_use]
29 pub fn new() -> Self {
30 Self::default()
31 }
32
33 #[must_use]
35 pub fn shared() -> Arc<Self> {
36 Arc::new(Self::new())
37 }
38
39 pub fn set_git_cwd(&self, cwd: impl Into<PathBuf>) {
41 *self
42 .git_cwd
43 .lock()
44 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(cwd.into());
45 }
46
47 pub fn register_template(&self, name: impl Into<String>, body: impl Into<String>) {
49 self.templates
50 .lock()
51 .unwrap_or_else(std::sync::PoisonError::into_inner)
52 .insert(name.into(), body.into());
53 }
54
55 pub fn write_scratch(&self, name: &str, content: String) -> Result<String, HostError> {
61 let name = name.trim();
62 if name.is_empty() {
63 return Err(HostError::Failed("scratch name must be non-empty".into()));
64 }
65 if name.contains("..") || name.contains('/') || name.contains('\\') {
66 return Err(HostError::Failed(
67 "scratch name must be a single path segment".into(),
68 ));
69 }
70 if content.len() > 1_048_576 {
71 return Err(HostError::Failed(
72 "scratch content exceeds 1 MiB limit".into(),
73 ));
74 }
75 self.scratch
76 .lock()
77 .unwrap_or_else(std::sync::PoisonError::into_inner)
78 .insert(name.to_owned(), content);
79 Ok(format!("scratch/{name}"))
80 }
81
82 pub fn read_scratch(&self, name: &str) -> Result<String, HostError> {
88 let name = name.trim().trim_start_matches("scratch/");
89 self.scratch
90 .lock()
91 .unwrap_or_else(std::sync::PoisonError::into_inner)
92 .get(name)
93 .cloned()
94 .ok_or_else(|| HostError::Failed(format!("scratch not found: {name}")))
95 }
96
97 pub fn render_template(
103 &self,
104 name: &str,
105 vars: &serde_json::Value,
106 ) -> Result<String, HostError> {
107 let body = self
108 .templates
109 .lock()
110 .unwrap_or_else(std::sync::PoisonError::into_inner)
111 .get(name)
112 .cloned()
113 .ok_or_else(|| HostError::Failed(format!("template not found: {name}")))?;
114 let Some(obj) = vars.as_object() else {
115 return Err(HostError::Failed(
116 "render_template vars must be a JSON object".into(),
117 ));
118 };
119 let mut out = body;
120 for (k, v) in obj {
121 let needle = format!("{{{{{k}}}}}");
122 let replacement = match v {
123 serde_json::Value::String(s) => s.clone(),
124 other => other.to_string(),
125 };
126 out = out.replace(&needle, &replacement);
127 }
128 Ok(out)
129 }
130
131 #[must_use]
133 pub fn scratch_len(&self) -> usize {
134 self.scratch
135 .lock()
136 .unwrap_or_else(std::sync::PoisonError::into_inner)
137 .len()
138 }
139
140 pub fn git_diff_since(&self, commit: &str) -> Result<String, HostError> {
149 let commit = commit.trim();
150 if commit.is_empty() {
151 return Err(HostError::Failed("git commit must be non-empty".into()));
152 }
153 if commit
155 .chars()
156 .any(|c| c.is_whitespace() || ";&|$`()".contains(c))
157 {
158 return Err(HostError::Failed(
159 "git commit contains invalid characters".into(),
160 ));
161 }
162 let cwd = self
163 .git_cwd
164 .lock()
165 .unwrap_or_else(std::sync::PoisonError::into_inner)
166 .clone()
167 .ok_or_else(|| {
168 HostError::Unsupported(
169 "git_diff_since requires WorkflowSideEffects::set_git_cwd".into(),
170 )
171 })?;
172
173 let output = Command::new("git")
174 .arg("-C")
175 .arg(&cwd)
176 .arg("diff")
177 .arg("--no-ext-diff")
178 .arg(commit)
179 .output()
180 .map_err(|e| HostError::Failed(format!("git diff: {e}")))?;
181
182 if !output.status.success() {
183 let err = String::from_utf8_lossy(&output.stderr);
184 return Err(HostError::Failed(format!(
185 "git diff failed ({}): {}",
186 output.status,
187 err.trim()
188 )));
189 }
190 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
191 truncate_diff(&mut text);
192 Ok(text)
193 }
194}
195
196const GIT_DIFF_MAX_BYTES: usize = 512 * 1024;
197
198fn truncate_diff(text: &mut String) {
199 if text.len() > GIT_DIFF_MAX_BYTES {
200 text.truncate(GIT_DIFF_MAX_BYTES);
201 text.push_str("\nā¦[truncated]");
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use serde_json::json;
208
209 use super::*;
210
211 #[test]
212 fn scratch_round_trip() {
213 let s = WorkflowSideEffects::new();
214 let path = s.write_scratch("a.txt", "hello".into()).expect("write");
215 assert_eq!(path, "scratch/a.txt");
216 assert_eq!(s.read_scratch("a.txt").expect("read"), "hello");
217 assert_eq!(s.read_scratch("scratch/a.txt").expect("read2"), "hello");
218 }
219
220 #[test]
221 fn template_render() {
222 let s = WorkflowSideEffects::new();
223 s.register_template("greet", "hi {{name}}");
224 let out = s
225 .render_template("greet", &json!({"name": "machi"}))
226 .expect("render");
227 assert_eq!(out, "hi machi");
228 }
229
230 #[test]
231 fn rejects_path_escape_name() {
232 let s = WorkflowSideEffects::new();
233 assert!(s.write_scratch("../x", "no".into()).is_err());
234 }
235
236 #[test]
237 fn git_diff_requires_cwd() {
238 let s = WorkflowSideEffects::new();
239 let err = s.git_diff_since("HEAD").expect_err("cwd");
240 assert!(matches!(err, HostError::Unsupported(_)));
241 }
242
243 #[test]
244 fn git_diff_rejects_bad_commit() {
245 let s = WorkflowSideEffects::new();
246 s.set_git_cwd("/tmp");
247 assert!(s.git_diff_since("HEAD; rm -rf /").is_err());
248 }
249}