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