Skip to main content

chronicle/schema/
v2.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::common::{AstAnchor, LineRange};
5
6// ---------------------------------------------------------------------------
7// Top-level Annotation
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11pub struct Annotation {
12    pub schema: String,
13    pub commit: String,
14    pub timestamp: String,
15
16    /// The narrative (commit-level, always present).
17    pub narrative: Narrative,
18
19    /// Design decisions (zero or more).
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub decisions: Vec<Decision>,
22
23    /// Code-level markers (optional, only where valuable).
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub markers: Vec<CodeMarker>,
26
27    /// Link to broader effort.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub effort: Option<EffortLink>,
30
31    /// How this annotation was created.
32    pub provenance: Provenance,
33}
34
35impl Annotation {
36    /// Validate the annotation for structural correctness.
37    pub fn validate(&self) -> Result<(), String> {
38        if self.schema != "chronicle/v2" {
39            return Err(format!("unsupported schema version: {}", self.schema));
40        }
41        if self.commit.is_empty() {
42            return Err("commit SHA is empty".to_string());
43        }
44        if self.narrative.summary.is_empty() {
45            return Err("narrative summary is empty".to_string());
46        }
47        for (i, marker) in self.markers.iter().enumerate() {
48            if let Err(e) = marker.validate() {
49                return Err(format!("marker[{}]: {}", i, e));
50            }
51        }
52        Ok(())
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Narrative
58// ---------------------------------------------------------------------------
59
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct Narrative {
62    /// What this commit does and WHY this approach. Not a diff restatement.
63    pub summary: String,
64
65    /// What triggered this change? User request, bug, planned work?
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub motivation: Option<String>,
68
69    /// What alternatives were considered and rejected.
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub rejected_alternatives: Vec<RejectedAlternative>,
72
73    /// Expected follow-up. None = this is complete.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub follow_up: Option<String>,
76
77    /// Files touched (auto-populated from diff for indexing).
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub files_changed: Vec<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
83pub struct RejectedAlternative {
84    pub approach: String,
85    pub reason: String,
86}
87
88// ---------------------------------------------------------------------------
89// Decisions
90// ---------------------------------------------------------------------------
91
92#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
93pub struct Decision {
94    /// What was decided.
95    pub what: String,
96    /// Why.
97    pub why: String,
98    /// How stable is this decision.
99    pub stability: Stability,
100    /// When to revisit.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub revisit_when: Option<String>,
103    /// Files/modules this applies to.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub scope: Vec<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
109#[serde(rename_all = "snake_case")]
110pub enum Stability {
111    Permanent,
112    Provisional,
113    Experimental,
114}
115
116// ---------------------------------------------------------------------------
117// Code Markers (replaces RegionAnnotation)
118// ---------------------------------------------------------------------------
119
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
121pub struct CodeMarker {
122    pub file: String,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub anchor: Option<AstAnchor>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub lines: Option<LineRange>,
127    pub kind: MarkerKind,
128}
129
130impl CodeMarker {
131    pub fn validate(&self) -> Result<(), String> {
132        if self.file.is_empty() {
133            return Err("file is empty".to_string());
134        }
135        if let Some(lines) = &self.lines {
136            if lines.start > lines.end {
137                return Err(format!(
138                    "invalid line range: start ({}) > end ({})",
139                    lines.start, lines.end
140                ));
141            }
142        }
143        Ok(())
144    }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
148#[serde(rename_all = "snake_case", tag = "type")]
149pub enum MarkerKind {
150    /// Behavioral contract: invariant, precondition, assumption.
151    Contract {
152        description: String,
153        source: ContractSource,
154    },
155    /// Something non-obvious that could cause bugs.
156    Hazard { description: String },
157    /// This code assumes something about code elsewhere.
158    Dependency {
159        target_file: String,
160        target_anchor: String,
161        assumption: String,
162    },
163    /// This code is provisional/experimental.
164    Unstable {
165        description: String,
166        revisit_when: String,
167    },
168    /// Security-sensitive code: auth, crypto, input validation, etc.
169    Security { description: String },
170    /// Performance-sensitive code: hot paths, allocations, latency.
171    Performance { description: String },
172    /// Deprecated code with optional replacement pointer.
173    Deprecated {
174        description: String,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        replacement: Option<String>,
177    },
178    /// Known technical debt to address later.
179    TechDebt { description: String },
180    /// Test coverage note: what's tested, what's missing.
181    TestCoverage { description: String },
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
185#[serde(rename_all = "snake_case")]
186pub enum ContractSource {
187    Author,
188    Inferred,
189}
190
191// ---------------------------------------------------------------------------
192// Effort Linking
193// ---------------------------------------------------------------------------
194
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
196pub struct EffortLink {
197    /// Stable identifier (ticket ID, slug, etc.)
198    pub id: String,
199    pub description: String,
200    pub phase: EffortPhase,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
204#[serde(rename_all = "snake_case")]
205pub enum EffortPhase {
206    Start,
207    InProgress,
208    Complete,
209}
210
211// ---------------------------------------------------------------------------
212// Provenance
213// ---------------------------------------------------------------------------
214
215#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
216pub struct Provenance {
217    pub source: ProvenanceSource,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub author: Option<String>,
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub derived_from: Vec<String>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub notes: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
227#[serde(rename_all = "snake_case")]
228pub enum ProvenanceSource {
229    Live,
230    Batch,
231    Backfill,
232    Squash,
233    Amend,
234    MigratedV1,
235}
236
237impl std::fmt::Display for ProvenanceSource {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            Self::Live => write!(f, "live"),
241            Self::Batch => write!(f, "batch"),
242            Self::Backfill => write!(f, "backfill"),
243            Self::Squash => write!(f, "squash"),
244            Self::Amend => write!(f, "amend"),
245            Self::MigratedV1 => write!(f, "migrated_v1"),
246        }
247    }
248}