1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::knowledge::{FilteredKnowledge, KnowledgeStore};
4
5const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf899d15f13160d28";
8
9pub const KNOWLEDGE_REF: &str = "refs/notes/chronicle-knowledge";
11
12pub fn read_store(git: &dyn GitOps) -> Result<KnowledgeStore, GitError> {
16 let note = git.note_read(EMPTY_TREE_SHA)?;
17 match note {
18 Some(json) => {
19 let store: KnowledgeStore = serde_json::from_str(&json).map_err(|e| {
20 crate::error::git_error::CommandFailedSnafu {
21 message: format!("failed to parse knowledge store: {e}"),
22 }
23 .build()
24 })?;
25 Ok(store)
26 }
27 None => Ok(KnowledgeStore::new()),
28 }
29}
30
31pub fn write_store(git: &dyn GitOps, store: &KnowledgeStore) -> Result<(), GitError> {
33 let json = serde_json::to_string_pretty(store).map_err(|e| {
34 crate::error::git_error::CommandFailedSnafu {
35 message: format!("failed to serialize knowledge store: {e}"),
36 }
37 .build()
38 })?;
39 git.note_write(EMPTY_TREE_SHA, &json)
40}
41
42pub fn filter_by_scope(store: &KnowledgeStore, file: &str) -> FilteredKnowledge {
49 let file_normalized = file.strip_prefix("./").unwrap_or(file);
50
51 let conventions = store
52 .conventions
53 .iter()
54 .filter(|c| scope_matches(&c.scope, file_normalized))
55 .cloned()
56 .collect();
57
58 let boundaries = store
59 .boundaries
60 .iter()
61 .filter(|b| scope_matches(&b.module, file_normalized))
62 .cloned()
63 .collect();
64
65 let anti_patterns = store.anti_patterns.clone();
67
68 FilteredKnowledge {
69 conventions,
70 boundaries,
71 anti_patterns,
72 }
73}
74
75fn scope_matches(scope: &str, file: &str) -> bool {
76 if scope == "*" {
77 return true;
78 }
79 let scope_normalized = scope.strip_prefix("./").unwrap_or(scope);
80 let file_normalized = file.strip_prefix("./").unwrap_or(file);
81 if scope_normalized.ends_with('/') {
82 file_normalized.starts_with(scope_normalized)
83 } else {
84 file_normalized == scope_normalized
85 || file_normalized.starts_with(&format!("{scope_normalized}/"))
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::git::diff::FileDiff;
93 use crate::git::CommitInfo;
94 use crate::schema::knowledge::{AntiPattern, Convention, ModuleBoundary};
95 use crate::schema::v2::Stability;
96 use std::collections::HashMap;
97 use std::sync::Mutex;
98
99 struct MockGitOps {
100 notes: Mutex<HashMap<String, String>>,
101 }
102
103 impl MockGitOps {
104 fn new() -> Self {
105 Self {
106 notes: Mutex::new(HashMap::new()),
107 }
108 }
109
110 fn with_note(self, commit: &str, content: &str) -> Self {
111 self.notes
112 .lock()
113 .unwrap()
114 .insert(commit.to_string(), content.to_string());
115 self
116 }
117 }
118
119 impl GitOps for MockGitOps {
120 fn diff(&self, _commit: &str) -> Result<Vec<FileDiff>, GitError> {
121 Ok(vec![])
122 }
123 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
124 Ok(self.notes.lock().unwrap().get(commit).cloned())
125 }
126 fn note_write(&self, commit: &str, content: &str) -> Result<(), GitError> {
127 self.notes
128 .lock()
129 .unwrap()
130 .insert(commit.to_string(), content.to_string());
131 Ok(())
132 }
133 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
134 Ok(self.notes.lock().unwrap().contains_key(commit))
135 }
136 fn file_at_commit(
137 &self,
138 _path: &std::path::Path,
139 _commit: &str,
140 ) -> Result<String, GitError> {
141 Ok(String::new())
142 }
143 fn commit_info(&self, _commit: &str) -> Result<CommitInfo, GitError> {
144 Ok(CommitInfo {
145 sha: "abc123".to_string(),
146 message: "test".to_string(),
147 author_name: "test".to_string(),
148 author_email: "test@test.com".to_string(),
149 timestamp: "2025-01-01T00:00:00Z".to_string(),
150 parent_shas: vec![],
151 })
152 }
153 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
154 Ok("abc123".to_string())
155 }
156 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
157 Ok(None)
158 }
159 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
160 Ok(())
161 }
162 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
163 Ok(vec![])
164 }
165 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
166 Ok(vec![])
167 }
168 }
169
170 #[test]
171 fn test_read_empty_store() {
172 let git = MockGitOps::new();
173 let store = read_store(&git).unwrap();
174 assert!(store.is_empty());
175 assert_eq!(store.schema, "chronicle/knowledge-v1");
176 }
177
178 #[test]
179 fn test_write_and_read_roundtrip() {
180 let git = MockGitOps::new();
181
182 let mut store = KnowledgeStore::new();
183 store.conventions.push(Convention {
184 id: "conv-1".to_string(),
185 scope: "src/schema/".to_string(),
186 rule: "Use parse_annotation() for all deserialization".to_string(),
187 decided_in: None,
188 stability: Stability::Permanent,
189 });
190
191 write_store(&git, &store).unwrap();
192 let loaded = read_store(&git).unwrap();
193 assert_eq!(loaded.conventions.len(), 1);
194 assert_eq!(loaded.conventions[0].id, "conv-1");
195 }
196
197 #[test]
198 fn test_read_existing_store() {
199 let store = KnowledgeStore {
200 schema: "chronicle/knowledge-v1".to_string(),
201 conventions: vec![Convention {
202 id: "conv-1".to_string(),
203 scope: "src/".to_string(),
204 rule: "Test rule".to_string(),
205 decided_in: None,
206 stability: Stability::Provisional,
207 }],
208 boundaries: vec![],
209 anti_patterns: vec![],
210 };
211 let json = serde_json::to_string(&store).unwrap();
212 let git = MockGitOps::new().with_note(EMPTY_TREE_SHA, &json);
213
214 let loaded = read_store(&git).unwrap();
215 assert_eq!(loaded.conventions.len(), 1);
216 }
217
218 #[test]
219 fn test_filter_by_scope_directory_prefix() {
220 let store = KnowledgeStore {
221 schema: "chronicle/knowledge-v1".to_string(),
222 conventions: vec![
223 Convention {
224 id: "conv-1".to_string(),
225 scope: "src/schema/".to_string(),
226 rule: "Schema rule".to_string(),
227 decided_in: None,
228 stability: Stability::Permanent,
229 },
230 Convention {
231 id: "conv-2".to_string(),
232 scope: "src/git/".to_string(),
233 rule: "Git rule".to_string(),
234 decided_in: None,
235 stability: Stability::Permanent,
236 },
237 ],
238 boundaries: vec![],
239 anti_patterns: vec![AntiPattern {
240 id: "ap-1".to_string(),
241 pattern: "bad".to_string(),
242 instead: "good".to_string(),
243 learned_from: None,
244 }],
245 };
246
247 let filtered = filter_by_scope(&store, "src/schema/v2.rs");
248 assert_eq!(filtered.conventions.len(), 1);
249 assert_eq!(filtered.conventions[0].id, "conv-1");
250 assert_eq!(filtered.anti_patterns.len(), 1);
252 }
253
254 #[test]
255 fn test_filter_by_scope_wildcard() {
256 let store = KnowledgeStore {
257 schema: "chronicle/knowledge-v1".to_string(),
258 conventions: vec![Convention {
259 id: "conv-1".to_string(),
260 scope: "*".to_string(),
261 rule: "Global rule".to_string(),
262 decided_in: None,
263 stability: Stability::Permanent,
264 }],
265 boundaries: vec![],
266 anti_patterns: vec![],
267 };
268
269 let filtered = filter_by_scope(&store, "any/file.rs");
270 assert_eq!(filtered.conventions.len(), 1);
271 }
272
273 #[test]
274 fn test_filter_by_scope_no_match() {
275 let store = KnowledgeStore {
276 schema: "chronicle/knowledge-v1".to_string(),
277 conventions: vec![Convention {
278 id: "conv-1".to_string(),
279 scope: "src/git/".to_string(),
280 rule: "Git rule".to_string(),
281 decided_in: None,
282 stability: Stability::Permanent,
283 }],
284 boundaries: vec![],
285 anti_patterns: vec![],
286 };
287
288 let filtered = filter_by_scope(&store, "src/schema/v2.rs");
289 assert!(filtered.conventions.is_empty());
290 }
291
292 #[test]
293 fn test_scope_matches_normalization() {
294 assert!(scope_matches("src/", "./src/foo.rs"));
295 assert!(scope_matches("./src/", "src/foo.rs"));
296 }
297
298 #[test]
299 fn test_filter_boundaries_by_module() {
300 let store = KnowledgeStore {
301 schema: "chronicle/knowledge-v1".to_string(),
302 conventions: vec![],
303 boundaries: vec![
304 ModuleBoundary {
305 id: "b-1".to_string(),
306 module: "src/git/".to_string(),
307 owns: "Git operations".to_string(),
308 boundary: "No provider imports".to_string(),
309 decided_in: None,
310 },
311 ModuleBoundary {
312 id: "b-2".to_string(),
313 module: "src/provider/".to_string(),
314 owns: "LLM providers".to_string(),
315 boundary: "No git imports".to_string(),
316 decided_in: None,
317 },
318 ],
319 anti_patterns: vec![],
320 };
321
322 let filtered = filter_by_scope(&store, "src/git/cli_ops.rs");
323 assert_eq!(filtered.boundaries.len(), 1);
324 assert_eq!(filtered.boundaries[0].id, "b-1");
325 }
326}