Skip to main content

chronicle/schema/
knowledge.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::v2::Stability;
5
6/// The top-level knowledge store, stored as a git note on the empty tree.
7#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
8pub struct KnowledgeStore {
9    pub schema: String, // "chronicle/knowledge-v1"
10    #[serde(default)]
11    pub conventions: Vec<Convention>,
12    #[serde(default)]
13    pub boundaries: Vec<ModuleBoundary>,
14    #[serde(default)]
15    pub anti_patterns: Vec<AntiPattern>,
16}
17
18impl KnowledgeStore {
19    pub fn new() -> Self {
20        Self {
21            schema: "chronicle/knowledge-v1".to_string(),
22            conventions: Vec::new(),
23            boundaries: Vec::new(),
24            anti_patterns: Vec::new(),
25        }
26    }
27
28    pub fn is_empty(&self) -> bool {
29        self.conventions.is_empty() && self.boundaries.is_empty() && self.anti_patterns.is_empty()
30    }
31
32    /// Remove an entry by ID. Returns true if found and removed.
33    pub fn remove_by_id(&mut self, id: &str) -> bool {
34        let len_before = self.conventions.len() + self.boundaries.len() + self.anti_patterns.len();
35
36        self.conventions.retain(|c| c.id != id);
37        self.boundaries.retain(|b| b.id != id);
38        self.anti_patterns.retain(|a| a.id != id);
39
40        let len_after = self.conventions.len() + self.boundaries.len() + self.anti_patterns.len();
41        len_after < len_before
42    }
43}
44
45impl Default for KnowledgeStore {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51/// A coding convention or rule.
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct Convention {
54    pub id: String,
55    pub scope: String,
56    pub rule: String,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub decided_in: Option<String>,
59    pub stability: Stability,
60}
61
62/// A module boundary definition.
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
64pub struct ModuleBoundary {
65    pub id: String,
66    pub module: String,
67    pub owns: String,
68    pub boundary: String,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub decided_in: Option<String>,
71}
72
73/// An anti-pattern to avoid.
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
75pub struct AntiPattern {
76    pub id: String,
77    pub pattern: String,
78    pub instead: String,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub learned_from: Option<String>,
81}
82
83/// Filtered knowledge applicable to a specific file.
84#[derive(Debug, Clone, Serialize)]
85pub struct FilteredKnowledge {
86    pub conventions: Vec<Convention>,
87    pub boundaries: Vec<ModuleBoundary>,
88    pub anti_patterns: Vec<AntiPattern>,
89}
90
91impl FilteredKnowledge {
92    pub fn is_empty(&self) -> bool {
93        self.conventions.is_empty() && self.boundaries.is_empty() && self.anti_patterns.is_empty()
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_knowledge_store_new() {
103        let store = KnowledgeStore::new();
104        assert_eq!(store.schema, "chronicle/knowledge-v1");
105        assert!(store.is_empty());
106    }
107
108    #[test]
109    fn test_knowledge_store_remove_by_id() {
110        let mut store = KnowledgeStore::new();
111        store.conventions.push(Convention {
112            id: "conv-1".to_string(),
113            scope: "src/".to_string(),
114            rule: "Use snafu for errors".to_string(),
115            decided_in: None,
116            stability: Stability::Permanent,
117        });
118        store.anti_patterns.push(AntiPattern {
119            id: "ap-1".to_string(),
120            pattern: "unwrap() in production code".to_string(),
121            instead: "Use proper error handling".to_string(),
122            learned_from: None,
123        });
124
125        assert!(store.remove_by_id("conv-1"));
126        assert!(store.conventions.is_empty());
127        assert_eq!(store.anti_patterns.len(), 1);
128
129        assert!(!store.remove_by_id("nonexistent"));
130    }
131
132    #[test]
133    fn test_knowledge_store_roundtrip() {
134        let mut store = KnowledgeStore::new();
135        store.conventions.push(Convention {
136            id: "conv-1".to_string(),
137            scope: "src/schema/".to_string(),
138            rule: "Use parse_annotation() for all deserialization".to_string(),
139            decided_in: Some("abc123".to_string()),
140            stability: Stability::Permanent,
141        });
142        store.boundaries.push(ModuleBoundary {
143            id: "bound-1".to_string(),
144            module: "src/git/".to_string(),
145            owns: "All git operations".to_string(),
146            boundary: "Must not import from provider module".to_string(),
147            decided_in: None,
148        });
149        store.anti_patterns.push(AntiPattern {
150            id: "ap-1".to_string(),
151            pattern: "serde_json::from_str for annotations".to_string(),
152            instead: "Use schema::parse_annotation()".to_string(),
153            learned_from: Some("BUG-42".to_string()),
154        });
155
156        let json = serde_json::to_string_pretty(&store).unwrap();
157        let parsed: KnowledgeStore = serde_json::from_str(&json).unwrap();
158        assert_eq!(parsed.conventions.len(), 1);
159        assert_eq!(parsed.boundaries.len(), 1);
160        assert_eq!(parsed.anti_patterns.len(), 1);
161        assert_eq!(parsed.conventions[0].id, "conv-1");
162    }
163}