Skip to main content

vasari_core/schema/
attribution.rs

1use serde::{Deserialize, Serialize};
2use serde_json::json;
3
4use super::NodeId;
5use crate::hash::node_id;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Attribution {
9    pub id: NodeId,
10    pub action_id: NodeId,
11    pub target: AttributionTarget,
12    /// Confidence that this action produced this target. 0.0–1.0.
13    /// EXCLUDED from content hash — computed annotation, not identity.
14    pub confidence: f32,
15    pub evidence: Vec<Evidence>,
16    pub parent_ids: Vec<NodeId>,
17    #[serde(default = "default_schema_version")]
18    pub schema_version: String,
19}
20
21fn default_schema_version() -> String {
22    "1".to_string()
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum AttributionTarget {
28    /// A specific line range within a file.
29    LineRange {
30        path: String,
31        start: u32,
32        end: u32,
33    },
34    /// The whole file — used when a precise range could not be determined
35    /// (e.g. an Edit whose location couldn't be located in the file content).
36    /// Replaces the former `LineRange { start: 1, end: u32::MAX }` sentinel.
37    WholeFile {
38        path: String,
39    },
40    CommitSha {
41        sha: String,
42    },
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Evidence {
47    pub kind: EvidenceKind,
48    pub details: serde_json::Value,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum EvidenceKind {
54    /// The action touched this exact range.
55    ExactRange,
56    /// Heuristic match (e.g., diff fuzzed across whitespace).
57    Fuzzed,
58    /// A later edit may have overwritten this range.
59    Overwritten,
60    /// Synthesized from span/session data; not directly observed.
61    Inferred,
62}
63
64impl Attribution {
65    pub fn new(
66        action_id: NodeId,
67        target: AttributionTarget,
68        confidence: f32,
69        evidence: Vec<Evidence>,
70        parent_ids: Vec<NodeId>,
71    ) -> Self {
72        let id = NodeId(node_id(&Self::hash_input(&action_id, &target, &parent_ids)));
73        Self {
74            id,
75            action_id,
76            target,
77            confidence,
78            evidence,
79            parent_ids,
80            schema_version: default_schema_version(),
81        }
82    }
83
84    /// confidence and evidence are intentionally excluded — confidence is a computed
85    /// annotation and evidence may be refined without changing what was attributed.
86    fn hash_input(
87        action_id: &NodeId,
88        target: &AttributionTarget,
89        parent_ids: &[NodeId],
90    ) -> serde_json::Value {
91        let target_json = match target {
92            AttributionTarget::LineRange { path, start, end } => {
93                json!({ "kind": "line_range", "path": path, "start": start, "end": end })
94            }
95            AttributionTarget::WholeFile { path } => {
96                json!({ "kind": "whole_file", "path": path })
97            }
98            AttributionTarget::CommitSha { sha } => {
99                json!({ "kind": "commit_sha", "sha": sha })
100            }
101        };
102        json!({
103            "type": "attribution",
104            "action_id": action_id.as_str(),
105            "target": target_json,
106            "parent_ids": parent_ids.iter().map(|id| id.as_str()).collect::<Vec<_>>(),
107        })
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn whole_file_and_line_range_hash_differently() {
117        let action_id = NodeId("abc123".into());
118        let whole = Attribution::new(
119            action_id.clone(),
120            AttributionTarget::WholeFile {
121                path: "src/auth.rs".into(),
122            },
123            0.7,
124            vec![],
125            vec![],
126        );
127        let range = Attribution::new(
128            action_id,
129            AttributionTarget::LineRange {
130                path: "src/auth.rs".into(),
131                start: 1,
132                end: 10,
133            },
134            0.7,
135            vec![],
136            vec![],
137        );
138        assert_ne!(
139            whole.id, range.id,
140            "whole-file and line-range targets are distinct identities"
141        );
142    }
143
144    #[test]
145    fn whole_file_round_trips_through_serde() {
146        let attr = Attribution::new(
147            NodeId("abc123".into()),
148            AttributionTarget::WholeFile {
149                path: "src/x.rs".into(),
150            },
151            0.7,
152            vec![],
153            vec![],
154        );
155        let json = serde_json::to_string(&attr).unwrap();
156        assert!(json.contains("\"kind\":\"whole_file\""));
157        let decoded: Attribution = serde_json::from_str(&json).unwrap();
158        assert_eq!(decoded.id, attr.id);
159    }
160
161    #[test]
162    fn confidence_change_does_not_change_id() {
163        let action_id = NodeId("abc123".into());
164        let target = AttributionTarget::LineRange {
165            path: "src/auth.ts".into(),
166            start: 47,
167            end: 47,
168        };
169        let a = Attribution::new(action_id.clone(), target.clone(), 1.0, vec![], vec![]);
170        let b = Attribution::new(action_id, target, 0.7, vec![], vec![]);
171        assert_eq!(a.id, b.id);
172    }
173}