Skip to main content

chronicle/schema/
v3.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::common::LineRange;
5
6// Re-export Provenance from v2 — shared between v2 and v3.
7pub use super::v2::{Provenance, ProvenanceSource};
8
9// ---------------------------------------------------------------------------
10// Top-level Annotation
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
14pub struct Annotation {
15    pub schema: String,
16    pub commit: String,
17    pub timestamp: String,
18
19    /// What this commit does and WHY this approach. Not a diff restatement.
20    pub summary: String,
21
22    /// Accumulated wisdom entries — dead ends, gotchas, insights, threads.
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub wisdom: Vec<WisdomEntry>,
25
26    /// How this annotation was created.
27    pub provenance: Provenance,
28}
29
30impl Annotation {
31    /// Validate the annotation for structural correctness.
32    pub fn validate(&self) -> Result<(), String> {
33        if self.schema != "chronicle/v3" {
34            return Err(format!("unsupported schema version: {}", self.schema));
35        }
36        if self.commit.is_empty() {
37            return Err("commit SHA is empty".to_string());
38        }
39        if self.summary.is_empty() {
40            return Err("summary is empty".to_string());
41        }
42        for (i, entry) in self.wisdom.iter().enumerate() {
43            if let Err(e) = entry.validate() {
44                return Err(format!("wisdom[{}]: {}", i, e));
45            }
46        }
47        Ok(())
48    }
49}
50
51// ---------------------------------------------------------------------------
52// Wisdom Entries
53// ---------------------------------------------------------------------------
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
56pub struct WisdomEntry {
57    /// What kind of wisdom this captures.
58    pub category: WisdomCategory,
59
60    /// Free-form prose — what was learned, not what the code does.
61    pub content: String,
62
63    /// File this wisdom applies to. None = repo-wide.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub file: Option<String>,
66
67    /// Line range within the file. Only meaningful when `file` is present.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub lines: Option<LineRange>,
70}
71
72impl WisdomEntry {
73    pub fn validate(&self) -> Result<(), String> {
74        if self.content.is_empty() {
75            return Err("content is empty".to_string());
76        }
77        if let Some(lines) = &self.lines {
78            if lines.start > lines.end {
79                return Err(format!(
80                    "invalid line range: start ({}) > end ({})",
81                    lines.start, lines.end
82                ));
83            }
84        }
85        if self.lines.is_some() && self.file.is_none() {
86            return Err("lines specified without file".to_string());
87        }
88        Ok(())
89    }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
93#[serde(rename_all = "snake_case")]
94pub enum WisdomCategory {
95    /// Things tried and failed.
96    DeadEnd,
97    /// Non-obvious traps invisible in the code.
98    Gotcha,
99    /// Mental models, key relationships, architecture.
100    Insight,
101    /// Incomplete work, suspected better approaches.
102    UnfinishedThread,
103}
104
105impl std::fmt::Display for WisdomCategory {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        match self {
108            Self::DeadEnd => write!(f, "dead_end"),
109            Self::Gotcha => write!(f, "gotcha"),
110            Self::Insight => write!(f, "insight"),
111            Self::UnfinishedThread => write!(f, "unfinished_thread"),
112        }
113    }
114}