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;
7
8// Re-export shared types used across all versions.
9pub use common::{AstAnchor, LineRange};
10
11// Re-export correction types (version-independent).
12pub use correction::*;
13
14// The canonical annotation type is always the latest version.
15pub use v2::Annotation;
16pub use v2::*;
17
18/// Parse an annotation from JSON, detecting the schema version and migrating
19/// to the canonical (latest) type.
20///
21/// This is the single deserialization chokepoint. All code that reads
22/// annotations from git notes should call this instead of using
23/// `serde_json::from_str` directly.
24pub fn parse_annotation(json: &str) -> Result<v2::Annotation, ParseAnnotationError> {
25    // Peek at the schema field to determine version.
26    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/// Minimal struct to peek at the schema version without full deserialization.
44#[derive(serde::Deserialize)]
45struct SchemaVersion {
46    schema: String,
47}
48
49/// Errors from `parse_annotation`.
50#[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        // Constraint -> Contract marker
181        assert!(ann.markers.iter().any(|m| matches!(
182            &m.kind,
183            MarkerKind::Contract { description, .. } if description == "Must not allocate"
184        )));
185
186        // risk_notes -> Hazard marker
187        assert!(ann.markers.iter().any(|m| matches!(
188            &m.kind,
189            MarkerKind::Hazard { description } if description.contains("panic")
190        )));
191
192        // semantic_dependencies -> Dependency marker
193        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        // Cross-cutting -> Decision
200        assert_eq!(ann.decisions.len(), 1);
201        assert_eq!(ann.decisions[0].what, "All paths validate input");
202    }
203}