1pub mod common;
2pub mod correction;
3pub mod knowledge;
4pub mod migrate;
5pub mod v1;
6pub mod v2;
7pub mod v3;
8
9pub use common::{AstAnchor, LineRange};
11
12pub use correction::*;
14
15pub use v3::Annotation;
17pub use v3::*;
18
19pub fn parse_annotation(json: &str) -> Result<v3::Annotation, ParseAnnotationError> {
26 let peek: SchemaVersion =
28 serde_json::from_str(json).map_err(|e| ParseAnnotationError::InvalidJson { source: e })?;
29
30 match peek.schema.as_str() {
31 "chronicle/v3" => serde_json::from_str::<v3::Annotation>(json)
32 .map_err(|e| ParseAnnotationError::InvalidJson { source: e }),
33 "chronicle/v2" => {
34 let v2_ann: v2::Annotation = serde_json::from_str(json)
35 .map_err(|e| ParseAnnotationError::InvalidJson { source: e })?;
36 Ok(migrate::v2_to_v3(v2_ann))
37 }
38 "chronicle/v1" => {
39 let v1_ann: v1::Annotation = serde_json::from_str(json)
40 .map_err(|e| ParseAnnotationError::InvalidJson { source: e })?;
41 let v2_ann = migrate::v1_to_v2(v1_ann);
42 Ok(migrate::v2_to_v3(v2_ann))
43 }
44 other => Err(ParseAnnotationError::UnknownVersion {
45 version: other.to_string(),
46 }),
47 }
48}
49
50#[derive(serde::Deserialize)]
52struct SchemaVersion {
53 schema: String,
54}
55
56#[derive(Debug)]
58pub enum ParseAnnotationError {
59 InvalidJson { source: serde_json::Error },
60 UnknownVersion { version: String },
61}
62
63impl std::fmt::Display for ParseAnnotationError {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 ParseAnnotationError::InvalidJson { source } => {
67 write!(f, "invalid annotation JSON: {source}")
68 }
69 ParseAnnotationError::UnknownVersion { version } => {
70 write!(f, "unknown annotation schema version: {version}")
71 }
72 }
73 }
74}
75
76impl std::error::Error for ParseAnnotationError {
77 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
78 match self {
79 ParseAnnotationError::InvalidJson { source } => Some(source),
80 ParseAnnotationError::UnknownVersion { .. } => None,
81 }
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn test_parse_v1_annotation() {
91 let json = r#"{
92 "schema": "chronicle/v1",
93 "commit": "abc123",
94 "timestamp": "2025-01-01T00:00:00Z",
95 "summary": "Test commit",
96 "context_level": "enhanced",
97 "regions": [],
98 "provenance": {
99 "operation": "initial",
100 "derived_from": [],
101 "original_annotations_preserved": false
102 }
103 }"#;
104
105 let ann = parse_annotation(json).unwrap();
106 assert_eq!(ann.schema, "chronicle/v3");
108 assert_eq!(ann.commit, "abc123");
109 assert_eq!(ann.summary, "Test commit");
110 assert_eq!(ann.provenance.source, ProvenanceSource::MigratedV1);
111 }
112
113 #[test]
114 fn test_parse_v2_annotation() {
115 let json = r#"{
116 "schema": "chronicle/v2",
117 "commit": "def456",
118 "timestamp": "2025-01-02T00:00:00Z",
119 "narrative": {
120 "summary": "Direct v2 annotation"
121 },
122 "provenance": {
123 "source": "live"
124 }
125 }"#;
126
127 let ann = parse_annotation(json).unwrap();
128 assert_eq!(ann.schema, "chronicle/v3");
130 assert_eq!(ann.commit, "def456");
131 assert_eq!(ann.summary, "Direct v2 annotation");
132 assert_eq!(ann.provenance.source, ProvenanceSource::Live);
133 }
134
135 #[test]
136 fn test_parse_v3_annotation() {
137 let json = r#"{
138 "schema": "chronicle/v3",
139 "commit": "ghi789",
140 "timestamp": "2025-06-01T00:00:00Z",
141 "summary": "Native v3 annotation",
142 "provenance": {
143 "source": "live"
144 }
145 }"#;
146
147 let ann = parse_annotation(json).unwrap();
148 assert_eq!(ann.schema, "chronicle/v3");
149 assert_eq!(ann.commit, "ghi789");
150 assert_eq!(ann.summary, "Native v3 annotation");
151 assert_eq!(ann.provenance.source, ProvenanceSource::Live);
152 }
153
154 #[test]
155 fn test_parse_unknown_version() {
156 let json = r#"{"schema": "chronicle/v99", "commit": "abc"}"#;
157 let result = parse_annotation(json);
158 assert!(matches!(
159 result,
160 Err(ParseAnnotationError::UnknownVersion { .. })
161 ));
162 }
163
164 #[test]
165 fn test_parse_invalid_json() {
166 let result = parse_annotation("not json");
167 assert!(matches!(
168 result,
169 Err(ParseAnnotationError::InvalidJson { .. })
170 ));
171 }
172
173 #[test]
174 fn test_v1_roundtrip_preserves_data() {
175 let json = r#"{
176 "schema": "chronicle/v1",
177 "commit": "abc123",
178 "timestamp": "2025-01-01T00:00:00Z",
179 "summary": "Test commit",
180 "context_level": "enhanced",
181 "regions": [{
182 "file": "src/foo.rs",
183 "ast_anchor": {"unit_type": "function", "name": "foo"},
184 "lines": {"start": 1, "end": 10},
185 "intent": "Do something",
186 "constraints": [{"text": "Must not allocate", "source": "author"}],
187 "risk_notes": "Could panic on empty input",
188 "semantic_dependencies": [
189 {"file": "src/bar.rs", "anchor": "bar", "nature": "calls bar"}
190 ]
191 }],
192 "cross_cutting": [{
193 "description": "All paths validate input",
194 "regions": [{"file": "src/foo.rs", "anchor": "foo"}]
195 }],
196 "provenance": {
197 "operation": "initial",
198 "derived_from": [],
199 "original_annotations_preserved": false
200 }
201 }"#;
202
203 let ann = parse_annotation(json).unwrap();
204 assert_eq!(ann.schema, "chronicle/v3");
205 assert_eq!(ann.summary, "Test commit");
206
207 assert!(ann
209 .wisdom
210 .iter()
211 .any(|w| w.category == WisdomCategory::Gotcha && w.content == "Must not allocate"));
212
213 assert!(ann
215 .wisdom
216 .iter()
217 .any(|w| w.category == WisdomCategory::Gotcha && w.content.contains("panic")));
218
219 assert!(ann
221 .wisdom
222 .iter()
223 .any(|w| w.category == WisdomCategory::Insight && w.content.contains("src/bar.rs")));
224
225 assert!(ann
227 .wisdom
228 .iter()
229 .any(|w| w.category == WisdomCategory::Insight
230 && w.content.contains("All paths validate input")));
231 }
232}