1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::common::{AstAnchor, LineRange};
5
6#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11pub struct Annotation {
12 pub schema: String,
13 pub commit: String,
14 pub timestamp: String,
15
16 pub narrative: Narrative,
18
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
21 pub decisions: Vec<Decision>,
22
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub markers: Vec<CodeMarker>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub effort: Option<EffortLink>,
30
31 pub provenance: Provenance,
33}
34
35impl Annotation {
36 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct Narrative {
62 pub summary: String,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub motivation: Option<String>,
68
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub rejected_alternatives: Vec<RejectedAlternative>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub follow_up: Option<String>,
76
77 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
93pub struct Decision {
94 pub what: String,
96 pub why: String,
98 pub stability: Stability,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub revisit_when: Option<String>,
103 #[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#[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 Contract {
152 description: String,
153 source: ContractSource,
154 },
155 Hazard { description: String },
157 Dependency {
159 target_file: String,
160 target_anchor: String,
161 assumption: String,
162 },
163 Unstable {
165 description: String,
166 revisit_when: String,
167 },
168 Security { description: String },
170 Performance { description: String },
172 Deprecated {
174 description: String,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 replacement: Option<String>,
177 },
178 TechDebt { description: String },
180 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
196pub struct EffortLink {
197 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#[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}