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/// Peek at the schema version string from raw annotation JSON without full parsing.
57pub fn peek_version(json: &str) -> Option<String> {
58    serde_json::from_str::<SchemaVersion>(json)
59        .ok()
60        .map(|sv| sv.schema)
61}
62
63/// Errors from `parse_annotation`.
64#[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        // v1 -> v2 -> v3
114        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        // v2 -> v3
136        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        // v1 constraint -> v2 Contract marker -> v3 gotcha wisdom
215        assert!(ann
216            .wisdom
217            .iter()
218            .any(|w| w.category == WisdomCategory::Gotcha && w.content == "Must not allocate"));
219
220        // v1 risk_notes -> v2 Hazard -> v3 gotcha wisdom
221        assert!(ann
222            .wisdom
223            .iter()
224            .any(|w| w.category == WisdomCategory::Gotcha && w.content.contains("panic")));
225
226        // v1 semantic_dependencies -> v2 Dependency -> v3 insight wisdom
227        assert!(ann
228            .wisdom
229            .iter()
230            .any(|w| w.category == WisdomCategory::Insight && w.content.contains("src/bar.rs")));
231
232        // v1 cross-cutting -> v2 Decision -> v3 insight wisdom
233        assert!(ann
234            .wisdom
235            .iter()
236            .any(|w| w.category == WisdomCategory::Insight
237                && w.content.contains("All paths validate input")));
238    }
239}