cruise 0.1.35

YAML-driven coding agent workflow orchestrator
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
use std::collections::HashMap;

use crate::error::{CruiseError, Result};

/// Holds all runtime variables for a workflow execution.
#[derive(Debug, Default, Clone)]
pub struct VariableStore {
    /// Initial input from the CLI argument or stdin.
    input: String,

    /// LLM output from the previous step.
    prev_output: Option<String>,

    /// User text input from the previous option step.
    prev_input: Option<String>,

    /// Stderr captured from the previous command step.
    prev_stderr: Option<String>,

    /// Exit status of the previous command step.
    prev_success: Option<bool>,

    /// Named variables (e.g. plan file path).
    named: HashMap<String, NamedVariable>,
}

/// A named variable value.
#[derive(Debug, Clone)]
pub enum NamedVariable {
    /// A file path - resolves to the path string itself (not the file contents).
    FilePath(std::path::PathBuf),
    /// A plain string value.
    Value(String),
}

impl VariableStore {
    #[must_use]
    pub fn new(input: String) -> Self {
        Self {
            input,
            ..Default::default()
        }
    }

    /// Register a named variable backed by a file path.
    pub fn set_named_file(&mut self, name: &str, path: std::path::PathBuf) {
        self.named
            .insert(name.to_string(), NamedVariable::FilePath(path));
    }

    /// Register a named variable with a plain string value.
    pub fn set_named_value(&mut self, name: &str, value: String) {
        self.named
            .insert(name.to_string(), NamedVariable::Value(value));
    }

    pub fn set_prev_output(&mut self, output: Option<String>) {
        self.prev_output = output;
    }

    pub fn set_prev_input(&mut self, input: Option<String>) {
        self.prev_input = input;
    }

    pub fn set_prev_stderr(&mut self, stderr: Option<String>) {
        self.prev_stderr = stderr;
    }

    pub fn set_prev_success(&mut self, success: Option<bool>) {
        self.prev_success = success;
    }

    pub fn set_input(&mut self, input: String) {
        self.input = input;
    }

    #[must_use]
    pub fn input_is_empty(&self) -> bool {
        self.input.is_empty()
    }

    /// Resolve all `{variable_name}` placeholders in `template`.
    ///
    /// # Errors
    ///
    /// Returns an error if the template references an undefined variable.
    pub fn resolve(&self, template: &str) -> Result<String> {
        let mut result = String::new();
        let mut chars = template.chars().peekable();

        while let Some(ch) = chars.next() {
            if ch == '{' {
                // Collect the variable name up to the closing brace.
                let mut var_name = String::new();
                let mut closed = false;

                for inner_ch in chars.by_ref() {
                    if inner_ch == '}' {
                        closed = true;
                        break;
                    }
                    var_name.push(inner_ch);
                }

                if closed {
                    let value = self.get_variable(&var_name)?;
                    result.push_str(&value);
                } else {
                    // No closing brace -- emit literally.
                    result.push('{');
                    result.push_str(&var_name);
                }
            } else {
                result.push(ch);
            }
        }

        Ok(result)
    }

