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#[derive(Debug, Clone)]
18pub struct ResolveChain {
19 pub intents: Vec<Intent>,
23 pub plan: Plan,
25 pub plan_step_index: usize,
27 pub action: Action,
29 pub attribution: Attribution,
31 pub constraints: Vec<Constraint>,
36}
37
38impl ResolveChain {
39 pub fn plan_step(&self) -> Option<&PlanStep> {
41 self.plan.steps.get(self.plan_step_index)
42 }
43
44 pub fn confidence(&self) -> f32 {
47 self.attribution.confidence
48 }
49
50 pub fn primary_intent(&self) -> Option<&Intent> {
53 self.intents.first()
54 }
55}
56
57pub 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 chains.sort_by(|a, b| {
75 b.attribution
76 .confidence
77 .partial_cmp(&a.attribution.confidence)
78 .unwrap_or(std::cmp::Ordering::Less) });
80 Ok(chains.into_iter().next())
81}
82
83pub 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 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 eprintln!("vasari: warn: skipping attribution {attr_id}: {msg}");
115 }
116 Err(e) => return Err(e),
117 }
118 }
119
120 Ok(chains)
121}
122
123fn 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 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 intents.sort_by_key(|i| Reverse(i.created_at));
194
195 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 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 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 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 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 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}