use std::collections::HashMap;
use crate::error::{CruiseError, Result};
#[derive(Debug, Default, Clone)]
pub struct VariableStore {
input: String,
prev_output: Option<String>,
prev_input: Option<String>,
prev_stderr: Option<String>,
prev_success: Option<bool>,
named: HashMap<String, NamedVariable>,
}
#[derive(Debug, Clone)]
pub enum NamedVariable {
FilePath(std::path::PathBuf),
Value(String),
}
impl VariableStore {
#[must_use]
pub fn new(input: String) -> Self {
Self {
input,
..Default::default()
}
}
pub fn set_named_file(&mut self, name: &str, path: std::path::PathBuf) {
self.named
.insert(name.to_string(), NamedVariable::FilePath(path));
}
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()
}
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 == '{' {
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 {
result.push('{');
result.push_str(&var_name);
}
} else {
result.push(ch);
}
}
Ok(result)
}
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());
assert_eq!(
store
.resolve("Hello {unclosed")
.unwrap_or_else(|e| panic!("{e:?}")),
"Hello {unclosed"
);
}
#[test]
fn test_set_named_value_resolves() {
let mut store = VariableStore::new("input".to_string());
store.set_named_value("greeting", "Hello".to_string());
let result = store
.resolve("Say: {greeting}")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "Say: Hello");
}
#[test]
fn test_resolve_pr_url() {
let mut store = VariableStore::new("input".to_string());
store.set_named_value(
"pr.url",
"https://github.com/owner/repo/pull/42".to_string(),
);
let result = store
.resolve("PR: {pr.url}")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "PR: https://github.com/owner/repo/pull/42");
}
#[test]
fn test_resolve_pr_number() {
let mut store = VariableStore::new("input".to_string());
store.set_named_value("pr.number", "42".to_string());
let result = store
.resolve("gh pr edit {pr.number} --add-label foo")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "gh pr edit 42 --add-label foo");
}
#[test]
fn test_set_named_value_overrides_existing() {
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());
let result = store
.resolve("{pr.number}")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "42");
}
#[test]
fn test_resolve_double_brace() {
let store = VariableStore::new("hello".to_string());
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() {
let store = VariableStore::new("hello".to_string());
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() {
let store = VariableStore::new("hello".to_string());
let result = store
.resolve("trailing {")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "trailing {");
}
#[test]
fn test_resolve_nested_dot_path() {
let mut store = VariableStore::new("input".to_string());
store.set_named_value("foo.bar.baz", "deep".to_string());
let result = store
.resolve("{foo.bar.baz}")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "deep");
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() {
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());
let result = store
.resolve("echo 'PR #{pr.number} created: {pr.url}'")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
result,
"echo 'PR #42 created: https://github.com/owner/repo/pull/42'"
);
}
}