1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use crate::steps::{ParsedValue, StepOutput};
5
6#[derive(Debug, Clone)]
8pub struct ChatMessage {
9 pub role: String,
10 pub content: String,
11}
12
13#[derive(Debug, Clone, Default)]
15pub struct ChatHistory {
16 pub messages: Vec<ChatMessage>,
17}
18
19type ChatSessionStore = Arc<Mutex<HashMap<String, ChatHistory>>>;
21
22pub struct Context {
24 steps: HashMap<String, StepOutput>,
25 parsed_outputs: HashMap<String, ParsedValue>,
26 variables: HashMap<String, serde_json::Value>,
27 parent: Option<Arc<Context>>,
28 pub scope_value: Option<serde_json::Value>,
29 pub scope_index: usize,
30 pub session_id: Option<String>,
31 chat_sessions: ChatSessionStore,
33}
34
35impl Context {
36 pub fn new(target: String, vars: HashMap<String, serde_json::Value>) -> Self {
37 let mut variables = vars;
38 variables.insert("target".to_string(), serde_json::Value::String(target));
39
40 Self {
41 steps: HashMap::new(),
42 parsed_outputs: HashMap::new(),
43 variables,
44 parent: None,
45 scope_value: None,
46 scope_index: 0,
47 session_id: None,
48 chat_sessions: Arc::new(Mutex::new(HashMap::new())),
49 }
50 }
51
52 pub fn store(&mut self, name: &str, output: StepOutput) {
54 if let StepOutput::Agent(ref agent) = output {
55 if let Some(ref sid) = agent.session_id {
56 self.session_id = Some(sid.clone());
57 }
58 }
59 self.steps.insert(name.to_string(), output);
60 }
61
62 pub fn get_step(&self, name: &str) -> Option<&StepOutput> {
64 self.steps
65 .get(name)
66 .or_else(|| self.parent.as_ref().and_then(|p| p.get_step(name)))
67 }
68
69 pub fn get_var(&self, name: &str) -> Option<&serde_json::Value> {
71 self.variables
72 .get(name)
73 .or_else(|| self.parent.as_ref().and_then(|p| p.get_var(name)))
74 }
75
76 pub fn get_session(&self) -> Option<&str> {
78 self.session_id
79 .as_deref()
80 .or_else(|| self.parent.as_ref().and_then(|p| p.get_session()))
81 }
82
83 pub fn store_parsed(&mut self, name: &str, parsed: ParsedValue) {
85 self.parsed_outputs.insert(name.to_string(), parsed);
86 }
87
88 pub fn get_parsed(&self, name: &str) -> Option<&ParsedValue> {
90 self.parsed_outputs
91 .get(name)
92 .or_else(|| self.parent.as_ref().and_then(|p| p.get_parsed(name)))
93 }
94
95 pub fn child(parent: Arc<Context>, scope_value: Option<serde_json::Value>, index: usize) -> Self {
97 Self {
98 steps: HashMap::new(),
99 parsed_outputs: HashMap::new(),
100 variables: HashMap::new(),
101 parent: Some(parent.clone()),
102 scope_value,
103 scope_index: index,
104 session_id: parent.session_id.clone(),
105 chat_sessions: Arc::clone(&parent.chat_sessions),
106 }
107 }
108
109 pub fn get_from_value(&self, name: &str) -> Option<serde_json::Value> {
112 let step = self.get_step(name)?;
113 let parsed = self.get_parsed(name);
114 Some(step_output_to_value_with_parsed(step, parsed))
115 }
116
117 pub fn var_exists(&self, path: &str) -> bool {
119 let parts: Vec<&str> = path.split('.').collect();
120 if parts.is_empty() {
121 return false;
122 }
123 let root = parts[0];
124 if let Some(step) = self.get_step(root) {
125 if parts.len() == 1 {
126 return true;
127 }
128 let val = step_output_to_value_with_parsed(step, self.get_parsed(root));
129 return check_json_path(&val, &parts[1..]);
130 }
131 if let Some(var) = self.get_var(root) {
132 if parts.len() == 1 {
133 return true;
134 }
135 return check_json_path(var, &parts[1..]);
136 }
137 false
138 }
139
140 pub fn get_chat_messages(&self, session: &str) -> Vec<ChatMessage> {
142 let guard = self.chat_sessions.lock().expect("chat_sessions lock poisoned");
143 guard.get(session).map(|h| h.messages.clone()).unwrap_or_default()
144 }
145
146 pub fn append_chat_messages(&self, session: &str, messages: Vec<ChatMessage>) {
148 let mut guard = self.chat_sessions.lock().expect("chat_sessions lock poisoned");
149 let history = guard.entry(session.to_string()).or_insert_with(ChatHistory::default);
150 history.messages.extend(messages);
151 }
152
153 pub fn to_tera_context(&self) -> tera::Context {
155 let mut ctx = tera::Context::new();
156
157 if let Some(parent) = &self.parent {
159 ctx = parent.to_tera_context();
160 }
161 for (k, v) in &self.variables {
162 ctx.insert(k, v);
163 }
164
165 let mut steps_map: HashMap<String, serde_json::Value> = HashMap::new();
167 if let Some(parent) = &self.parent {
168 collect_steps_with_parsed(parent, &mut steps_map);
169 }
170 for (name, output) in &self.steps {
171 let parsed = self.parsed_outputs.get(name);
172 let val = step_output_to_value_with_parsed(output, parsed);
173 steps_map.insert(name.clone(), val);
174 }
175
176 for (name, val) in &steps_map {
178 ctx.insert(name.as_str(), val);
179 }
180 ctx.insert("steps", &steps_map);
181
182 if let Some(sv) = &self.scope_value {
184 let mut scope_map = HashMap::new();
185 scope_map.insert("value".to_string(), sv.clone());
186 scope_map.insert("index".to_string(), serde_json::json!(self.scope_index));
187 ctx.insert("scope", &scope_map);
188 }
189
190 ctx
191 }
192
193 pub fn render_template(&self, template: &str) -> Result<String, crate::error::StepError> {
195 let pre = crate::engine::template::preprocess_template(template, self)?;
197
198 let mut tera_ctx = self.to_tera_context();
200
201 for (k, v) in &pre.injected {
203 tera_ctx.insert(k.as_str(), v);
204 }
205
206 let mut tera = tera::Tera::default();
208 tera.add_raw_template("__tmpl__", &pre.template)
209 .map_err(|e| crate::error::StepError::Template(format!("{e}")))?;
210
211 tera.render("__tmpl__", &tera_ctx)
212 .map_err(|e| crate::error::StepError::Template(format!("{e}")))
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::steps::{CmdOutput, StepOutput};
220 use std::time::Duration;
221
222 fn cmd_output(stdout: &str, exit_code: i32) -> StepOutput {
223 StepOutput::Cmd(CmdOutput {
224 stdout: stdout.to_string(),
225 stderr: String::new(),
226 exit_code,
227 duration: Duration::ZERO,
228 })
229 }
230
231 #[test]
232 fn store_and_retrieve() {
233 let mut ctx = Context::new("123".to_string(), HashMap::new());
234 ctx.store("step1", cmd_output("hello", 0));
235 let out = ctx.get_step("step1").unwrap();
236 assert_eq!(out.text(), "hello");
237 assert_eq!(out.exit_code(), 0);
238 }
239
240 #[test]
241 fn parent_context_inheritance() {
242 let mut parent = Context::new("456".to_string(), HashMap::new());
243 parent.store("parent_step", cmd_output("from parent", 0));
244 let child = Context::child(Arc::new(parent), None, 0);
245 let out = child.get_step("parent_step").unwrap();
246 assert_eq!(out.text(), "from parent");
247 }
248
249 #[test]
250 fn target_variable_resolves() {
251 let ctx = Context::new("42".to_string(), HashMap::new());
252 let result = ctx.render_template("{{ target }}").unwrap();
253 assert_eq!(result, "42");
254 }
255
256 #[test]
257 fn render_template_with_step_stdout() {
258 let mut ctx = Context::new("".to_string(), HashMap::new());
259 ctx.store("fetch", cmd_output("some output", 0));
260 let result = ctx.render_template("{{ steps.fetch.stdout }}").unwrap();
261 assert_eq!(result, "some output");
262 }
263
264 #[test]
265 fn render_scope_value() {
266 let parent = Context::new("".to_string(), HashMap::new());
267 let child = Context::child(Arc::new(parent), Some(serde_json::json!("my_value")), 0);
268 let result = child.render_template("{{ scope.value }}").unwrap();
269 assert_eq!(result, "my_value");
270 }
271
272 #[test]
273 fn render_template_with_step_exit_code() {
274 let mut ctx = Context::new("".to_string(), HashMap::new());
275 ctx.store("prev", cmd_output("output", 0));
276 let result = ctx.render_template("{{ steps.prev.exit_code }}").unwrap();
277 assert_eq!(result, "0");
278 }
279
280 #[test]
281 fn agent_session_id_accessible_in_template() {
282 use crate::steps::{AgentOutput, AgentStats, StepOutput};
283 let mut ctx = Context::new("".to_string(), HashMap::new());
284 ctx.store(
285 "scan",
286 StepOutput::Agent(AgentOutput {
287 response: "done".to_string(),
288 session_id: Some("sess-abc".to_string()),
289 stats: AgentStats::default(),
290 }),
291 );
292 let result = ctx.render_template("{{ steps.scan.session_id }}").unwrap();
293 assert_eq!(result, "sess-abc");
294 }
295
296 #[test]
297 fn cmd_step_session_id_is_empty_string() {
298 let mut ctx = Context::new("".to_string(), HashMap::new());
299 ctx.store("build", cmd_output("output", 0));
300 let result = ctx.render_template("{{ steps.build.session_id }}").unwrap();
301 assert_eq!(result, "");
302 }
303
304 #[test]
305 fn agent_session_id_none_renders_empty_string() {
306 use crate::steps::{AgentOutput, AgentStats, StepOutput};
307 let mut ctx = Context::new("".to_string(), HashMap::new());
308 ctx.store(
309 "scan",
310 StepOutput::Agent(AgentOutput {
311 response: "done".to_string(),
312 session_id: None,
313 stats: AgentStats::default(),
314 }),
315 );
316 let result = ctx.render_template("{{ steps.scan.session_id }}").unwrap();
317 assert_eq!(result, "");
318 }
319
320 #[test]
321 fn child_inherits_parent_steps() {
322 let mut parent = Context::new("test".to_string(), HashMap::new());
323 parent.store("a", cmd_output("alpha", 0));
324 let mut child = Context::child(Arc::new(parent), None, 0);
325 child.store("b", cmd_output("beta", 0));
326 assert!(child.get_step("a").is_some());
328 assert!(child.get_step("b").is_some());
330 }
331
332 #[test]
333 fn output_key_defaults_to_text() {
334 let mut ctx = Context::new("".to_string(), HashMap::new());
335 ctx.store("fetch", cmd_output("hello world", 0));
336 let result = ctx.render_template("{{ fetch.output }}").unwrap();
338 assert_eq!(result, "hello world");
339 }
340
341 #[test]
342 fn output_key_with_json_parsed_value() {
343 use crate::steps::ParsedValue;
344 let mut ctx = Context::new("".to_string(), HashMap::new());
345 ctx.store("scan", cmd_output(r#"{"count": 5}"#, 0));
346 ctx.store_parsed("scan", ParsedValue::Json(serde_json::json!({"count": 5})));
347 let result = ctx.render_template("{{ scan.output.count }}").unwrap();
349 assert_eq!(result, "5");
350 }
351
352 #[test]
353 fn output_key_with_lines_parsed_value() {
354 use crate::steps::ParsedValue;
355 let mut ctx = Context::new("".to_string(), HashMap::new());
356 ctx.store("files", cmd_output("a.rs\nb.rs\nc.rs", 0));
357 ctx.store_parsed(
358 "files",
359 ParsedValue::Lines(vec!["a.rs".into(), "b.rs".into(), "c.rs".into()]),
360 );
361 let result = ctx.render_template("{{ files.output | length }}").unwrap();
363 assert_eq!(result, "3");
364 }
365
366 #[test]
367 fn step_accessible_directly_by_name() {
368 let mut ctx = Context::new("".to_string(), HashMap::new());
369 ctx.store("greet", cmd_output("hi", 0));
370 let result = ctx.render_template("{{ greet.output }}").unwrap();
372 assert_eq!(result, "hi");
373 }
374
375 #[test]
376 fn from_accesses_step_by_name() {
377 let mut ctx = Context::new("".to_string(), HashMap::new());
378 ctx.store("global-config", cmd_output("prod", 0));
379 let result = ctx
381 .render_template(r#"{{ from("global-config").output }}"#)
382 .unwrap();
383 assert_eq!(result, "prod");
384 }
385
386 #[test]
387 fn from_fails_for_nonexistent_step() {
388 let ctx = Context::new("".to_string(), HashMap::new());
389 let err = ctx
390 .render_template(r#"{{ from("nonexistent").output }}"#)
391 .unwrap_err();
392 assert!(
393 err.to_string().contains("not found"),
394 "expected 'not found' error, got: {err}"
395 );
396 }
397
398 #[test]
399 fn from_with_json_dot_access() {
400 use crate::steps::ParsedValue;
401 let mut ctx = Context::new("".to_string(), HashMap::new());
402 ctx.store("scan", cmd_output(r#"{"issues": [1, 2]}"#, 0));
403 ctx.store_parsed(
404 "scan",
405 ParsedValue::Json(serde_json::json!({"issues": [1, 2]})),
406 );
407 let result = ctx
409 .render_template(r#"{{ from("scan").output.issues | length }}"#)
410 .unwrap();
411 assert_eq!(result, "2");
412 }
413
414 #[test]
415 fn from_traverses_parent_scope() {
416 let mut parent = Context::new("".to_string(), HashMap::new());
417 parent.store("root-step", cmd_output("root-value", 0));
418 let child = Context::child(Arc::new(parent), None, 0);
419 let result = child
421 .render_template(r#"{{ from("root-step").output }}"#)
422 .unwrap();
423 assert_eq!(result, "root-value");
424 }
425
426 #[test]
427 fn from_safe_accessor_returns_empty_when_step_missing() {
428 let ctx = Context::new("".to_string(), HashMap::new());
429 let result = ctx
431 .render_template(r#"{{ from("nonexistent").output? }}"#)
432 .unwrap();
433 assert_eq!(result, "");
434 }
435}
436
437fn collect_steps_with_parsed(ctx: &Context, map: &mut HashMap<String, serde_json::Value>) {
438 if let Some(parent) = &ctx.parent {
439 collect_steps_with_parsed(parent, map);
440 }
441 for (name, output) in &ctx.steps {
442 let parsed = ctx.parsed_outputs.get(name);
443 map.insert(name.clone(), step_output_to_value_with_parsed(output, parsed));
444 }
445}
446
447fn step_output_to_value_with_parsed(
448 output: &StepOutput,
449 parsed: Option<&ParsedValue>,
450) -> serde_json::Value {
451 let mut val = serde_json::to_value(output).unwrap_or(serde_json::Value::Null);
452
453 if let serde_json::Value::Object(ref mut map) = val {
454 let output_val = match parsed {
456 Some(ParsedValue::Json(j)) => j.clone(),
457 Some(ParsedValue::Lines(lines)) => serde_json::json!(lines),
458 Some(ParsedValue::Integer(n)) => serde_json::json!(n),
459 Some(ParsedValue::Boolean(b)) => serde_json::json!(b),
460 Some(ParsedValue::Text(t)) => serde_json::Value::String(t.clone()),
461 None => serde_json::Value::String(output.text().to_string()),
462 };
463 map.insert("output".to_string(), output_val);
464
465 let sid = match map.get("session_id") {
467 Some(serde_json::Value::String(s)) => serde_json::Value::String(s.clone()),
468 _ => serde_json::Value::String(String::new()),
469 };
470 map.insert("session_id".to_string(), sid);
471 }
472
473 val
474}
475
476fn check_json_path(val: &serde_json::Value, path: &[&str]) -> bool {
477 if path.is_empty() {
478 return true;
479 }
480 match val {
481 serde_json::Value::Object(map) => {
482 if let Some(next) = map.get(path[0]) {
483 check_json_path(next, &path[1..])
484 } else {
485 false
486 }
487 }
488 _ => false,
489 }
490}