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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub sentiments: Vec<Sentiment>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct Sentiment {
91 pub feeling: String,
94 pub detail: String,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99pub struct RejectedAlternative {
100 pub approach: String,
101 pub reason: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109pub struct Decision {
110 pub what: String,
112 pub why: String,
114 pub stability: Stability,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub revisit_when: Option<String>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub scope: Vec<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
125#[serde(rename_all = "snake_case")]
126pub enum Stability {
127 Permanent,
128 Provisional,
129 Experimental,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
137pub struct CodeMarker {
138 pub file: String,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub anchor: Option<AstAnchor>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub lines: Option<LineRange>,
143 pub kind: MarkerKind,
144}
145
146impl CodeMarker {
147 pub fn validate(&self) -> Result<(), String> {
148 if self.file.is_empty() {
149 return Err("file is empty".to_string());
150 }
151 if let Some(lines) = &self.lines {
152 if lines.start > lines.end {
153 return Err(format!(
154 "invalid line range: start ({}) > end ({})",
155 lines.start, lines.end
156 ));
157 }
158 }
159 Ok(())
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164#[serde(rename_all = "snake_case", tag = "type")]
165pub enum MarkerKind {
166 Contract {
168 description: String,
169 source: ContractSource,
170 },
171 Hazard { description: String },
173 Dependency {
175 target_file: String,
176 target_anchor: String,
177 assumption: String,
178 },
179 Unstable {
181 description: String,
182 revisit_when: String,
183 },
184 Security { description: String },
186 Performance { description: String },
188 Deprecated {
190 description: String,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 replacement: Option<String>,
193 },
194 TechDebt { description: String },
196 TestCoverage { description: String },
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
201#[serde(rename_all = "snake_case")]
202pub enum ContractSource {
203 Author,
204 Inferred,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212pub struct EffortLink {
213 pub id: String,
215 pub description: String,
216 pub phase: EffortPhase,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
220#[serde(rename_all = "snake_case")]
221pub enum EffortPhase {
222 Start,
223 InProgress,
224 Complete,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
232pub struct Provenance {
233 pub source: ProvenanceSource,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub author: Option<String>,
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub derived_from: Vec<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub notes: Option<String>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
243#[serde(rename_all = "snake_case")]
244pub enum ProvenanceSource {
245 Live,
246 Batch,
247 Backfill,
248 Squash,
249 Amend,
250 MigratedV1,
251 MigratedV2,
252}
253
254impl std::fmt::Display for ProvenanceSource {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Self::Live => write!(f, "live"),
258 Self::Batch => write!(f, "batch"),
259 Self::Backfill => write!(f, "backfill"),
260 Self::Squash => write!(f, "squash"),
261 Self::Amend => write!(f, "amend"),
262 Self::MigratedV1 => write!(f, "migrated_v1"),
263 Self::MigratedV2 => write!(f, "migrated_v2"),
264 }
265 }
266}