Skip to main content

chronicle/schema/
mod.rs

1pub mod common;
2pub mod correction;
3pub mod knowledge;
4pub mod migrate;
5pub mod v1;
6pub mod v2;
7pub mod v3;
8
9// Re-export shared types used across all versions.
10pub use common::{AstAnchor, LineRange};
11
12// Re-export correction types (version-independent).
13pub use correction::*;
14
15// The canonical annotation type is always the latest version.
16pub use v3::Annotation;
17pub use v3::*;
18
19/// Parse an annotation from JSON, detecting the schema version and migrating
20/// to the canonical (latest) type.
21///
22/// This is the single deserialization chokepoint. All code that reads
23/// annotations from git notes should call this instead of using
24/// `serde_json::from_str` directly.
25pub fn parse_annotation(json: &str) -> Result<v3::Annotation, ParseAnnotationError> {
26    // Peek at the schema field to determine version.
27    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/// Minimal struct to peek at the schema version without full deserialization.
51#[derive(serde::Deserialize)]
52struct SchemaVersion {
53    schema: String,
54}
55
56/// Errors from `parse_annotation`.
57#[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        // v1 -> v2 -> v3
107        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        // v2 -> v3
129        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        // v1 constraint -> v2 Contract marker -> v3 gotcha wisdom
208        assert!(ann
209            .wisdom
210            .iter()
211            .any(|w| w.category == WisdomCategory::Gotcha && w.content == "Must not allocate"));
212
213        // v1 risk_notes -> v2 Hazard -> v3 gotcha wisdom
214        assert!(ann
215            .wisdom
216            .iter()
217            .any(|w| w.category == WisdomCategory::Gotcha && w.content.contains("panic")));
218
219        // v1 semantic_dependencies -> v2 Dependency -> v3 insight wisdom
220        assert!(ann
221            .wisdom
222            .iter()
223            .any(|w| w.category == WisdomCategory::Insight && w.content.contains("src/bar.rs")));
224
225        // v1 cross-cutting -> v2 Decision -> v3 insight wisdom
226        assert!(ann
227            .wisdom
228            .iter()
229            .any(|w| w.category == WisdomCategory::Insight
230                && w.content.contains("All paths validate input")));
231    }
232}