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 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 LineRange {
30 path: String,
31 start: u32,
32 end: u32,
33 },
34 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 ExactRange,
56 Fuzzed,
58 Overwritten,
60 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 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}