Skip to main content

vasari_core/
resolve.rs

1use std::cmp::Reverse;
2use std::collections::HashSet;
3
4use crate::error::VasariError;
5use crate::schema::{Action, Attribution, Constraint, Intent, Node, NodeId, Plan, PlanStep};
6use crate::store::ObjectStore;
7
8/// The full attribution chain for a file:line query.
9///
10/// Fields are ordered answer-first: `intents` carries the "why" answer;
11/// the remaining fields provide the causal chain that produced it.
12///
13/// **Line staleness:** line numbers are matched against the ranges recorded
14/// at ingest time. If lines have moved since the last `vasari ingest` run
15/// (e.g. after a refactor that inserts lines above the queried line),
16/// results may be stale. Re-run `vasari ingest` after significant refactors.
17#[derive(Debug, Clone)]
18pub struct ResolveChain {
19    /// The intent(s) that motivated this line of code — the "why" answer.
20    /// Sorted by `created_at` descending (most recent amendment first).
21    /// Empty only for orphan plans (plans with no `intent_ids`).
22    pub intents: Vec<Intent>,
23    /// The plan that structured the work.
24    pub plan: Plan,
25    /// Index into `plan.steps` identifying the specific step that produced this line.
26    pub plan_step_index: usize,
27    /// The agent action (tool call) that wrote the code.
28    pub action: Action,
29    /// The attribution node linking the action to this file:line range.
30    pub attribution: Attribution,
31    /// Constraints that shaped this work: any Constraint derived from this
32    /// plan or one of its intents (e.g. "must validate the token signature",
33    /// "never store the secret in source"). Sorted by text. Empty when the
34    /// session carried no extractable constraints.
35    pub constraints: Vec<Constraint>,
36}
37
38impl ResolveChain {
39    /// The specific plan step that produced this line.
40    pub fn plan_step(&self) -> Option<&PlanStep> {
41        self.plan.steps.get(self.plan_step_index)
42    }
43
44    /// Attribution confidence: how strongly the ingest adapter believes this
45    /// action produced this target. 0.0–1.0; 1.0 = exact range match.
46    pub fn confidence(&self) -> f32 {
47        self.attribution.confidence
48    }
49
50    /// The most recent intent (the originating or most-recently-amended one).
51    /// Use this when you need a single "primary" answer for display.
52    pub fn primary_intent(&self) -> Option<&Intent> {
53        self.intents.first()
54    }
55}
56
57/// Answer "which intent caused this file:line to exist?"
58///
59/// Returns the single best-matched [`ResolveChain`] for the given target,
60/// or `None` if no attribution covers that line. When multiple attributions
61/// overlap the same line, returns the one with the highest confidence.
62/// Use [`why_all`] to get all overlapping chains.
63///
64/// # Errors
65/// Returns `Err` if the object store is inaccessible or if a referenced node
66/// is missing (which indicates graph corruption — run `vasari fsck` to diagnose).
67pub fn why(
68    store: &ObjectStore,
69    path: &str,
70    line: u32,
71) -> Result<Option<ResolveChain>, VasariError> {
72    let mut chains = why_all(store, path, line)?;
73    // Prefer the highest-confidence chain when there are multiple.
74    chains.sort_by(|a, b| {
75        b.attribution
76            .confidence
77            .partial_cmp(&a.attribution.confidence)
78            .unwrap_or(std::cmp::Ordering::Less) // NaN sorts as lowest confidence
79    });
80    Ok(chains.into_iter().next())
81}
82
83/// Answer "which intents caused this file:line to exist?" for all overlapping attributions.
84///
85/// Most callers should use [`why`] — this returns the full set only for cases
86/// where multiple agent actions have overlapping line ranges (e.g. an edit
87/// followed by a cleanup pass).
88///
89/// # Errors
90/// Same as [`why`].
91pub fn why_all(
92    store: &ObjectStore,
93    path: &str,
94    line: u32,
95) -> Result<Vec<ResolveChain>, VasariError> {
96    let attr_ids = store.lookup_attributions(path, line)?;
97
98    // Deduplicate attribution IDs before walking — the append-only index can
99    // accumulate duplicates if the store is manually manipulated.
100    let mut seen = HashSet::new();
101    let attr_ids: Vec<NodeId> = attr_ids
102        .into_iter()
103        .filter(|id| seen.insert(id.clone()))
104        .collect();
105
106    let mut chains = Vec::new();
107
108    for attr_id in &attr_ids {
109        match resolve_one(store, attr_id) {
110            Ok(chain) => chains.push(chain),
111            Err(VasariError::NodeNotFound(msg)) => {
112                // Soft-log integrity gaps: the store may have been partially
113                // written. The caller (cmd_why) still gets partial results.
114                eprintln!("vasari: warn: skipping attribution {attr_id}: {msg}");
115            }
116            Err(e) => return Err(e),
117        }
118    }
119
120    Ok(chains)
121}
122
123/// Walk a single attribution ID to build a complete ResolveChain.
124fn resolve_one(store: &ObjectStore, attr_id: &NodeId) -> Result<ResolveChain, VasariError> {
125    let attr = match store.get(attr_id)? {
126        Some(Node::Attribution(a)) => a,
127        Some(_) => {
128            return Err(VasariError::NodeNotFound(format!(
129                "id {attr_id} is not an Attribution node"
130            )))
131        }
132        None => {
133            return Err(VasariError::NodeNotFound(format!(
134                "attribution node {attr_id} not found — run `vasari fsck` to rebuild the index"
135            )))
136        }
137    };
138
139    let action = match store.get(&attr.action_id)? {
140        Some(Node::Action(a)) => a,
141        Some(_) => {
142            return Err(VasariError::NodeNotFound(format!(
143                "action id {} is not an Action node",
144                attr.action_id
145            )))
146        }
147        None => {
148            return Err(VasariError::NodeNotFound(format!(
149                "action node {} not found — graph may be incomplete; run `vasari ingest` again",
150                attr.action_id
151            )))
152        }
153    };
154
155    let plan = match store.get(&action.plan_ref.plan_id)? {
156        Some(Node::Plan(p)) => p,
157        Some(_) => {
158            return Err(VasariError::NodeNotFound(format!(
159                "plan id {} is not a Plan node",
160                action.plan_ref.plan_id
161            )))
162        }
163        None => {
164            return Err(VasariError::NodeNotFound(format!(
165                "plan node {} not found — graph may be incomplete; run `vasari ingest` again",
166                action.plan_ref.plan_id
167            )))
168        }
169    };
170
171    let step_index = action.plan_ref.step_index;
172    if step_index >= plan.steps.len() {
173        return Err(VasariError::PlanStepOutOfBounds {
174            plan_id: plan.id.to_string(),
175            step_index,
176            step_count: plan.steps.len(),
177        });
178    }
179
180    // Resolve all intents. Missing individual intents are soft-logged —
181    // the plan may legitimately reference a pruned or deferred intent.
182    let mut intents: Vec<Intent> = Vec::new();
183    for intent_id in &plan.intent_ids {
184        match store.get(intent_id)? {
185            Some(Node::Intent(i)) => intents.push(i),
186            Some(_) => {
187                eprintln!("vasari: warn: intent id {intent_id} is not an Intent node — skipping")
188            }
189            None => eprintln!("vasari: warn: intent node {intent_id} not found — skipping"),
190        }
191    }
192    // Most recent amendment first.
193    intents.sort_by_key(|i| Reverse(i.created_at));
194
195    // Join constraints attached to this chain: any Constraint whose
196    // `derived_from` points at this plan or one of its intents. v0.x scans the
197    // object store (one ingest produces a handful of constraints); if constraint
198    // volume grows, a `derived_from` index can replace this scan.
199    let mut relevant: HashSet<NodeId> = plan.intent_ids.iter().cloned().collect();
200    relevant.insert(plan.id.clone());
201    let mut constraints: Vec<Constraint> = store
202        .iter_all()?
203        .into_iter()
204        .filter_map(|n| match n {
205            Node::Constraint(c) if relevant.contains(&c.derived_from) => Some(c),
206            _ => None,
207        })
208        .collect();
209    // Stable, deterministic order for display + tests.
210    constraints.sort_by(|a, b| a.text.cmp(&b.text));
211
212    Ok(ResolveChain {
213        intents,
214        plan,
215        plan_step_index: step_index,
216        action,
217        attribution: attr,
218        constraints,
219    })
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::schema::{Attribution, AttributionTarget, Node, Plan, PlanRef, PlanStep};
226    use chrono::Utc;
227
228    fn make_store() -> (ObjectStore, tempfile::TempDir) {
229        let dir = tempfile::tempdir().unwrap();
230        let store = ObjectStore::open(dir.path()).unwrap();
231        (store, dir)
232    }
233
234    fn make_intent(store: &ObjectStore, source: &str, text: &str) -> Intent {
235        let intent = Intent::new(source.into(), text.into(), vec![]);
236        store.put(&Node::Intent(intent.clone())).unwrap();
237        intent
238    }
239
240    fn make_plan(store: &ObjectStore, intent_ids: Vec<NodeId>, steps: Vec<PlanStep>) -> Plan {
241        let plan = Plan::new(intent_ids, steps, vec![]);
242        store.put(&Node::Plan(plan.clone())).unwrap();
243        plan
244    }
245
246    fn make_action(store: &ObjectStore, tool: &str, plan_id: NodeId, step_index: usize) -> Action {
247        let action = Action::new(
248            tool.into(),
249            serde_json::json!({ "path": "src/auth.ts" }),
250            String::new(),
251            Utc::now(),
252            PlanRef {
253                plan_id,
254                step_index,
255            },
256            vec![],
257        );
258        store.put(&Node::Action(action.clone())).unwrap();
259        action
260    }
261
262    fn make_attr(
263        store: &ObjectStore,
264        action_id: NodeId,
265        path: &str,
266        start: u32,
267        end: u32,
268        confidence: f32,
269    ) -> Attribution {
270        let attr = Attribution::new(
271            action_id,
272            AttributionTarget::LineRange {
273                path: path.into(),
274                start,
275                end,
276            },
277            confidence,
278            vec![],
279            vec![],
280        );
281        store.put(&Node::Attribution(attr.clone())).unwrap();
282        attr
283    }
284
285    fn make_constraint(store: &ObjectStore, text: &str, derived_from: NodeId) -> Constraint {
286        use crate::schema::ConstraintPolarity;
287        let c = Constraint::new(
288            text.into(),
289            derived_from,
290            ConstraintPolarity::Mandatory,
291            vec![],
292        );
293        store.put(&Node::Constraint(c.clone())).unwrap();
294        c
295    }
296
297    #[test]
298    fn chain_includes_constraints_derived_from_intent_and_excludes_others() {
299        let (store, _dir) = make_store();
300        let intent = make_intent(&store, "ACME-1", "Add JWT verification");
301        let plan = make_plan(
302            &store,
303            vec![intent.id.clone()],
304            vec![PlanStep {
305                goal: "invoke Edit".into(),
306                constraints: vec![],
307            }],
308        );
309        let action = make_action(&store, "Edit", plan.id.clone(), 0);
310        make_attr(&store, action.id.clone(), "src/auth.ts", 1, u32::MAX, 0.7);
311
312        // Two constraints derived from the intent (should both appear), one
313        // derived from an unrelated node (should be excluded).
314        make_constraint(
315            &store,
316            "must validate the token signature",
317            intent.id.clone(),
318        );
319        make_constraint(
320            &store,
321            "never store the secret in source",
322            intent.id.clone(),
323        );
324        make_constraint(&store, "unrelated rule", NodeId("deadbeef".repeat(8)));
325
326        let chain = why(&store, "src/auth.ts", 47).unwrap().unwrap();
327        let texts: Vec<&str> = chain.constraints.iter().map(|c| c.text.as_str()).collect();
328        assert_eq!(
329            texts,
330            vec![
331                "must validate the token signature",
332                "never store the secret in source"
333            ],
334            "only intent-derived constraints, sorted by text"
335        );
336    }
337
338    #[test]
339    fn chain_constraints_empty_when_none_attached() {
340        let (store, _dir) = make_store();
341        let intent = make_intent(&store, "s", "do a thing");
342        let plan = make_plan(
343            &store,
344            vec![intent.id.clone()],
345            vec![PlanStep {
346                goal: "invoke Write".into(),
347                constraints: vec![],
348            }],
349        );
350        let action = make_action(&store, "Write", plan.id.clone(), 0);
351        make_attr(&store, action.id.clone(), "src/x.rs", 1, u32::MAX, 0.7);
352        let chain = why(&store, "src/x.rs", 1).unwrap().unwrap();
353        assert!(chain.constraints.is_empty());
354    }
355
356    #[test]
357    fn why_returns_none_for_empty_store() {
358        let (store, _dir) = make_store();
359        assert!(why(&store, "src/main.rs", 1).unwrap().is_none());
360    }
361
362    #[test]
363    fn why_all_returns_empty_for_empty_store() {
364        let (store, _dir) = make_store();
365        assert!(why_all(&store, "src/main.rs", 1).unwrap().is_empty());
366    }
367
368    #[test]
369    fn why_full_happy_path() {
370        let (store, _dir) = make_store();
371
372        let intent = make_intent(
373            &store,
374            "ACME-411",
375            "Add JWT verification before the user-id lookup",
376        );
377        let plan = make_plan(
378            &store,
379            vec![intent.id.clone()],
380            vec![
381                PlanStep {
382                    goal: "Read existing auth code".into(),
383                    constraints: vec![],
384                },
385                PlanStep {
386                    goal: "Add JWT verification middleware".into(),
387                    constraints: vec!["no async/await".into()],
388                },
389            ],
390        );
391        let action = make_action(&store, "edit_file", plan.id.clone(), 1);
392        make_attr(&store, action.id.clone(), "src/auth.ts", 40, 55, 1.0);
393
394        let chain = why(&store, "src/auth.ts", 47)
395            .unwrap()
396            .expect("chain must be found");
397
398        assert_eq!(chain.intents.len(), 1);
399        assert_eq!(
400            chain.intents[0].text,
401            "Add JWT verification before the user-id lookup"
402        );
403        assert_eq!(chain.intents[0].id, intent.id);
404        assert_eq!(chain.plan_step_index, 1);
405
406        let step = chain.plan_step().expect("plan step must be present");
407        assert_eq!(step.goal, "Add JWT verification middleware");
408
409        assert!((chain.confidence() - 1.0).abs() < f32::EPSILON);
410        assert!(chain.constraints.is_empty());
411
412        let primary = chain
413            .primary_intent()
414            .expect("primary intent must be present");
415        assert_eq!(primary.id, intent.id);
416    }
417
418    #[test]
419    fn why_returns_highest_confidence_when_multiple() {
420        let (store, _dir) = make_store();
421
422        let i1 = make_intent(&store, "ACME-411", "First intent");
423        let p1 = make_plan(
424            &store,
425            vec![i1.id.clone()],
426            vec![PlanStep {
427                goal: "step A".into(),
428                constraints: vec![],
429            }],
430        );
431        let a1 = make_action(&store, "edit_file", p1.id.clone(), 0);
432        make_attr(&store, a1.id.clone(), "src/auth.ts", 40, 55, 0.9);
433
434        let i2 = make_intent(&store, "ACME-419", "Second intent");
435        let p2 = make_plan(
436            &store,
437            vec![i2.id.clone()],
438            vec![PlanStep {
439                goal: "step B".into(),
440                constraints: vec![],
441            }],
442        );
443        let a2 = make_action(&store, "str_replace", p2.id.clone(), 0);
444        make_attr(&store, a2.id.clone(), "src/auth.ts", 44, 50, 0.7);
445
446        let chains = why_all(&store, "src/auth.ts", 47).unwrap();
447        assert_eq!(chains.len(), 2);
448
449        let best = why(&store, "src/auth.ts", 47).unwrap().unwrap();
450        assert!((best.confidence() - 0.9).abs() < f32::EPSILON);
451        assert_eq!(best.intents[0].source, "ACME-411");
452    }
453
454    #[test]
455    fn step_index_out_of_bounds_returns_error() {
456        let (store, _dir) = make_store();
457
458        let intent = make_intent(&store, "src", "test");
459        let plan = make_plan(
460            &store,
461            vec![intent.id.clone()],
462            vec![PlanStep {
463                goal: "only step".into(),
464                constraints: vec![],
465            }],
466        );
467        // Action points to step_index=5 but plan has only 1 step
468        let action = make_action(&store, "edit_file", plan.id.clone(), 5);
469        make_attr(&store, action.id.clone(), "src/main.rs", 1, 10, 1.0);
470
471        let result = why_all(&store, "src/main.rs", 5);
472        assert!(
473            matches!(
474                result,
475                Err(VasariError::PlanStepOutOfBounds {
476                    step_index: 5,
477                    step_count: 1,
478                    ..
479                })
480            ),
481            "expected PlanStepOutOfBounds, got: {result:?}"
482        );
483    }
484
485    #[test]
486    fn plan_with_multiple_intents_resolves_all() {
487        let (store, _dir) = make_store();
488
489        let i1 = make_intent(&store, "ACME-411", "First");
490        let i2 = make_intent(&store, "ACME-412", "Second");
491        let plan = make_plan(
492            &store,
493            vec![i1.id.clone(), i2.id.clone()],
494            vec![PlanStep {
495                goal: "do thing".into(),
496                constraints: vec![],
497            }],
498        );
499        let action = make_action(&store, "edit_file", plan.id.clone(), 0);
500        make_attr(&store, action.id.clone(), "src/lib.rs", 1, 5, 1.0);
501
502        let chain = why(&store, "src/lib.rs", 3).unwrap().unwrap();
503        assert_eq!(chain.intents.len(), 2);
504    }
505
506    #[test]
507    fn plan_with_zero_intents_succeeds() {
508        let (store, _dir) = make_store();
509
510        let plan = make_plan(
511            &store,
512            vec![],
513            vec![PlanStep {
514                goal: "orphan step".into(),
515                constraints: vec![],
516            }],
517        );
518        let action = make_action(&store, "edit_file", plan.id.clone(), 0);
519        make_attr(&store, action.id.clone(), "src/empty.rs", 1, 1, 1.0);
520
521        let chain = why(&store, "src/empty.rs", 1).unwrap().unwrap();
522        assert!(chain.intents.is_empty());
523    }
524
525    #[test]
526    fn missing_action_node_is_soft_logged_not_error() {
527        let (store, _dir) = make_store();
528        // Attribution references an action_id that was never stored
529        let ghost_action_id = NodeId("deadbeef".repeat(8));
530        let attr = Attribution::new(
531            ghost_action_id,
532            AttributionTarget::LineRange {
533                path: "src/ghost.rs".into(),
534                start: 1,
535                end: 5,
536            },
537            1.0,
538            vec![],
539            vec![],
540        );
541        store.put(&Node::Attribution(attr)).unwrap();
542
543        // why_all soft-logs NodeNotFound for missing action, returns empty
544        let chains = why_all(&store, "src/ghost.rs", 3).unwrap();
545        assert!(
546            chains.is_empty(),
547            "missing action should be soft-logged, not crash"
548        );
549    }
550}