1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde_json::Value;
5
6use crate::{
7 error::{DegradedReason, VasariError},
8 extract::constraints::extract_constraints,
9 redact::redact,
10 schema::{
11 Action, Attribution, AttributionTarget, Evidence, EvidenceKind, Intent, Node, NodeId, Plan,
12 PlanRef, PlanStep,
13 },
14 store::ObjectStore,
15};
16
17pub enum IngestSource {
19 File(PathBuf),
20 Stdin,
21}
22
23#[derive(Debug)]
25pub enum IngestEvent {
26 SessionStart {
28 source: String,
29 started_at: DateTime<Utc>,
30 },
31 UserPrompt {
33 text: String,
34 timestamp: DateTime<Utc>,
35 },
36 ToolCall {
38 name: String,
39 args: Value,
40 result_summary: String,
41 timestamp: DateTime<Utc>,
42 rationale: Option<String>,
47 },
48 SystemInstruction { text: String },
50}
51
52#[derive(Debug, Default)]
54pub struct IngestSummary {
55 pub intents_created: usize,
56 pub plans_created: usize,
57 pub constraints_created: usize,
58 pub actions_created: usize,
59 pub attributions_created: usize,
60 pub degraded: Vec<DegradedReason>,
61}
62
63pub trait IngestAdapter {
65 fn parse(&self, source: IngestSource) -> Result<Vec<IngestEvent>, VasariError>;
66}
67
68type ToolCallData = (String, Value, String, DateTime<Utc>, Option<String>);
70
71pub(crate) fn is_safe_path(path: &str) -> bool {
76 !path.contains("..") && !path.starts_with('/')
77}
78
79struct Turn {
84 text: String,
85 timestamp: DateTime<Utc>,
86 tools: Vec<ToolCallData>,
87}
88
89pub fn run_pipeline(
95 events: Vec<IngestEvent>,
96 store: &ObjectStore,
97) -> Result<IngestSummary, VasariError> {
98 let mut summary = IngestSummary::default();
99
100 let mut session_source = String::from("unknown-session");
105 let mut system_instructions: Vec<String> = Vec::new();
106 let mut turns: Vec<Turn> = Vec::new();
107 let mut pre_prompt_tools: Vec<ToolCallData> = Vec::new();
109
110 for event in events {
111 match event {
112 IngestEvent::SessionStart { source, .. } => {
113 session_source = source;
114 }
115 IngestEvent::UserPrompt { text, timestamp } => {
116 turns.push(Turn {
117 text: redact(&text),
118 timestamp,
119 tools: Vec::new(),
120 });
121 }
122 IngestEvent::ToolCall {
123 name,
124 args,
125 result_summary,
126 timestamp,
127 rationale,
128 } => {
129 let tc = (
130 name,
131 redact_value(args),
132 redact(&result_summary),
133 timestamp,
134 rationale.map(|r| redact(&r)),
135 );
136 match turns.last_mut() {
137 Some(turn) => turn.tools.push(tc),
138 None => pre_prompt_tools.push(tc),
139 }
140 }
141 IngestEvent::SystemInstruction { text } => {
142 system_instructions.push(redact(&text));
143 }
144 }
145 }
146
147 if turns.is_empty() {
149 summary.degraded.push(DegradedReason::EmptySession {
150 source: session_source.clone(),
151 });
152 return Ok(summary);
153 }
154
155 if !pre_prompt_tools.is_empty() {
157 let mut prepended = std::mem::take(&mut pre_prompt_tools);
158 prepended.append(&mut turns[0].tools);
159 turns[0].tools = prepended;
160 }
161
162 let mut plan_ids: Vec<NodeId> = Vec::with_capacity(turns.len());
165
166 let mut file_contents: std::collections::HashMap<String, String> =
169 std::collections::HashMap::new();
170
171 for turn in &turns {
172 let intent = Intent::new_at(
174 session_source.clone(),
175 turn.text.clone(),
176 turn.timestamp,
177 vec![],
178 );
179 let intent_id = intent.id.clone();
180 store.put(&Node::Intent(intent))?;
181 summary.intents_created += 1;
182
183 let steps: Vec<PlanStep> = turn
185 .tools
186 .iter()
187 .map(|(name, args, _, _, rationale)| PlanStep {
188 goal: step_goal(name, args, rationale.as_deref()),
189 constraints: vec![],
190 })
191 .collect();
192 let plan = Plan::new(vec![intent_id.clone()], steps, vec![]);
193 let plan_id = plan.id.clone();
194 store.put(&Node::Plan(plan))?;
195 summary.plans_created += 1;
196 plan_ids.push(plan_id.clone());
197
198 for (step_index, (name, args, result_summary, timestamp, _rationale)) in
200 turn.tools.iter().enumerate()
201 {
202 if name == "Read" {
207 if let Some(path) = args.get("file_path").and_then(|v| v.as_str()) {
208 if !result_summary.is_empty() {
209 file_contents
210 .insert(path.to_string(), strip_read_line_numbers(result_summary));
211 }
212 }
213 }
214
215 let action = Action::new(
216 name.clone(),
217 args.clone(),
218 truncate_summary(result_summary),
221 *timestamp,
222 PlanRef {
223 plan_id: plan_id.clone(),
224 step_index,
225 },
226 vec![intent_id.clone()],
227 );
228 let action_id = action.id.clone();
229 store.put(&Node::Action(action))?;
230 summary.actions_created += 1;
231
232 for attr in
233 attributions_for_action(name, args, &action_id, &intent_id, &mut file_contents)
234 {
235 store.put(&Node::Attribution(attr))?;
236 summary.attributions_created += 1;
237 }
238 }
239
240 for constraint in extract_constraints(&turn.text, intent_id.clone(), vec![]) {
242 store.put(&Node::Constraint(constraint))?;
243 summary.constraints_created += 1;
244 }
245 }
246
247 for sys in &system_instructions {
249 for plan_id in &plan_ids {
250 for constraint in extract_constraints(sys, plan_id.clone(), vec![]) {
251 store.put(&Node::Constraint(constraint))?;
252 summary.constraints_created += 1;
253 }
254 }
255 }
256
257 Ok(summary)
258}
259
260pub fn redact_value(value: Value) -> Value {
266 match value {
267 Value::String(s) => Value::String(redact(&s)),
268 Value::Array(arr) => Value::Array(arr.into_iter().map(redact_value).collect()),
269 Value::Object(map) => Value::Object(
270 map.into_iter()
271 .map(|(k, v)| (redact(&k), redact_value(v)))
272 .collect(),
273 ),
274 other => other,
275 }
276}
277
278const MAX_RESULT_SUMMARY: usize = 2000;
282
283fn truncate_summary(s: &str) -> String {
286 if s.len() <= MAX_RESULT_SUMMARY {
287 return s.to_string();
288 }
289 let mut end = MAX_RESULT_SUMMARY;
290 while end > 0 && !s.is_char_boundary(end) {
291 end -= 1;
292 }
293 format!("{}… [truncated]", &s[..end])
294}
295
296fn step_goal(tool: &str, args: &Value, rationale: Option<&str>) -> String {
302 if let Some(r) = rationale {
304 let r = r.trim();
305 if !r.is_empty() {
306 let truncated: String = r.chars().take(120).collect();
307 return if r.chars().count() > 120 {
308 format!("{truncated}…")
309 } else {
310 truncated
311 };
312 }
313 }
314 if let Some(path) = args.get("file_path").and_then(|v| v.as_str()) {
315 return format!("{tool} {path}");
316 }
317 let hint = args
318 .get("command")
319 .or_else(|| args.get("pattern"))
320 .or_else(|| args.get("query"))
321 .and_then(|v| v.as_str());
322 match hint {
323 Some(h) => {
324 let truncated: String = h.chars().take(60).collect();
325 if h.chars().count() > 60 {
326 format!("{tool} {truncated}…")
327 } else {
328 format!("{tool} {truncated}")
329 }
330 }
331 None => tool.to_string(),
332 }
333}
334
335const EXACT_CONFIDENCE: f32 = 1.0;
337const WHOLE_FILE_CONFIDENCE: f32 = 0.7;
339
340fn attributions_for_action(
356 tool: &str,
357 args: &Value,
358 action_id: &NodeId,
359 intent_id: &NodeId,
360 file_contents: &mut std::collections::HashMap<String, String>,
361) -> Vec<Attribution> {
362 let path = match tool {
363 "Edit" | "Write" | "MultiEdit" => args.get("file_path").and_then(|v| v.as_str()),
364 _ => return vec![],
365 };
366 let Some(path) = path else { return vec![] };
367 if !is_safe_path(path) {
369 return vec![];
370 }
371
372 let make = |target, confidence, kind| {
373 Attribution::new(
374 action_id.clone(),
375 target,
376 confidence,
377 vec![Evidence {
378 kind,
379 details: Value::Null,
380 }],
381 vec![intent_id.clone()],
382 )
383 };
384
385 match tool {
386 "Write" => {
387 let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
388 let lines = line_count(content);
389 file_contents.insert(path.to_string(), content.to_string());
390 vec![make(
391 AttributionTarget::LineRange {
392 path: path.to_string(),
393 start: 1,
394 end: lines,
395 },
396 EXACT_CONFIDENCE,
397 EvidenceKind::ExactRange,
398 )]
399 }
400 "Edit" => {
401 let old = args
402 .get("old_string")
403 .and_then(|v| v.as_str())
404 .unwrap_or("");
405 let new = args
406 .get("new_string")
407 .and_then(|v| v.as_str())
408 .unwrap_or("");
409 vec![edit_attribution(path, old, new, file_contents, &make)]
410 }
411 "MultiEdit" => {
412 let edits = args.get("edits").and_then(|v| v.as_array());
413 match edits {
414 Some(edits) if !edits.is_empty() => edits
415 .iter()
416 .map(|e| {
417 let old = e.get("old_string").and_then(|v| v.as_str()).unwrap_or("");
418 let new = e.get("new_string").and_then(|v| v.as_str()).unwrap_or("");
419 edit_attribution(path, old, new, file_contents, &make)
420 })
421 .collect(),
422 _ => vec![make(
424 AttributionTarget::WholeFile {
425 path: path.to_string(),
426 },
427 WHOLE_FILE_CONFIDENCE,
428 EvidenceKind::Fuzzed,
429 )],
430 }
431 }
432 _ => vec![],
433 }
434}
435
436fn edit_attribution(
438 path: &str,
439 old: &str,
440 new: &str,
441 file_contents: &mut std::collections::HashMap<String, String>,
442 make: &impl Fn(AttributionTarget, f32, EvidenceKind) -> Attribution,
443) -> Attribution {
444 if let Some(content) = file_contents.get(path) {
445 if !old.is_empty() {
446 if let Some(byte_idx) = content.find(old) {
447 let start = content[..byte_idx].matches('\n').count() as u32 + 1;
448 let end = start + line_count(new).saturating_sub(1);
449 let updated = content.replacen(old, new, 1);
451 file_contents.insert(path.to_string(), updated);
452 return make(
453 AttributionTarget::LineRange {
454 path: path.to_string(),
455 start,
456 end,
457 },
458 EXACT_CONFIDENCE,
459 EvidenceKind::ExactRange,
460 );
461 }
462 }
463 }
464 make(
466 AttributionTarget::WholeFile {
467 path: path.to_string(),
468 },
469 WHOLE_FILE_CONFIDENCE,
470 EvidenceKind::Fuzzed,
471 )
472}
473
474fn strip_read_line_numbers(s: &str) -> String {
480 let mut out = String::with_capacity(s.len());
481 for (i, line) in s.split('\n').enumerate() {
482 if i > 0 {
483 out.push('\n');
484 }
485 out.push_str(strip_one_line_number(line));
486 }
487 out
488}
489
490fn strip_one_line_number(line: &str) -> &str {
492 let trimmed = line.trim_start_matches(' ');
493 let digits = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
494 if digits.len() < trimmed.len() {
496 if let Some(rest) = digits.strip_prefix('\t') {
497 return rest;
498 }
499 }
500 line
501}
502
503fn line_count(s: &str) -> u32 {
505 if s.is_empty() {
506 return 1;
507 }
508 s.lines().count().max(1) as u32
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use chrono::Utc;
515 use serde_json::json;
516
517 fn make_store() -> (ObjectStore, tempfile::TempDir) {
518 let dir = tempfile::tempdir().unwrap();
519 let store = ObjectStore::open(dir.path()).unwrap();
520 (store, dir)
521 }
522
523 #[test]
524 fn empty_session_is_degraded_not_error() {
525 let (store, _dir) = make_store();
526 let summary = run_pipeline(vec![], &store).unwrap();
527 assert_eq!(summary.intents_created, 0);
528 assert_eq!(summary.degraded.len(), 1);
529 assert!(matches!(
530 summary.degraded[0],
531 DegradedReason::EmptySession { .. }
532 ));
533 }
534
535 #[test]
536 fn minimal_session_creates_intent_and_plan() {
537 let (store, _dir) = make_store();
538 let events = vec![
539 IngestEvent::SessionStart {
540 source: "test-session".into(),
541 started_at: Utc::now(),
542 },
543 IngestEvent::UserPrompt {
544 text: "Add JWT verification to the auth module".into(),
545 timestamp: Utc::now(),
546 },
547 IngestEvent::ToolCall {
548 name: "Edit".into(),
549 args: json!({ "file_path": "src/auth.rs", "old_string": "", "new_string": "" }),
550 result_summary: "Edited src/auth.rs".into(),
551 timestamp: Utc::now(),
552 rationale: None,
553 },
554 ];
555
556 let summary = run_pipeline(events, &store).unwrap();
557 assert_eq!(summary.intents_created, 1);
558 assert_eq!(summary.plans_created, 1);
559 assert_eq!(summary.actions_created, 1);
560 assert_eq!(summary.attributions_created, 1);
561 assert!(summary.degraded.is_empty());
562 }
563
564 fn attr_target_for(store: &ObjectStore, path: &str) -> AttributionTarget {
565 store
566 .iter_all()
567 .unwrap()
568 .into_iter()
569 .find_map(|n| match n {
570 Node::Attribution(a) => {
571 let p = match &a.target {
572 AttributionTarget::LineRange { path, .. }
573 | AttributionTarget::WholeFile { path } => path.clone(),
574 AttributionTarget::CommitSha { .. } => return None,
575 };
576 (p == path).then_some(a.target)
577 }
578 _ => None,
579 })
580 .expect("attribution for path")
581 }
582
583 #[test]
584 fn write_attribution_is_line_range_one_to_n() {
585 let (store, _dir) = make_store();
586 let content = "line1\nline2\nline3\n";
587 let events = vec![
588 IngestEvent::UserPrompt {
589 text: "create the module".into(),
590 timestamp: Utc::now(),
591 },
592 IngestEvent::ToolCall {
593 name: "Write".into(),
594 args: json!({ "file_path": "src/new.rs", "content": content }),
595 result_summary: "New file created".into(),
596 timestamp: Utc::now(),
597 rationale: None,
598 },
599 ];
600 run_pipeline(events, &store).unwrap();
601 match attr_target_for(&store, "src/new.rs") {
602 AttributionTarget::LineRange { start, end, .. } => {
603 assert_eq!((start, end), (1, 3));
604 }
605 other => panic!("expected LineRange, got {other:?}"),
606 }
607 }
608
609 #[test]
610 fn edit_located_against_prior_read_yields_line_range() {
611 let (store, _dir) = make_store();
612 let file = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
614 let events = vec![
615 IngestEvent::UserPrompt {
616 text: "rewrite function c".into(),
617 timestamp: Utc::now(),
618 },
619 IngestEvent::ToolCall {
620 name: "Read".into(),
621 args: json!({ "file_path": "src/x.rs" }),
622 result_summary: file.into(),
623 timestamp: Utc::now(),
624 rationale: None,
625 },
626 IngestEvent::ToolCall {
627 name: "Edit".into(),
628 args: json!({ "file_path": "src/x.rs", "old_string": "fn c() {}", "new_string": "fn c() { c2(); }" }),
629 result_summary: "ok".into(),
630 timestamp: Utc::now(),
631 rationale: None,
632 },
633 ];
634 run_pipeline(events, &store).unwrap();
635 match attr_target_for(&store, "src/x.rs") {
636 AttributionTarget::LineRange { start, end, .. } => {
637 assert_eq!(start, 3, "fn c() is on line 3");
638 assert_eq!(end, 3, "single-line replacement");
639 }
640 other => panic!("expected LineRange, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn strip_read_line_numbers_removes_cat_n_prefix() {
646 let read = " 1\tfn a() {}\n 2\tfn b() {}\n 10\tfn j() {}";
648 assert_eq!(
649 strip_read_line_numbers(read),
650 "fn a() {}\nfn b() {}\nfn j() {}"
651 );
652 assert_eq!(strip_read_line_numbers("1\thello"), "hello");
654 let mixed = "1\tcode\nplain note\n3\tmore";
656 assert_eq!(strip_read_line_numbers(mixed), "code\nplain note\nmore");
657 assert_eq!(
659 strip_read_line_numbers("no number\there"),
660 "no number\there"
661 );
662 }
663
664 #[test]
665 fn edit_locates_against_cat_n_read_result() {
666 let (store, _dir) = make_store();
669 let read = " 1\tfn a() {}\n 2\tfn b() {}\n 3\tfn c() {}\n";
670 let events = vec![
671 IngestEvent::UserPrompt {
672 text: "rewrite c".into(),
673 timestamp: Utc::now(),
674 },
675 IngestEvent::ToolCall {
676 name: "Read".into(),
677 args: json!({ "file_path": "src/x.rs" }),
678 result_summary: read.into(),
679 timestamp: Utc::now(),
680 rationale: None,
681 },
682 IngestEvent::ToolCall {
683 name: "Edit".into(),
684 args: json!({ "file_path": "src/x.rs", "old_string": "fn c() {}", "new_string": "fn c() { c2(); }" }),
685 result_summary: "ok".into(),
686 timestamp: Utc::now(),
687 rationale: None,
688 },
689 ];
690 run_pipeline(events, &store).unwrap();
691 match attr_target_for(&store, "src/x.rs") {
692 AttributionTarget::LineRange { start, end, .. } => {
693 assert_eq!((start, end), (3, 3), "fn c() is on line 3");
694 }
695 other => panic!("expected exact LineRange, got {other:?}"),
696 }
697 }
698
699 #[test]
700 fn edit_without_known_content_degrades_to_whole_file() {
701 let (store, _dir) = make_store();
702 let events = vec![
704 IngestEvent::UserPrompt {
705 text: "tweak it".into(),
706 timestamp: Utc::now(),
707 },
708 IngestEvent::ToolCall {
709 name: "Edit".into(),
710 args: json!({ "file_path": "src/y.rs", "old_string": "a", "new_string": "b" }),
711 result_summary: String::new(),
712 timestamp: Utc::now(),
713 rationale: None,
714 },
715 ];
716 run_pipeline(events, &store).unwrap();
717 assert!(matches!(
718 attr_target_for(&store, "src/y.rs"),
719 AttributionTarget::WholeFile { .. }
720 ));
721 }
722
723 #[test]
724 fn is_safe_path_rejects_traversal_and_absolute() {
725 assert!(is_safe_path("src/auth.rs"));
726 assert!(is_safe_path("a/b/c.rs"));
727 assert!(!is_safe_path("../etc/passwd"));
728 assert!(!is_safe_path("a/../../b"));
729 assert!(!is_safe_path("/etc/passwd"));
730 assert!(!is_safe_path("/Users/x/secret"));
731 }
732
733 #[test]
734 fn line_count_handles_edges() {
735 assert_eq!(line_count(""), 1);
736 assert_eq!(line_count("one"), 1);
737 assert_eq!(line_count("a\nb"), 2);
738 assert_eq!(line_count("a\nb\n"), 2);
739 }
740
741 #[test]
742 fn multi_turn_session_creates_one_intent_per_turn() {
743 let (store, _dir) = make_store();
744 let t0 = Utc::now();
745 let events = vec![
746 IngestEvent::UserPrompt {
747 text: "Add JWT verification to auth".into(),
748 timestamp: t0,
749 },
750 IngestEvent::ToolCall {
751 name: "Edit".into(),
752 args: json!({ "file_path": "src/auth.rs", "old_string": "a", "new_string": "b" }),
753 result_summary: String::new(),
754 timestamp: t0,
755 rationale: None,
756 },
757 IngestEvent::UserPrompt {
758 text: "Now refactor the date helpers".into(),
759 timestamp: t0,
760 },
761 IngestEvent::ToolCall {
762 name: "Edit".into(),
763 args: json!({ "file_path": "src/dates.rs", "old_string": "c", "new_string": "d" }),
764 result_summary: String::new(),
765 timestamp: t0,
766 rationale: None,
767 },
768 ];
769 let summary = run_pipeline(events, &store).unwrap();
770 assert_eq!(summary.intents_created, 2, "one intent per turn");
771 assert_eq!(summary.plans_created, 2, "one plan per turn");
772
773 let a = crate::resolve::why(&store, "src/auth.rs", 1)
775 .unwrap()
776 .unwrap();
777 let d = crate::resolve::why(&store, "src/dates.rs", 1)
778 .unwrap()
779 .unwrap();
780 assert_eq!(
781 a.primary_intent().unwrap().text,
782 "Add JWT verification to auth"
783 );
784 assert_eq!(
785 d.primary_intent().unwrap().text,
786 "Now refactor the date helpers"
787 );
788 }
789
790 #[test]
791 fn tool_calls_before_first_prompt_attach_to_turn_one() {
792 let (store, _dir) = make_store();
793 let t0 = Utc::now();
794 let events = vec![
795 IngestEvent::ToolCall {
797 name: "Edit".into(),
798 args: json!({ "file_path": "src/early.rs", "old_string": "a", "new_string": "b" }),
799 result_summary: String::new(),
800 timestamp: t0,
801 rationale: None,
802 },
803 IngestEvent::UserPrompt {
804 text: "the actual first prompt".into(),
805 timestamp: t0,
806 },
807 ];
808 let summary = run_pipeline(events, &store).unwrap();
809 assert_eq!(summary.intents_created, 1);
810 let chain = crate::resolve::why(&store, "src/early.rs", 1)
812 .unwrap()
813 .unwrap();
814 assert_eq!(
815 chain.primary_intent().unwrap().text,
816 "the actual first prompt"
817 );
818 }
819
820 #[test]
821 fn step_goal_carries_file_path_and_falls_back() {
822 assert_eq!(
823 step_goal("Edit", &json!({ "file_path": "src/auth.rs" }), None),
824 "Edit src/auth.rs"
825 );
826 assert_eq!(
827 step_goal("Bash", &json!({ "command": "cargo test" }), None),
828 "Bash cargo test"
829 );
830 assert_eq!(
831 step_goal("Grep", &json!({ "pattern": "TODO" }), None),
832 "Grep TODO"
833 );
834 assert_eq!(step_goal("Read", &json!({}), None), "Read");
836 let long = "x".repeat(80);
838 let goal = step_goal("Bash", &json!({ "command": long }), None);
839 assert!(goal.starts_with("Bash "));
840 assert!(goal.ends_with('…'));
841 }
842
843 #[test]
844 fn step_goal_prefers_agent_rationale_over_file_path() {
845 assert_eq!(
848 step_goal(
849 "Edit",
850 &json!({ "file_path": "src/auth.rs" }),
851 Some("Add the JWT signature check before the user lookup"),
852 ),
853 "Add the JWT signature check before the user lookup"
854 );
855 assert_eq!(
857 step_goal("Edit", &json!({ "file_path": "src/auth.rs" }), Some(" ")),
858 "Edit src/auth.rs"
859 );
860 let long = "y".repeat(200);
862 let goal = step_goal("Edit", &json!({ "file_path": "src/x.rs" }), Some(&long));
863 assert!(goal.ends_with('…'));
864 assert!(goal.chars().count() <= 121);
865 }
866
867 #[test]
868 fn plan_step_goal_reflects_edited_file() {
869 let (store, _dir) = make_store();
870 let events = vec![
871 IngestEvent::UserPrompt {
872 text: "do the thing".into(),
873 timestamp: Utc::now(),
874 },
875 IngestEvent::ToolCall {
876 name: "Edit".into(),
877 args: json!({ "file_path": "src/auth.rs", "old_string": "a", "new_string": "b" }),
878 result_summary: String::new(),
879 timestamp: Utc::now(),
880 rationale: None,
881 },
882 ];
883 run_pipeline(events, &store).unwrap();
884 let plan = store
885 .iter_all()
886 .unwrap()
887 .into_iter()
888 .find_map(|n| if let Node::Plan(p) = n { Some(p) } else { None })
889 .expect("a plan was created");
890 assert_eq!(plan.steps[0].goal, "Edit src/auth.rs");
891 }
892
893 #[test]
894 fn constraint_keywords_in_prompt_create_constraints() {
895 let (store, _dir) = make_store();
896 let events = vec![IngestEvent::UserPrompt {
897 text:
898 "You must validate all inputs before writing. Never store passwords in plaintext."
899 .into(),
900 timestamp: Utc::now(),
901 }];
902
903 let summary = run_pipeline(events, &store).unwrap();
904 assert!(summary.constraints_created >= 2);
905 }
906
907 #[test]
908 fn dotdot_path_produces_no_attribution() {
909 let (store, _dir) = make_store();
910 let events = vec![
911 IngestEvent::UserPrompt {
912 text: "test".into(),
913 timestamp: Utc::now(),
914 },
915 IngestEvent::ToolCall {
916 name: "Write".into(),
917 args: json!({ "file_path": "../escape/path.rs" }),
918 result_summary: String::new(),
919 timestamp: Utc::now(),
920 rationale: None,
921 },
922 ];
923
924 let summary = run_pipeline(events, &store).unwrap();
925 assert_eq!(summary.attributions_created, 0);
926 }
927
928 #[test]
929 fn multi_edit_produces_attribution() {
930 let (store, _dir) = make_store();
931 let events = vec![
932 IngestEvent::UserPrompt {
933 text: "refactor the module".into(),
934 timestamp: Utc::now(),
935 },
936 IngestEvent::ToolCall {
937 name: "MultiEdit".into(),
938 args: json!({ "file_path": "src/lib.rs" }),
939 result_summary: String::new(),
940 timestamp: Utc::now(),
941 rationale: None,
942 },
943 ];
944 let summary = run_pipeline(events, &store).unwrap();
945 assert_eq!(summary.attributions_created, 1);
946 }
947
948 #[test]
949 fn bash_tool_produces_no_attribution() {
950 let (store, _dir) = make_store();
951 let events = vec![
952 IngestEvent::UserPrompt {
953 text: "run tests".into(),
954 timestamp: Utc::now(),
955 },
956 IngestEvent::ToolCall {
957 name: "Bash".into(),
958 args: json!({ "command": "cargo test" }),
959 result_summary: "all tests passed".into(),
960 timestamp: Utc::now(),
961 rationale: None,
962 },
963 ];
964 let summary = run_pipeline(events, &store).unwrap();
965 assert_eq!(
966 summary.attributions_created, 0,
967 "Bash tool should not produce attribution"
968 );
969 }
970
971 #[test]
972 fn multiple_tool_calls_produce_multiple_actions() {
973 let (store, _dir) = make_store();
974 let events = vec![
975 IngestEvent::UserPrompt {
976 text: "add jwt and update tests".into(),
977 timestamp: Utc::now(),
978 },
979 IngestEvent::ToolCall {
980 name: "Edit".into(),
981 args: json!({ "file_path": "src/auth.rs" }),
982 result_summary: String::new(),
983 timestamp: Utc::now(),
984 rationale: None,
985 },
986 IngestEvent::ToolCall {
987 name: "Write".into(),
988 args: json!({ "file_path": "src/jwt.rs" }),
989 result_summary: String::new(),
990 timestamp: Utc::now(),
991 rationale: None,
992 },
993 IngestEvent::ToolCall {
994 name: "Bash".into(),
995 args: json!({ "command": "cargo test" }),
996 result_summary: String::new(),
997 timestamp: Utc::now(),
998 rationale: None,
999 },
1000 ];
1001 let summary = run_pipeline(events, &store).unwrap();
1002 assert_eq!(summary.actions_created, 3);
1003 assert_eq!(
1004 summary.attributions_created, 2,
1005 "only Edit + Write produce attributions"
1006 );
1007 }
1008
1009 #[test]
1010 fn tool_call_args_are_redacted_before_storage() {
1011 let (store, _dir) = make_store();
1012 let secret = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890abcdef";
1013 let events = vec![
1014 IngestEvent::UserPrompt {
1015 text: "write the config".into(),
1016 timestamp: Utc::now(),
1017 },
1018 IngestEvent::ToolCall {
1019 name: "Write".into(),
1020 args: json!({ "file_path": "config.toml", "new_content": secret }),
1021 result_summary: String::new(),
1022 timestamp: Utc::now(),
1023 rationale: None,
1024 },
1025 ];
1026 run_pipeline(events, &store).unwrap();
1027
1028 let nodes = store.iter_all().unwrap();
1030 for node in nodes {
1031 if let crate::Node::Action(action) = node {
1032 let serialized = serde_json::to_string(&action.args).unwrap();
1033 assert!(
1034 !serialized.contains("sk-ant-api03-"),
1035 "Anthropic API key must not survive in stored Action args"
1036 );
1037 }
1038 }
1039 }
1040
1041 #[test]
1042 fn system_instruction_constraints_are_extracted() {
1043 let (store, _dir) = make_store();
1044 let events = vec![
1045 IngestEvent::UserPrompt {
1046 text: "make the thing work".into(),
1047 timestamp: Utc::now(),
1048 },
1049 IngestEvent::SystemInstruction {
1050 text: "You must always validate user input. Never access external APIs without approval.".into(),
1051 },
1052 ];
1053 let summary = run_pipeline(events, &store).unwrap();
1054 assert!(
1055 summary.constraints_created >= 2,
1056 "system instruction constraints should be extracted"
1057 );
1058 }
1059}