    /// Look up a variable by name and return its value.
    ///
    /// # Errors
    ///
    /// Returns an error if `name` does not correspond to a defined variable.
    pub fn get_variable(&self, name: &str) -> Result<String> {
        match name {
            "input" => Ok(self.input.clone()),
            "prev.output" => self
                .prev_output
                .clone()
                .ok_or_else(|| CruiseError::UndefinedVariable("prev.output".to_string())),
            "prev.input" => self
                .prev_input
                .clone()
                .ok_or_else(|| CruiseError::UndefinedVariable("prev.input".to_string())),
            "prev.stderr" => self
                .prev_stderr
                .clone()
                .ok_or_else(|| CruiseError::UndefinedVariable("prev.stderr".to_string())),
            "prev.success" => self
                .prev_success
                .map(|b| b.to_string())
                .ok_or_else(|| CruiseError::UndefinedVariable("prev.success".to_string())),
            other => match self.named.get(other) {
                Some(NamedVariable::FilePath(path)) => Ok(path.to_string_lossy().to_string()),
                Some(NamedVariable::Value(val)) => Ok(val.clone()),
                None => Err(CruiseError::UndefinedVariable(other.to_string())),
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::NamedTempFile;

    #[test]
    fn test_resolve_input() {
        let store = VariableStore::new("hello world".to_string());
        assert_eq!(
            store
                .resolve("Input: {input}")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "Input: hello world"
        );
    }

    #[test]
    fn test_resolve_prev_output() {
        let mut store = VariableStore::new("input".to_string());
        store.set_prev_output(Some("LLM response".to_string()));
        assert_eq!(
            store
                .resolve("Prev: {prev.output}")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "Prev: LLM response"
        );
    }

    #[test]
    fn test_resolve_prev_input() {
        let mut store = VariableStore::new("input".to_string());
        store.set_prev_input(Some("user text".to_string()));
        assert_eq!(
            store
                .resolve("User said: {prev.input}")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "User said: user text"
        );
    }

    #[test]
    fn test_resolve_prev_stderr() {
        let mut store = VariableStore::new("input".to_string());
        store.set_prev_stderr(Some("error message".to_string()));
        assert_eq!(
            store
                .resolve("Error: {prev.stderr}")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "Error: error message"
        );
    }

    #[test]
    fn test_resolve_prev_success() {
        let mut store = VariableStore::new("input".to_string());
        store.set_prev_success(Some(true));
        assert_eq!(
            store
                .resolve("Success: {prev.success}")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "Success: true"
        );
    }

    #[test]
    fn test_resolve_named_file() {
        let file = NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
        let path = file.path().to_path_buf();
        let path_str = path.to_string_lossy().to_string();

        let mut store = VariableStore::new("input".to_string());
        store.set_named_file("plan", path);
        let result = store
            .resolve("Plan: {plan}")
            .unwrap_or_else(|e| panic!("{e:?}"));
        assert_eq!(result, format!("Plan: {path_str}"));
    }

    #[test]
    fn test_resolve_undefined_variable() {
        let store = VariableStore::new("input".to_string());
        let err = store
            .resolve("Value: {undefined}")
            .map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"));
        matches!(err, crate::error::CruiseError::UndefinedVariable(name) if name == "undefined");
    }

    #[test]
    fn test_resolve_undefined_prev_output() {
        let store = VariableStore::new("input".to_string());
        let err = store
            .resolve("{prev.output}")
            .map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"));
        matches!(err, crate::error::CruiseError::UndefinedVariable(name) if name == "prev.output");
    }

    #[test]
    fn test_resolve_multiple_variables() {
        let mut store = VariableStore::new("hello".to_string());
        store.set_prev_output(Some("world".to_string()));
        assert_eq!(
            store
                .resolve("{input} {prev.output}")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "hello world"
        );
    }

    #[test]
    fn test_resolve_no_variables() {
        let store = VariableStore::new("input".to_string());
        assert_eq!(
            store
                .resolve("No variables here")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "No variables here"
        );
    }

    #[test]
    fn test_resolve_unclosed_brace() {
        let store = VariableStore::new("input".to_string());
        // No closing brace -- emit literally.
        assert_eq!(
            store
                .resolve("Hello {unclosed")
                .unwrap_or_else(|e| panic!("{e:?}")),
            "Hello {unclosed"
        );
    }

    #[test]
    fn test_set_named_value_resolves() {
        // Given: a store with a named string value set via set_named_value
        let mut store = VariableStore::new("input".to_string());
        store.set_named_value("greeting", "Hello".to_string());
        // When: resolved
        let result = store
            .resolve("Say: {greeting}")
            .unwrap_or_else(|e| panic!("{e:?}"));
        // Then: variable is substituted correctly
        assert_eq!(result, "Say: Hello");
    }

    #[test]
    fn test_resolve_pr_url() {
        // Given: pr.url is set as a named value
        let mut store = VariableStore::new("input".to_string());
        store.set_named_value(
            "pr.url",
            "https://github.com/owner/repo/pull/42".to_string(),
        );
        // When: resolved in a template
        let result = store
            .resolve("PR: {pr.url}")
            .unwrap_or_else(|e| panic!("{e:?}"));
        // Then: URL is substituted
        assert_eq!(result, "PR: https://github.com/owner/repo/pull/42");
    }

    #[test]
    fn test_resolve_pr_number() {
        // Given: pr.number is set as a named value
        let mut store = VariableStore::new("input".to_string());
        store.set_named_value("pr.number", "42".to_string());
        // When: resolved in a command template
        let result = store
            .resolve("gh pr edit {pr.number} --add-label foo")
            .unwrap_or_else(|e| panic!("{e:?}"));
        // Then: PR number is substituted
        assert_eq!(result, "gh pr edit 42 --add-label foo");
    }

    #[test]
    fn test_set_named_value_overrides_existing() {
        // Given: the same named value is set twice
        let mut store = VariableStore::new("input".to_string());
        store.set_named_value("pr.number", "10".to_string());
        store.set_named_value("pr.number", "42".to_string());
        // When: resolved
        let result = store
            .resolve("{pr.number}")
            .unwrap_or_else(|e| panic!("{e:?}"));
        // Then: latest value wins
        assert_eq!(result, "42");
    }

    #[test]
    fn test_resolve_double_brace() {
        // Given: template "{{input}}" -- outer `{` opens a var whose name is "{input"
        let store = VariableStore::new("hello".to_string());
        // The parser sees `{` -> starts collecting; collects `{input` until `}` is hit.
        // Then calls get_variable("{input") which is undefined.
        let err = store
            .resolve("{{input}}")
            .map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"));
        assert!(
            matches!(err, crate::error::CruiseError::UndefinedVariable(ref n) if n == "{input"),
            "expected UndefinedVariable(\"{{input\"), got: {err:?}"
        );
    }

    #[test]
    fn test_resolve_empty_var_name() {
        // Given: template "{}" -- empty variable name
        let store = VariableStore::new("hello".to_string());
        // get_variable("") falls through to named lookup, nothing registered -> UndefinedVariable
        let err = store
            .resolve("{}")
            .map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"));
        assert!(
            matches!(err, crate::error::CruiseError::UndefinedVariable(ref n) if n.is_empty()),
            "expected UndefinedVariable(\"\"), got: {err:?}"
        );
    }

    #[test]
    fn test_resolve_trailing_open_brace() {
        // Given: template "trailing {" -- no closing brace
        let store = VariableStore::new("hello".to_string());
        // Parser hits `{`, collects until end-of-string, closed=false -> emits literally.
        let result = store
            .resolve("trailing {")
            .unwrap_or_else(|e| panic!("{e:?}"));
        assert_eq!(result, "trailing {");
    }

    #[test]
    fn test_resolve_nested_dot_path() {
        // Given: store with "foo.bar.baz" registered as a named value
        let mut store = VariableStore::new("input".to_string());
        store.set_named_value("foo.bar.baz", "deep".to_string());
        // When: resolved
        let result = store
            .resolve("{foo.bar.baz}")
            .unwrap_or_else(|e| panic!("{e:?}"));
        // Then: resolves correctly
        assert_eq!(result, "deep");

        // And: an unregistered dotted name returns UndefinedVariable
        let store2 = VariableStore::new("input".to_string());
        let err = store2
            .resolve("{foo.bar.baz}")
            .map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"));
        assert!(
            matches!(err, crate::error::CruiseError::UndefinedVariable(ref n) if n == "foo.bar.baz"),
            "expected UndefinedVariable(\"foo.bar.baz\"), got: {err:?}"
        );
    }

    #[test]
    fn test_set_named_value_both_pr_vars() {
        // Given: both pr.url and pr.number are set
        let mut store = VariableStore::new("input".to_string());
        store.set_named_value(
            "pr.url",
            "https://github.com/owner/repo/pull/42".to_string(),
        );
        store.set_named_value("pr.number", "42".to_string());
        // When: template uses both placeholders
        let result = store
            .resolve("echo 'PR #{pr.number} created: {pr.url}'")
            .unwrap_or_else(|e| panic!("{e:?}"));
        // Then: both are substituted
        assert_eq!(
            result,
            "echo 'PR #42 created: https://github.com/owner/repo/pull/42'"
        );
    }
}