Skip to main content

chronicle/knowledge/
mod.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::knowledge::{FilteredKnowledge, KnowledgeStore};
4
5/// The empty tree SHA — exists in every git repo. Used as the "commit" object
6/// for the knowledge notes ref, since knowledge is repo-global, not per-commit.
7const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf899d15f13160d28";
8
9/// The notes ref used to store the knowledge store.
10pub const KNOWLEDGE_REF: &str = "refs/notes/chronicle-knowledge";
11
12/// Read the knowledge store from git notes.
13///
14/// Returns a default (empty) store if no knowledge has been written yet.
15pub 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
31/// Write the knowledge store to git notes (atomic overwrite).
32pub 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
42/// Filter the knowledge store to entries whose scope matches a file path.
43///
44/// Matching rules:
45/// - A scope of "*" matches everything.
46/// - A scope ending with "/" is a directory prefix match.
47/// - Otherwise, exact match on the file path.
48pub 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    // Anti-patterns are always global (no scope field), so return all of them.
66    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        // Anti-patterns are always returned
251        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}