git_adr/core/
index.rs

1//! Search index for ADRs.
2//!
3//! This module provides full-text search capabilities for ADRs
4//! using an index stored in git notes.
5
6use crate::core::{Adr, Git, NotesManager};
7use crate::Error;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Notes reference for the search index.
12pub const INDEX_NOTES_REF: &str = "adr-index";
13
14/// A search index entry for an ADR.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct IndexEntry {
17    /// ADR ID.
18    pub id: String,
19    /// Commit hash.
20    pub commit: String,
21    /// ADR title.
22    pub title: String,
23    /// ADR status.
24    pub status: String,
25    /// Tags.
26    pub tags: Vec<String>,
27    /// Full-text content for searching.
28    pub text: String,
29}
30
31impl IndexEntry {
32    /// Create an index entry from an ADR.
33    #[must_use]
34    pub fn from_adr(adr: &Adr) -> Self {
35        Self {
36            id: adr.id.clone(),
37            commit: adr.commit.clone(),
38            title: adr.frontmatter.title.clone(),
39            status: adr.frontmatter.status.to_string(),
40            tags: adr.frontmatter.tags.clone(),
41            text: format!(
42                "{} {} {}",
43                adr.frontmatter.title,
44                adr.frontmatter.tags.join(" "),
45                adr.body
46            )
47            .to_lowercase(),
48        }
49    }
50
51    /// Check if this entry matches a query.
52    #[must_use]
53    pub fn matches(&self, query: &str) -> bool {
54        let query_lower = query.to_lowercase();
55        self.text.contains(&query_lower)
56            || self.id.to_lowercase().contains(&query_lower)
57            || self.title.to_lowercase().contains(&query_lower)
58    }
59}
60
61/// The search index.
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
63pub struct SearchIndex {
64    /// Index entries by ADR ID.
65    pub entries: HashMap<String, IndexEntry>,
66    /// Version of the index format.
67    pub version: u32,
68}
69
70impl SearchIndex {
71    /// Create a new empty index.
72    #[must_use]
73    pub fn new() -> Self {
74        Self {
75            entries: HashMap::new(),
76            version: 1,
77        }
78    }
79
80    /// Add or update an entry.
81    pub fn upsert(&mut self, entry: IndexEntry) {
82        self.entries.insert(entry.id.clone(), entry);
83    }
84
85    /// Remove an entry.
86    pub fn remove(&mut self, id: &str) {
87        self.entries.remove(id);
88    }
89
90    /// Search the index.
91    #[must_use]
92    pub fn search(&self, query: &str) -> Vec<&IndexEntry> {
93        self.entries
94            .values()
95            .filter(|entry| entry.matches(query))
96            .collect()
97    }
98
99    /// Get all entries.
100    #[must_use]
101    pub fn all(&self) -> Vec<&IndexEntry> {
102        self.entries.values().collect()
103    }
104}
105
106/// Manager for the search index.
107#[derive(Debug)]
108pub struct IndexManager {
109    git: Git,
110}
111
112impl IndexManager {
113    /// Create a new `IndexManager`.
114    #[must_use]
115    pub const fn new(git: Git) -> Self {
116        Self { git }
117    }
118
119    /// Load the index from git notes.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the index cannot be loaded.
124    pub fn load(&self) -> Result<SearchIndex, Error> {
125        // We store the index in a note attached to a special "index" ref
126        // For simplicity, we use the repo's initial commit or a fixed hash
127        let commit = self.get_index_commit()?;
128
129        match self.git.notes_show(INDEX_NOTES_REF, &commit)? {
130            Some(content) => serde_yaml::from_str(&content).map_err(|e| Error::ParseError {
131                message: format!("Failed to parse index: {e}"),
132            }),
133            None => Ok(SearchIndex::new()),
134        }
135    }
136
137    /// Save the index to git notes.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the index cannot be saved.
142    pub fn save(&self, index: &SearchIndex) -> Result<(), Error> {
143        let commit = self.get_index_commit()?;
144        let content = serde_yaml::to_string(index).map_err(|e| Error::ParseError {
145            message: format!("Failed to serialize index: {e}"),
146        })?;
147
148        self.git.notes_add(INDEX_NOTES_REF, &commit, &content)?;
149
150        Ok(())
151    }
152
153    /// Rebuild the index from all ADRs.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the index cannot be rebuilt.
158    pub fn rebuild(&self, notes: &NotesManager) -> Result<SearchIndex, Error> {
159        let adrs = notes.list()?;
160        let mut index = SearchIndex::new();
161
162        for adr in &adrs {
163            index.upsert(IndexEntry::from_adr(adr));
164        }
165
166        self.save(&index)?;
167
168        Ok(index)
169    }
170
171    /// Search for ADRs matching a query.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the search fails.
176    pub fn search(&self, query: &str) -> Result<Vec<IndexEntry>, Error> {
177        let index = self.load()?;
178        Ok(index.search(query).into_iter().cloned().collect())
179    }
180
181    /// Get the commit hash used to store the index.
182    fn get_index_commit(&self) -> Result<String, Error> {
183        // Try to get the first commit in the repository
184        // This may fail in empty repositories with no commits
185        match self
186            .git
187            .run_output(&["rev-list", "--max-parents=0", "HEAD"])
188        {
189            Ok(output) => {
190                let first_line = output.lines().next().unwrap_or("").trim();
191                if first_line.is_empty() {
192                    // No commits yet, use HEAD (may also fail)
193                    self.git.head()
194                } else {
195                    Ok(first_line.to_string())
196                }
197            },
198            Err(_) => {
199                // Empty repository with no commits - try HEAD, then fall back to empty tree
200                self.git.head().or_else(|_| {
201                    // Use git's empty tree hash as fallback for truly empty repos
202                    Ok("4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string())
203                })
204            },
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_index_entry_matches() {
215        let entry = IndexEntry {
216            id: "ADR-0001".to_string(),
217            commit: "abc123".to_string(),
218            title: "Use Rust for CLI".to_string(),
219            status: "proposed".to_string(),
220            tags: vec!["architecture".to_string()],
221            text: "use rust for cli architecture".to_string(),
222        };
223
224        assert!(entry.matches("rust"));
225        assert!(entry.matches("RUST"));
226        assert!(entry.matches("adr-0001"));
227        assert!(!entry.matches("python"));
228    }
229
230    #[test]
231    fn test_index_entry_from_adr() {
232        let mut adr = Adr::new("ADR-0001".to_string(), "Test Title".to_string());
233        adr.commit = "abc123".to_string();
234        adr.frontmatter.tags = vec!["rust".to_string(), "cli".to_string()];
235        adr.body = "This is the body content.".to_string();
236
237        let entry = IndexEntry::from_adr(&adr);
238        assert_eq!(entry.id, "ADR-0001");
239        assert_eq!(entry.commit, "abc123");
240        assert_eq!(entry.title, "Test Title");
241        assert_eq!(entry.status, "proposed");
242        assert_eq!(entry.tags, vec!["rust", "cli"]);
243        assert!(entry.text.contains("test title"));
244        assert!(entry.text.contains("this is the body content"));
245    }
246
247    #[test]
248    fn test_search_index() {
249        let mut index = SearchIndex::new();
250        index.upsert(IndexEntry {
251            id: "ADR-0001".to_string(),
252            commit: "abc123".to_string(),
253            title: "Use Rust".to_string(),
254            status: "proposed".to_string(),
255            tags: vec![],
256            text: "use rust".to_string(),
257        });
258        index.upsert(IndexEntry {
259            id: "ADR-0002".to_string(),
260            commit: "def456".to_string(),
261            title: "Use Python".to_string(),
262            status: "accepted".to_string(),
263            tags: vec![],
264            text: "use python".to_string(),
265        });
266
267        assert_eq!(index.search("rust").len(), 1);
268        assert_eq!(index.search("use").len(), 2);
269        assert_eq!(index.search("java").len(), 0);
270    }
271
272    #[test]
273    fn test_search_index_remove() {
274        let mut index = SearchIndex::new();
275        index.upsert(IndexEntry {
276            id: "ADR-0001".to_string(),
277            commit: "abc123".to_string(),
278            title: "Use Rust".to_string(),
279            status: "proposed".to_string(),
280            tags: vec![],
281            text: "use rust".to_string(),
282        });
283
284        assert_eq!(index.entries.len(), 1);
285        index.remove("ADR-0001");
286        assert_eq!(index.entries.len(), 0);
287    }
288
289    #[test]
290    fn test_search_index_all() {
291        let mut index = SearchIndex::new();
292        index.upsert(IndexEntry {
293            id: "ADR-0001".to_string(),
294            commit: "abc123".to_string(),
295            title: "First".to_string(),
296            status: "proposed".to_string(),
297            tags: vec![],
298            text: "first".to_string(),
299        });
300        index.upsert(IndexEntry {
301            id: "ADR-0002".to_string(),
302            commit: "def456".to_string(),
303            title: "Second".to_string(),
304            status: "accepted".to_string(),
305            tags: vec![],
306            text: "second".to_string(),
307        });
308
309        let all = index.all();
310        assert_eq!(all.len(), 2);
311    }
312
313    #[test]
314    fn test_search_index_upsert_updates() {
315        let mut index = SearchIndex::new();
316        index.upsert(IndexEntry {
317            id: "ADR-0001".to_string(),
318            commit: "abc123".to_string(),
319            title: "Original".to_string(),
320            status: "proposed".to_string(),
321            tags: vec![],
322            text: "original".to_string(),
323        });
324
325        index.upsert(IndexEntry {
326            id: "ADR-0001".to_string(),
327            commit: "abc123".to_string(),
328            title: "Updated".to_string(),
329            status: "accepted".to_string(),
330            tags: vec![],
331            text: "updated".to_string(),
332        });
333
334        assert_eq!(index.entries.len(), 1);
335        assert_eq!(index.entries.get("ADR-0001").unwrap().title, "Updated");
336    }
337
338    #[test]
339    fn test_search_index_new() {
340        let index = SearchIndex::new();
341        assert_eq!(index.version, 1);
342        assert!(index.entries.is_empty());
343    }
344
345    #[test]
346    fn test_search_index_default() {
347        let index = SearchIndex::default();
348        assert_eq!(index.version, 0); // Default doesn't set version to 1
349        assert!(index.entries.is_empty());
350    }
351
352    #[test]
353    fn test_index_entry_matches_by_id() {
354        let entry = IndexEntry {
355            id: "ADR-0001".to_string(),
356            commit: "abc123".to_string(),
357            title: "Something Else".to_string(),
358            status: "proposed".to_string(),
359            tags: vec![],
360            text: "something else".to_string(),
361        };
362        // Should match by ID
363        assert!(entry.matches("ADR-0001"));
364        assert!(entry.matches("adr-0001"));
365    }
366
367    #[test]
368    fn test_index_entry_matches_by_title() {
369        let entry = IndexEntry {
370            id: "ADR-0001".to_string(),
371            commit: "abc123".to_string(),
372            title: "Use PostgreSQL for Database".to_string(),
373            status: "proposed".to_string(),
374            tags: vec![],
375            text: "some text".to_string(),
376        };
377        // Should match by title
378        assert!(entry.matches("PostgreSQL"));
379        assert!(entry.matches("POSTGRESQL"));
380    }
381
382    #[test]
383    fn test_index_manager_new() {
384        let git = Git::new();
385        let _manager = IndexManager::new(git);
386        // Just verify it creates without panic
387    }
388
389    #[test]
390    fn test_search_index_clone() {
391        let mut index = SearchIndex::new();
392        index.upsert(IndexEntry {
393            id: "ADR-0001".to_string(),
394            commit: "abc123".to_string(),
395            title: "Test".to_string(),
396            status: "proposed".to_string(),
397            tags: vec!["test".to_string()],
398            text: "test".to_string(),
399        });
400        let cloned = index.clone();
401        assert_eq!(cloned.entries.len(), 1);
402        assert_eq!(cloned.version, index.version);
403    }
404
405    #[test]
406    fn test_index_entry_clone() {
407        let entry = IndexEntry {
408            id: "ADR-0001".to_string(),
409            commit: "abc123".to_string(),
410            title: "Test".to_string(),
411            status: "proposed".to_string(),
412            tags: vec!["test".to_string()],
413            text: "test content".to_string(),
414        };
415        let cloned = entry.clone();
416        assert_eq!(cloned.id, entry.id);
417        assert_eq!(cloned.commit, entry.commit);
418        assert_eq!(cloned.title, entry.title);
419        assert_eq!(cloned.tags, entry.tags);
420    }
421
422    #[test]
423    fn test_search_index_serialization() {
424        let mut index = SearchIndex::new();
425        index.upsert(IndexEntry {
426            id: "ADR-0001".to_string(),
427            commit: "abc123".to_string(),
428            title: "Test".to_string(),
429            status: "proposed".to_string(),
430            tags: vec!["tag1".to_string()],
431            text: "test".to_string(),
432        });
433
434        let yaml = serde_yaml::to_string(&index).expect("Should serialize");
435        let deserialized: SearchIndex = serde_yaml::from_str(&yaml).expect("Should deserialize");
436        assert_eq!(deserialized.version, index.version);
437        assert_eq!(deserialized.entries.len(), index.entries.len());
438    }
439
440    use crate::core::{AdrConfig, NotesManager};
441    use std::process::Command as StdCommand;
442    use tempfile::TempDir;
443
444    fn setup_git_repo() -> TempDir {
445        let temp_dir = TempDir::new().expect("Failed to create temp directory");
446        let path = temp_dir.path();
447
448        StdCommand::new("git")
449            .args(["init"])
450            .current_dir(path)
451            .output()
452            .expect("Failed to init git repo");
453
454        StdCommand::new("git")
455            .args(["config", "user.email", "test@example.com"])
456            .current_dir(path)
457            .output()
458            .expect("Failed to set git user email");
459
460        StdCommand::new("git")
461            .args(["config", "user.name", "Test User"])
462            .current_dir(path)
463            .output()
464            .expect("Failed to set git user name");
465
466        std::fs::write(path.join("README.md"), "# Test Repo\n").expect("Failed to write README");
467        StdCommand::new("git")
468            .args(["add", "."])
469            .current_dir(path)
470            .output()
471            .expect("Failed to stage files");
472        StdCommand::new("git")
473            .args(["commit", "-m", "Initial commit"])
474            .current_dir(path)
475            .output()
476            .expect("Failed to create initial commit");
477
478        temp_dir
479    }
480
481    #[test]
482    fn test_index_manager_load_empty() {
483        let temp_dir = setup_git_repo();
484        let git = Git::with_work_dir(temp_dir.path());
485        let manager = IndexManager::new(git);
486
487        // Load should return empty index when none exists
488        let index = manager.load().expect("Should load");
489        assert!(index.entries.is_empty());
490    }
491
492    #[test]
493    fn test_index_manager_save_and_load() {
494        let temp_dir = setup_git_repo();
495        let git = Git::with_work_dir(temp_dir.path());
496        let manager = IndexManager::new(git);
497
498        // Create and save an index
499        let mut index = SearchIndex::new();
500        index.upsert(IndexEntry {
501            id: "ADR-0001".to_string(),
502            commit: "abc123".to_string(),
503            title: "Test".to_string(),
504            status: "proposed".to_string(),
505            tags: vec!["tag1".to_string()],
506            text: "test".to_string(),
507        });
508
509        manager.save(&index).expect("Should save");
510
511        // Load it back
512        let loaded = manager.load().expect("Should load");
513        assert_eq!(loaded.entries.len(), 1);
514        assert!(loaded.entries.contains_key("ADR-0001"));
515    }
516
517    #[test]
518    fn test_index_manager_rebuild() {
519        let temp_dir = setup_git_repo();
520        let git = Git::with_work_dir(temp_dir.path());
521        let config = AdrConfig::default();
522        let notes = NotesManager::new(git.clone(), config);
523        let index_manager = IndexManager::new(git);
524
525        // Create an ADR
526        let adr = Adr::new("ADR-0001".to_string(), "Test Decision".to_string());
527        notes.create(&adr).expect("Should create ADR");
528
529        // Rebuild index
530        let index = index_manager.rebuild(&notes).expect("Should rebuild");
531        assert_eq!(index.entries.len(), 1);
532        assert!(index.entries.contains_key("ADR-0001"));
533    }
534
535    #[test]
536    fn test_index_manager_search() {
537        let temp_dir = setup_git_repo();
538        let git = Git::with_work_dir(temp_dir.path());
539        let config = AdrConfig::default();
540        let notes = NotesManager::new(git.clone(), config);
541        let index_manager = IndexManager::new(git);
542
543        // Create ADRs
544        let adr1 = Adr::new("ADR-0001".to_string(), "Use Rust for CLI".to_string());
545        notes.create(&adr1).expect("Should create ADR");
546
547        // Create another commit for second ADR
548        std::fs::write(temp_dir.path().join("file1.txt"), "content").expect("Failed to write");
549        StdCommand::new("git")
550            .args(["add", "."])
551            .current_dir(temp_dir.path())
552            .output()
553            .expect("Failed to stage");
554        StdCommand::new("git")
555            .args(["commit", "-m", "Second commit"])
556            .current_dir(temp_dir.path())
557            .output()
558            .expect("Failed to commit");
559
560        let adr2 = Adr::new("ADR-0002".to_string(), "Use Python for Scripts".to_string());
561        notes.create(&adr2).expect("Should create ADR");
562
563        // Rebuild index
564        index_manager.rebuild(&notes).expect("Should rebuild");
565
566        // Search
567        let results = index_manager.search("Rust").expect("Should search");
568        assert_eq!(results.len(), 1);
569        assert_eq!(results[0].id, "ADR-0001");
570
571        // Search for both
572        let results = index_manager.search("Use").expect("Should search");
573        assert_eq!(results.len(), 2);
574    }
575
576    #[test]
577    fn test_index_manager_get_index_commit() {
578        let temp_dir = setup_git_repo();
579        let git = Git::with_work_dir(temp_dir.path());
580        let manager = IndexManager::new(git);
581
582        // get_index_commit is private, but we can test it indirectly via save/load
583        let mut index = SearchIndex::new();
584        manager.save(&index).expect("Should save");
585        index = manager.load().expect("Should load");
586        assert_eq!(index.version, 1);
587    }
588}