Skip to main content

ito_core/harness/
stub.rs

1use super::types::{Harness, HarnessName, HarnessRunConfig, HarnessRunResult};
2use miette::{Result, miette};
3use serde::Deserialize;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8#[derive(Debug, Clone, Deserialize)]
9#[serde(rename_all = "camelCase")]
10/// One scripted execution step for the stub harness.
11pub struct StubStep {
12    /// Captured stdout for this step.
13    pub stdout: String,
14    #[serde(default)]
15    /// Captured stderr for this step.
16    pub stderr: String,
17    #[serde(default)]
18    /// Exit code for this step.
19    pub exit_code: i32,
20}
21
22#[derive(Debug, Clone)]
23/// Harness implementation that returns pre-recorded outputs.
24///
25/// This is primarily used for tests and offline development workflows.
26pub struct StubHarness {
27    steps: Vec<StubStep>,
28    idx: usize,
29}
30
31impl StubHarness {
32    /// Create a stub harness with an explicit list of steps.
33    pub fn new(steps: Vec<StubStep>) -> Self {
34        Self { steps, idx: 0 }
35    }
36
37    /// Load stub steps from a JSON file.
38    pub fn from_json_path(path: &Path) -> Result<Self> {
39        let raw = fs::read_to_string(path)
40            .map_err(|e| miette!("Failed to read stub script {p}: {e}", p = path.display()))?;
41        let steps: Vec<StubStep> = serde_json::from_str(&raw)
42            .map_err(|e| miette!("Invalid stub script JSON in {p}: {e}", p = path.display()))?;
43        Ok(Self::new(steps))
44    }
45
46    /// Resolve the stub script path from CLI args or `ITO_STUB_SCRIPT`.
47    ///
48    /// When no script is provided, this returns a single default step that
49    /// yields `<promise>COMPLETE</promise>`.
50    pub fn from_env_or_default(script_path: Option<PathBuf>) -> Result<Self> {
51        let from_env = std::env::var("ITO_STUB_SCRIPT").ok().map(PathBuf::from);
52        let path = script_path.or(from_env);
53        if let Some(p) = path {
54            return Self::from_json_path(&p);
55        }
56
57        // Default: single successful completion.
58        Ok(Self::new(vec![StubStep {
59            stdout: "<promise>COMPLETE</promise>\n".to_string(),
60            stderr: String::new(),
61            exit_code: 0,
62        }]))
63    }
64
65    fn next_step(&mut self) -> Option<StubStep> {
66        if self.steps.is_empty() {
67            return None;
68        }
69        let step = self
70            .steps
71            .get(self.idx)
72            .cloned()
73            .or_else(|| self.steps.last().cloned());
74        self.idx = self.idx.saturating_add(1);
75        step
76    }
77}
78
79impl Harness for StubHarness {
80    fn name(&self) -> HarnessName {
81        HarnessName::Stub
82    }
83
84    fn run(&mut self, _config: &HarnessRunConfig) -> Result<HarnessRunResult> {
85        let started = Instant::now();
86        let step = self
87            .next_step()
88            .ok_or_else(|| miette!("Stub harness has no steps"))?;
89
90        Ok(HarnessRunResult {
91            stdout: step.stdout,
92            stderr: step.stderr,
93            exit_code: step.exit_code,
94            duration: started.elapsed().max(Duration::from_millis(1)),
95            timed_out: false,
96        })
97    }
98
99    fn stop(&mut self) {
100        // No-op
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::collections::BTreeMap;
108
109    fn dummy_config() -> HarnessRunConfig {
110        HarnessRunConfig {
111            prompt: "test".to_string(),
112            model: None,
113            cwd: std::env::temp_dir(),
114            env: BTreeMap::new(),
115            interactive: false,
116            allow_all: false,
117            inactivity_timeout: None,
118        }
119    }
120
121    #[test]
122    fn name_returns_stub() {
123        let stub = StubHarness::new(vec![StubStep {
124            stdout: "test".to_string(),
125            stderr: String::new(),
126            exit_code: 0,
127        }]);
128        assert_eq!(stub.name(), HarnessName::Stub);
129    }
130
131    #[test]
132    fn streams_output_returns_false() {
133        let stub = StubHarness::new(vec![StubStep {
134            stdout: "test".to_string(),
135            stderr: String::new(),
136            exit_code: 0,
137        }]);
138        assert!(!stub.streams_output());
139    }
140
141    #[test]
142    fn run_sets_timed_out_false() {
143        let mut stub = StubHarness::new(vec![StubStep {
144            stdout: "test".to_string(),
145            stderr: String::new(),
146            exit_code: 0,
147        }]);
148        let config = dummy_config();
149        let result = stub.run(&config).unwrap();
150        assert!(!result.timed_out);
151    }
152
153    #[test]
154    fn run_sets_nonzero_duration() {
155        let mut stub = StubHarness::new(vec![StubStep {
156            stdout: "test".to_string(),
157            stderr: String::new(),
158            exit_code: 0,
159        }]);
160        let config = dummy_config();
161        let result = stub.run(&config).unwrap();
162        assert!(result.duration > Duration::ZERO);
163    }
164
165    #[test]
166    fn from_env_or_default_with_explicit_path() {
167        use std::io::Write;
168        let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
169        let json = r#"[{"stdout": "hello", "stderr": "", "exitCode": 0}]"#;
170        tmpfile.write_all(json.as_bytes()).unwrap();
171        tmpfile.flush().unwrap();
172
173        let mut stub =
174            StubHarness::from_env_or_default(Some(tmpfile.path().to_path_buf())).unwrap();
175        let config = dummy_config();
176        let result = stub.run(&config).unwrap();
177        assert_eq!(result.stdout, "hello");
178    }
179}