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
56pub fn peek_version(json: &str) -> Option<String> {
58 serde_json::from_str::<SchemaVersion>(json)
59 .ok()
60 .map(|sv| sv.schema)
61}
62
63#[derive(Debug)]
65pub enum ParseAnnotationError {
66 InvalidJson { source: serde_json::Error },
67 UnknownVersion { version: String },
68}
69
70impl std::fmt::Display for ParseAnnotationError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 ParseAnnotationError::InvalidJson { source } => {
74 write!(f, "invalid annotation JSON: {source}")
75 }
76 ParseAnnotationError::UnknownVersion { version } => {
77 write!(f, "unknown annotation schema version: {version}")
78 }
79 }
80 }
81}
82
83impl std::error::Error for ParseAnnotationError {
84 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85 match self {
86 ParseAnnotationError::InvalidJson { source } => Some(source),
87 ParseAnnotationError::UnknownVersion { .. } => None,
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn test_parse_v1_annotation() {
98 let json = r#"{
99 "schema": "chronicle/v1",
100 "commit": "abc123",
101 "timestamp": "2025-01-01T00:00:00Z",
102 "summary": "Test commit",
103 "context_level": "enhanced",
104 "regions": [],
105 "provenance": {
106 "operation": "initial",
107 "derived_from": [],
108 "original_annotations_preserved": false
109 }
110 }"#;
111
112 let ann = parse_annotation(json).unwrap();
113 assert_eq!(ann.schema, "chronicle/v3");
115 assert_eq!(ann.commit, "abc123");
116 assert_eq!(ann.summary, "Test commit");
117 assert_eq!(ann.provenance.source, ProvenanceSource::MigratedV1);
118 }
119
120 #[test]
121 fn test_parse_v2_annotation() {
122 let json = r#"{
123 "schema": "chronicle/v2",
124 "commit": "def456",
125 "timestamp": "2025-01-02T00:00:00Z",
126 "narrative": {
127 "summary": "Direct v2 annotation"
128 },
129 "provenance": {
130 "source": "live"
131 }
132 }"#;
133
134 let ann = parse_annotation(json).unwrap();
135 assert_eq!(ann.schema, "chronicle/v3");
137 assert_eq!(ann.commit, "def456");
138 assert_eq!(ann.summary, "Direct v2 annotation");
139 assert_eq!(ann.provenance.source, ProvenanceSource::Live);
140 }
141
142 #[test]
143 fn test_parse_v3_annotation() {
144 let json = r#"{
145 "schema": "chronicle/v3",
146 "commit": "ghi789",
147 "timestamp": "2025-06-01T00:00:00Z",
148 "summary": "Native v3 annotation",
149 "provenance": {
150 "source": "live"
151 }
152 }"#;
153
154 let ann = parse_annotation(json).unwrap();
155 assert_eq!(ann.schema, "chronicle/v3");
156 assert_eq!(ann.commit, "ghi789");
157 assert_eq!(ann.summary, "Native v3 annotation");
158 assert_eq!(ann.provenance.source, ProvenanceSource::Live);
159 }
160
161 #[test]
162 fn test_parse_unknown_version() {
163 let json = r#"{"schema": "chronicle/v99", "commit": "abc"}"#;
164 let result = parse_annotation(json);
165 assert!(matches!(
166 result,
167 Err(ParseAnnotationError::UnknownVersion { .. })
168 ));
169 }
170
171 #[test]
172 fn test_parse_invalid_json() {
173 let result = parse_annotation("not json");
174 assert!(matches!(
175 result,
176 Err(ParseAnnotationError::InvalidJson { .. })
177 ));
178 }
179
180 #[test]
181 fn test_v1_roundtrip_preserves_data() {
182 let json = r#"{
183 "schema": "chronicle/v1",
184 "commit": "abc123",
185 "timestamp": "2025-01-01T00:00:00Z",
186 "summary": "Test commit",
187 "context_level": "enhanced",
188 "regions": [{
189 "file": "src/foo.rs",
190 "ast_anchor": {"unit_type": "function", "name": "foo"},
191 "lines": {"start": 1, "end": 10},
192 "intent": "Do something",
193 "constraints": [{"text": "Must not allocate", "source": "author"}],
194 "risk_notes": "Could panic on empty input",
195 "semantic_dependencies": [
196 {"file": "src/bar.rs", "anchor": "bar", "nature": "calls bar"}
197 ]
198 }],
199 "cross_cutting": [{
200 "description": "All paths validate input",
201 "regions": [{"file": "src/foo.rs", "anchor": "foo"}]
202 }],
203 "provenance": {
204 "operation": "initial",
205 "derived_from": [],
206 "original_annotations_preserved": false
207 }
208 }"#;
209
210 let ann = parse_annotation(json).unwrap();
211 assert_eq!(ann.schema, "chronicle/v3");
212 assert_eq!(ann.summary, "Test commit");
213
214 assert!(ann
216 .wisdom
217 .iter()
218 .any(|w| w.category == WisdomCategory::Gotcha && w.content == "Must not allocate"));
219
220 assert!(ann
222 .wisdom
223 .iter()
224 .any(|w| w.category == WisdomCategory::Gotcha && w.content.contains("panic")));
225
226 assert!(ann
228 .wisdom
229 .iter()
230 .any(|w| w.category == WisdomCategory::Insight && w.content.contains("src/bar.rs")));
231
232 assert!(ann
234 .wisdom
235 .iter()
236 .any(|w| w.category == WisdomCategory::Insight
237 && w.content.contains("All paths validate input")));
238 }
239}