Skip to main content

machi_runtime/
side_effects.rs

1//! In-process workflow side effects: scratch, templates, optional git diff.
2//!
3//! # Maturity
4//!
5//! - **Core host surface:** scratch R/W, template render (default path).
6//! - **Optional / host-specific:** [`WorkflowSideEffects::git_diff_since`] —
7//!   only after [`WorkflowSideEffects::set_git_cwd`]. Not part of the minimal
8//!   vertical slice; products enable when they need VCS context.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::process::Command;
13use std::sync::{Arc, Mutex};
14
15use machi_workflow::HostError;
16
17/// Shared mutable store for a single workflow run.
18#[derive(Debug, Default)]
19pub struct WorkflowSideEffects {
20    scratch: Mutex<HashMap<String, String>>,
21    templates: Mutex<HashMap<String, String>>,
22    /// When set, `git_diff_since` runs `git -C <cwd> diff <commit>`.
23    git_cwd: Mutex<Option<PathBuf>>,
24}
25
26impl WorkflowSideEffects {
27    /// Empty store.
28    #[must_use]
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Shared handle.
34    #[must_use]
35    pub fn shared() -> Arc<Self> {
36        Arc::new(Self::new())
37    }
38
39    /// Enable git operations rooted at `cwd` (must be a git work tree).
40    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    /// Register a named template body. Placeholders use `{{key}}`.
48    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    /// Write scratch content; returns virtual path `scratch/{name}`.
56    ///
57    /// # Errors
58    ///
59    /// Empty name or oversized content.
60    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    /// Read scratch content.
83    ///
84    /// # Errors
85    ///
86    /// Missing file.
87    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    /// Render a registered template with string vars from a JSON object.
98    ///
99    /// # Errors
100    ///
101    /// Missing template or non-object vars.
102    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    /// Number of scratch entries (tests).
132    #[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    /// Run `git diff <commit>` (commit → working tree) in the configured cwd.
141    ///
142    /// **Optional capability:** requires [`Self::set_git_cwd`]. Prefer leaving
143    /// git disabled unless the host product needs repository context.
144    ///
145    /// # Errors
146    ///
147    /// Missing cwd, invalid commit string, or git failure.
148    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        // Single argv; reject whitespace / shell metacharacters.
154        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}