use std::cmp::Reverse;
use std::collections::HashSet;
use crate::error::VasariError;
use crate::schema::{Action, Attribution, Constraint, Intent, Node, NodeId, Plan, PlanStep};
use crate::store::ObjectStore;
#[derive(Debug, Clone)]
pub struct ResolveChain {
pub intents: Vec<Intent>,
pub plan: Plan,
pub plan_step_index: usize,
pub action: Action,
pub attribution: Attribution,
pub constraints: Vec<Constraint>,
}
impl ResolveChain {
pub fn plan_step(&self) -> Option<&PlanStep> {
self.plan.steps.get(self.plan_step_index)
}
pub fn confidence(&self) -> f32 {
self.attribution.confidence
}
pub fn primary_intent(&self) -> Option<&Intent> {
self.intents.first()
}
}
pub fn why(
store: &ObjectStore,
path: &str,
line: u32,
) -> Result<Option<ResolveChain>, VasariError> {
let mut chains = why_all(store, path, line)?;
chains.sort_by(|a, b| {
b.attribution
.confidence
.partial_cmp(&a.attribution.confidence)
.unwrap_or(std::cmp::Ordering::Less) });
Ok(chains.into_iter().next())
}
pub fn why_all(
store: &ObjectStore,
path: &str,
line: u32,
) -> Result<Vec<ResolveChain>, VasariError> {
let attr_ids = store.lookup_attributions(path, line)?;
let mut seen = HashSet::new();
let attr_ids: Vec<NodeId> = attr_ids
.into_iter()
.filter(|id| seen.insert(id.clone()))
.collect();
let mut chains = Vec::new();
for attr_id in &attr_ids {
match resolve_one(store, attr_id) {
Ok(chain) => chains.push(chain),
Err(VasariError::NodeNotFound(msg)) => {
eprintln!("vasari: warn: skipping attribution {attr_id}: {msg}");
}
Err(e) => return Err(e),
}
}
Ok(chains)
}
fn resolve_one(store: &ObjectStore, attr_id: &NodeId) -> Result<ResolveChain, VasariError> {
let attr = match store.get(attr_id)? {
Some(Node::Attribution(a)) => a,
Some(_) => {
return Err(VasariError::NodeNotFound(format!(
"id {attr_id} is not an Attribution node"
)))
}
None => {
return Err(VasariError::NodeNotFound(format!(
"attribution node {attr_id} not found — run `vasari fsck` to rebuild the index"
)))
}
};
let action = match store.get(&attr.action_id)? {
Some(Node::Action(a)) => a,
Some(_) => {
return Err(VasariError::NodeNotFound(format!(
"action id {} is not an Action node",
attr.action_id
)))
}
None => {
return Err(VasariError::NodeNotFound(format!(
"action node {} not found — graph may be incomplete; run `vasari ingest` again",
attr.action_id
)))
}
};
let plan = match store.get(&action.plan_ref.plan_id)? {
Some(Node::Plan(p)) => p,
Some(_) => {
return Err(VasariError::NodeNotFound(format!(
"plan id {} is not a Plan node",
action.plan_ref.plan_id
)))
}
None => {
return Err(VasariError::NodeNotFound(format!(
"plan node {} not found — graph may be incomplete; run `vasari ingest` again",
action.plan_ref.plan_id
)))
}
};
let step_index = action.plan_ref.step_index;
if step_index >= plan.steps.len() {
return Err(VasariError::PlanStepOutOfBounds {
plan_id: plan.id.to_string(),
step_index,
step_count: plan.steps.len(),
});
}
let mut intents: Vec<Intent> = Vec::new();
for intent_id in &plan.intent_ids {
match store.get(intent_id)? {
Some(Node::Intent(i)) => intents.push(i),
Some(_) => {
eprintln!("vasari: warn: intent id {intent_id} is not an Intent node — skipping")
}
None => eprintln!("vasari: warn: intent node {intent_id} not found — skipping"),
}
}
intents.sort_by_key(|i| Reverse(i.created_at));
let mut relevant: HashSet<NodeId> = plan.intent_ids.iter().cloned().collect();
relevant.insert(plan.id.clone());
let mut constraints: Vec<Constraint> = store
.iter_all()?
.into_iter()
.filter_map(|n| match n {
Node::Constraint(c) if relevant.contains(&c.derived_from) => Some(c),
_ => None,
})
.collect();
constraints.sort_by(|a, b| a.text.cmp(&b.text));
Ok(ResolveChain {
intents,
plan,
plan_step_index: step_index,
action,
attribution: attr,
constraints,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{Attribution, AttributionTarget, Node, Plan, PlanRef, PlanStep};
use chrono::Utc;
fn make_store() -> (ObjectStore, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = ObjectStore::open(dir.path()).unwrap();
(store, dir)
}
fn make_intent(store: &ObjectStore, source: &str, text: &str) -> Intent {
let intent = Intent::new(source.into(), text.into(), vec![]);
store.put(&Node::Intent(intent.clone())).unwrap();
intent
}
fn make_plan(store: &ObjectStore, intent_ids: Vec<NodeId>, steps: Vec<PlanStep>) -> Plan {
let plan = Plan::new(intent_ids, steps, vec![]);
store.put(&Node::Plan(plan.clone())).unwrap();
plan
}
fn make_action(store: &ObjectStore, tool: &str, plan_id: NodeId, step_index: usize) -> Action {
let action = Action::new(
tool.into(),
serde_json::json!({ "path": "src/auth.ts" }),
String::new(),
Utc::now(),
PlanRef {
plan_id,
step_index,
},
vec![],
);
store.put(&Node::Action(action.clone())).unwrap();
action
}
fn make_attr(
store: &ObjectStore,
action_id: NodeId,
path: &str,
start: u32,
end: u32,
confidence: f32,
) -> Attribution {
let attr = Attribution::new(
action_id,
AttributionTarget::LineRange {
path: path.into(),
start,
end,
},
confidence,
vec![],
vec![],
);
store.put(&Node::Attribution(attr.clone())).unwrap();
attr
}
fn make_constraint(store: &ObjectStore, text: &str, derived_from: NodeId) -> Constraint {
use crate::schema::ConstraintPolarity;
let c = Constraint::new(
text.into(),
derived_from,
ConstraintPolarity::Mandatory,
vec![],
);
store.put(&Node::Constraint(c.clone())).unwrap();
c
}
#[test]
fn chain_includes_constraints_derived_from_intent_and_excludes_others() {
let (store, _dir) = make_store();
let intent = make_intent(&store, "ACME-1", "Add JWT verification");
let plan = make_plan(
&store,
vec![intent.id.clone()],
vec![PlanStep {
goal: "invoke Edit".into(),
constraints: vec![],
}],
);
let action = make_action(&store, "Edit", plan.id.clone(), 0);
make_attr(&store, action.id.clone(), "src/auth.ts", 1, u32::MAX, 0.7);
make_constraint(
&store,
"must validate the token signature",
intent.id.clone(),
);
make_constraint(
&store,
"never store the secret in source",
intent.id.clone(),
);
make_constraint(&store, "unrelated rule", NodeId("deadbeef".repeat(8)));
let chain = why(&store, "src/auth.ts", 47).unwrap().unwrap();
let texts: Vec<&str> = chain.constraints.iter().map(|c| c.text.as_str()).collect();
assert_eq!(
texts,
vec![
"must validate the token signature",
"never store the secret in source"
],
"only intent-derived constraints, sorted by text"
);
}
#[test]
fn chain_constraints_empty_when_none_attached() {
let (store, _dir) = make_store();
let intent = make_intent(&store, "s", "do a thing");
let plan = make_plan(
&store,
vec![intent.id.clone()],
vec![PlanStep {
goal: "invoke Write".into(),
constraints: vec![],
}],
);
let action = make_action(&store, "Write", plan.id.clone(), 0);
make_attr(&store, action.id.clone(), "src/x.rs", 1, u32::MAX, 0.7);
let chain = why(&store, "src/x.rs", 1).unwrap().unwrap();
assert!(chain.constraints.is_empty());
}
#[test]
fn why_returns_none_for_empty_store() {
let (store, _dir) = make_store();
assert!(why(&store, "src/main.rs", 1).unwrap().is_none());
}
#[test]
fn why_all_returns_empty_for_empty_store() {
let (store, _dir) = make_store();
assert!(why_all(&store, "src/main.rs", 1).unwrap().is_empty());
}
#[test]
fn why_full_happy_path() {
let (store, _dir) = make_store();
let intent = make_intent(
&store,
"ACME-411",
"Add JWT verification before the user-id lookup",
);
let plan = make_plan(
&store,
vec![intent.id.clone()],
vec![
PlanStep {
goal: "Read existing auth code".into(),
constraints: vec![],
},
PlanStep {
goal: "Add JWT verification middleware".into(),
constraints: vec!["no async/await".into()],
},
],
);
let action = make_action(&store, "edit_file", plan.id.clone(), 1);
make_attr(&store, action.id.clone(), "src/auth.ts", 40, 55, 1.0);
let chain = why(&store, "src/auth.ts", 47)
.unwrap()
.expect("chain must be found");
assert_eq!(chain.intents.len(), 1);
assert_eq!(
chain.intents[0].text,
"Add JWT verification before the user-id lookup"
);
assert_eq!(chain.intents[0].id, intent.id);
assert_eq!(chain.plan_step_index, 1);
let step = chain.plan_step().expect("plan step must be present");
assert_eq!(step.goal, "Add JWT verification middleware");
assert!((chain.confidence() - 1.0).abs() < f32::EPSILON);
assert!(chain.constraints.is_empty());
let primary = chain
.primary_intent()
.expect("primary intent must be present");
assert_eq!(primary.id, intent.id);
}
#[test]
fn why_returns_highest_confidence_when_multiple() {
let (store, _dir) = make_store();
let i1 = make_intent(&store, "ACME-411", "First intent");
let p1 = make_plan(
&store,
vec![i1.id.clone()],
vec![PlanStep {
goal: "step A".into(),
constraints: vec![],
}],
);
let a1 = make_action(&store, "edit_file", p1.id.clone(), 0);
make_attr(&store, a1.id.clone(), "src/auth.ts", 40, 55, 0.9);
let i2 = make_intent(&store, "ACME-419", "Second intent");
let p2 = make_plan(
&store,
vec![i2.id.clone()],
vec![PlanStep {
goal: "step B".into(),
constraints: vec![],
}],
);
let a2 = make_action(&store, "str_replace", p2.id.clone(), 0);
make_attr(&store, a2.id.clone(), "src/auth.ts", 44, 50, 0.7);
let chains = why_all(&store, "src/auth.ts", 47).unwrap();
assert_eq!(chains.len(), 2);
let best = why(&store, "src/auth.ts", 47).unwrap().unwrap();
assert!((best.confidence() - 0.9).abs() < f32::EPSILON);
assert_eq!(best.intents[0].source, "ACME-411");
}
#[test]
fn step_index_out_of_bounds_returns_error() {
let (store, _dir) = make_store();
let intent = make_intent(&store, "src", "test");
let plan = make_plan(
&store,
vec![intent.id.clone()],
vec![PlanStep {
goal: "only step".into(),
constraints: vec![],
}],
);
let action = make_action(&store, "edit_file", plan.id.clone(), 5);
make_attr(&store, action.id.clone(), "src/main.rs", 1, 10, 1.0);
let result = why_all(&store, "src/main.rs", 5);
assert!(
matches!(
result,
Err(VasariError::PlanStepOutOfBounds {
step_index: 5,
step_count: 1,
..
})
),
"expected PlanStepOutOfBounds, got: {result:?}"
);
}
#[test]
fn plan_with_multiple_intents_resolves_all() {
let (store, _dir) = make_store();
let i1 = make_intent(&store, "ACME-411", "First");
let i2 = make_intent(&store, "ACME-412", "Second");
let plan = make_plan(
&store,
vec![i1.id.clone(), i2.id.clone()],
vec![PlanStep {
goal: "do thing".into(),
constraints: vec![],
}],
);
let action = make_action(&store, "edit_file", plan.id.clone(), 0);
make_attr(&store, action.id.clone(), "src/lib.rs", 1, 5, 1.0);
let chain = why(&store, "src/lib.rs", 3).unwrap().unwrap();
assert_eq!(chain.intents.len(), 2);
}
#[test]
fn plan_with_zero_intents_succeeds() {
let (store, _dir) = make_store();
let plan = make_plan(
&store,
vec![],
vec![PlanStep {
goal: "orphan step".into(),
constraints: vec![],
}],
);
let action = make_action(&store, "edit_file", plan.id.clone(), 0);
make_attr(&store, action.id.clone(), "src/empty.rs", 1, 1, 1.0);
let chain = why(&store, "src/empty.rs", 1).unwrap().unwrap();
assert!(chain.intents.is_empty());
}
#[test]
fn missing_action_node_is_soft_logged_not_error() {
let (store, _dir) = make_store();
let ghost_action_id = NodeId("deadbeef".repeat(8));
let attr = Attribution::new(
ghost_action_id,
AttributionTarget::LineRange {
path: "src/ghost.rs".into(),
start: 1,
end: 5,
},
1.0,
vec![],
vec![],
);
store.put(&Node::Attribution(attr)).unwrap();
let chains = why_all(&store, "src/ghost.rs", 3).unwrap();
assert!(
chains.is_empty(),
"missing action should be soft-logged, not crash"
);
}
}