Skip to main content

vasari_core/ingest/
mod.rs

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
17/// Where to read session data from.
18pub enum IngestSource {
19    File(PathBuf),
20    Stdin,
21}
22
23/// Events emitted by an adapter during session parsing.
24#[derive(Debug)]
25pub enum IngestEvent {
26    /// Signals the start of a session; provides the source label for the Intent.
27    SessionStart {
28        source: String,
29        started_at: DateTime<Utc>,
30    },
31    /// A substantive user message — each one opens a new turn (Intent + Plan).
32    UserPrompt {
33        text: String,
34        timestamp: DateTime<Utc>,
35    },
36    /// Any tool invocation observed in the session.
37    ToolCall {
38        name: String,
39        args: Value,
40        result_summary: String,
41        timestamp: DateTime<Utc>,
42        /// The agent's stated reason for this call, if any — the nearest
43        /// preceding assistant text (e.g. "Now I'll add the JWT check"). Becomes
44        /// the plan step goal so `vasari why` shows the specific intent behind a
45        /// line rather than a generic "Edit <file>" label.
46        rationale: Option<String>,
47    },
48    /// System-level instruction (e.g., CLAUDE.md content, session preamble).
49    SystemInstruction { text: String },
50}
51
52/// Outcome of a `run_pipeline` call.
53#[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
63/// Adapter contract: parse a source into a flat list of IngestEvents.
64pub trait IngestAdapter {
65    fn parse(&self, source: IngestSource) -> Result<Vec<IngestEvent>, VasariError>;
66}
67
68/// A redacted tool call: (tool name, args, result summary, timestamp, rationale).
69type ToolCallData = (String, Value, String, DateTime<Utc>, Option<String>);
70
71/// Whether a tool-call file path is safe to ingest. Single source of truth for
72/// path safety, shared by every adapter and the attribution synthesizer so the
73/// rule can't drift between entry points: reject parent-dir traversal (`..`) and
74/// absolute paths (which point outside the target repo).
75pub(crate) fn is_safe_path(path: &str) -> bool {
76    !path.contains("..") && !path.starts_with('/')
77}
78
79/// One conversational turn: a substantive user prompt plus the tool calls the
80/// agent made in response to it (in document order). Each turn becomes one
81/// Intent + one Plan, so `vasari why` resolves a line to the specific request
82/// that produced it rather than the whole session's first prompt.
83struct Turn {
84    text: String,
85    timestamp: DateTime<Utc>,
86    tools: Vec<ToolCallData>,
87}
88
89/// Run the ingest pipeline on a list of events.
90///
91/// Pipeline: redact → synthesize → store → index.
92/// Returns a summary of what was created; non-fatal degradations are
93/// collected into `summary.degraded` rather than returned as errors.
94pub fn run_pipeline(
95    events: Vec<IngestEvent>,
96    store: &ObjectStore,
97) -> Result<IngestSummary, VasariError> {
98    let mut summary = IngestSummary::default();
99
100    // Walk events in DOCUMENT order, grouping tool calls into turns. Each
101    // substantive user prompt opens a new turn; tool calls attach to the most
102    // recent preceding prompt. Document order (not timestamps, which are coarse
103    // and can fall back to wall-clock at ingest) is the causal chain.
104    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    // Tool calls seen before the first prompt — attached to turn 1.
108    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    // Require at least one user prompt to form an Intent.
148    if turns.is_empty() {
149        summary.degraded.push(DegradedReason::EmptySession {
150            source: session_source.clone(),
151        });
152        return Ok(summary);
153    }
154
155    // Pre-prompt tool calls belong to the first turn.
156    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    // One Intent + one Plan per turn. Collect plan IDs so session-level
163    // (system-instruction) constraints can be attached to every plan.
164    let mut plan_ids: Vec<NodeId> = Vec::with_capacity(turns.len());
165
166    // Running, document-order view of each file's content, so Edits can be
167    // located against the file as the agent saw it (from Read results).
168    let mut file_contents: std::collections::HashMap<String, String> =
169        std::collections::HashMap::new();
170
171    for turn in &turns {
172        // --- Intent (this turn's prompt) ---
173        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        // --- Plan: one step per tool call in this turn ---
184        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        // --- Actions + Attributions for this turn ---
199        for (step_index, (name, args, result_summary, timestamp, _rationale)) in
200            turn.tools.iter().enumerate()
201        {
202            // A Read result is the file's content as the agent saw it — record it
203            // so a later Edit can be located against it. Claude Code returns Read
204            // content in `cat -n` form (line-number + tab prefix per line); strip
205            // it so `old_string` (raw source) can be located by substring match.
206            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                // Truncated annotation only: full results stay in-memory for
219                // range computation (see truncate_summary).
220                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        // --- Per-prompt constraints: derived from THIS turn's intent ---
241        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    // --- Session-level constraints: system instructions apply to every plan ---
248    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
260/// Recursively redact all string leaves in a JSON value — both object keys and
261/// values. Best-effort (the underlying `redact` is heuristic); applied to
262/// tool-call args before they are stored in Action nodes, and reused by the
263/// corpus scrubber. Keys are redacted too so a secret embedded as a key
264/// (e.g. `{"<secret>": "used"}`) cannot slip through.
265pub 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
278/// Max bytes of a tool result kept in the stored `Action.result_summary`.
279/// Results (notably Read = full file contents) are only needed in-memory for
280/// range computation; the stored copy is a human-readable annotation.
281const MAX_RESULT_SUMMARY: usize = 2000;
282
283/// Truncate a result summary to [`MAX_RESULT_SUMMARY`] bytes on a char boundary,
284/// appending an ellipsis marker when truncated.
285fn 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
296/// Human-meaningful goal for a plan step: the tool plus its primary target,
297/// so `vasari diff` compares *what was done*, not just which tool ran.
298///
299/// File tools → `"Edit src/auth.rs"`. Other tools fall back to a short target
300/// hint (command / pattern / query, already redacted) or the bare tool name.
301fn step_goal(tool: &str, args: &Value, rationale: Option<&str>) -> String {
302    // The agent's own stated reason is the most specific goal available.
303    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
335/// Confidence for an attribution whose exact line range was located.
336const EXACT_CONFIDENCE: f32 = 1.0;
337/// Confidence for a whole-file degrade (range could not be located).
338const WHOLE_FILE_CONFIDENCE: f32 = 0.7;
339
340/// Produce attribution node(s) for a file-writing tool call, computing line
341/// ranges structurally where possible.
342///
343/// - **Write** `{file_path, content}` → `LineRange 1..N` (N = line count); the
344///   file's content becomes `content`.
345/// - **Edit** `{file_path, old_string, new_string}` → locate `old_string` in the
346///   file's known content (captured from a prior Read result) to get a
347///   `LineRange`; on success the content is updated so later edits resolve
348///   against the post-edit file. If the content is unknown or `old_string` is
349///   not found, degrade to `WholeFile`.
350/// - **MultiEdit** `{file_path, edits:[{old_string,new_string}]}` → one
351///   attribution per edit, applied in sequence.
352///
353/// `file_contents` is the running, document-order view of each file's text.
354/// Returns an empty vec for non-file tools.
355fn 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    // Defence-in-depth: the adapter pre-validates, but never trust a path here.
368    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                // Malformed MultiEdit → whole-file degrade.
423                _ => 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
436/// Resolve one Edit to an attribution, updating `file_contents` on success.
437fn 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                // Apply the edit so subsequent edits resolve against new content.
450                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    // Unknown content or old_string not found → honest whole-file degrade.
465    make(
466        AttributionTarget::WholeFile {
467            path: path.to_string(),
468        },
469        WHOLE_FILE_CONFIDENCE,
470        EvidenceKind::Fuzzed,
471    )
472}
473
474/// Strip Claude Code's `cat -n` line-number prefix (`<optional spaces><digits>\t`)
475/// from each line of a Read result, leaving the raw source so an Edit's
476/// `old_string` can be located by substring match. Lines without the prefix
477/// (e.g. wrapped notes) pass through unchanged, and the line count is preserved
478/// so computed ranges stay accurate.
479fn 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
490/// Strip a single leading `<spaces><digits>\t` prefix, or return the line as-is.
491fn 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    // Require at least one digit consumed and a tab immediately after.
495    if digits.len() < trimmed.len() {
496        if let Some(rest) = digits.strip_prefix('\t') {
497            return rest;
498        }
499    }
500    line
501}
502
503/// Number of lines a string spans (at least 1; a trailing newline doesn't add one).
504fn 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        // Read provides the file content; the Edit targets line 3.
613        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        // Claude Code Read format: `<spaces><n>\t<content>`.
647        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        // Unpadded form (as seen in real sessions for line 1).
653        assert_eq!(strip_read_line_numbers("1\thello"), "hello");
654        // Lines without the prefix pass through; line count is preserved.
655        let mixed = "1\tcode\nplain note\n3\tmore";
656        assert_eq!(strip_read_line_numbers(mixed), "code\nplain note\nmore");
657        // A tab inside content (no leading number) is untouched.
658        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        // End-to-end: a Read result in `cat -n` form must still let an Edit's
667        // raw `old_string` resolve to an exact line range (not whole-file).
668        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        // No preceding Read → content unknown → whole-file degrade.
703        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        // why on lines from different turns returns the turn-specific intent.
774        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            // A tool call with no preceding prompt (e.g. a resumed session).
796            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        // The pre-prompt edit is attributed to turn 1's intent, not dropped.
811        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        // No target hint → bare tool name.
835        assert_eq!(step_goal("Read", &json!({}), None), "Read");
836        // Long hints are truncated with an ellipsis.
837        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        // The agent's stated reason is the most specific goal — it wins over the
846        // generic "<tool> <path>" fallback.
847        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        // Empty/whitespace rationale falls back to the path label.
856        assert_eq!(
857            step_goal("Edit", &json!({ "file_path": "src/auth.rs" }), Some("   ")),
858            "Edit src/auth.rs"
859        );
860        // Long rationale is truncated with an ellipsis.
861        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        // Retrieve all Action nodes and verify the secret is not present in args.
1029        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}