1use crate::core::{Adr, Git, NotesManager};
7use crate::Error;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11pub const INDEX_NOTES_REF: &str = "adr-index";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct IndexEntry {
17 pub id: String,
19 pub commit: String,
21 pub title: String,
23 pub status: String,
25 pub tags: Vec<String>,
27 pub text: String,
29}
30
31impl IndexEntry {
32 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
63pub struct SearchIndex {
64 pub entries: HashMap<String, IndexEntry>,
66 pub version: u32,
68}
69
70impl SearchIndex {
71 #[must_use]
73 pub fn new() -> Self {
74 Self {
75 entries: HashMap::new(),
76 version: 1,
77 }
78 }
79
80 pub fn upsert(&mut self, entry: IndexEntry) {
82 self.entries.insert(entry.id.clone(), entry);
83 }
84
85 pub fn remove(&mut self, id: &str) {
87 self.entries.remove(id);
88 }
89
90 #[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 #[must_use]
101 pub fn all(&self) -> Vec<&IndexEntry> {
102 self.entries.values().collect()
103 }
104}
105
106#[derive(Debug)]
108pub struct IndexManager {
109 git: Git,
110}
111
112impl IndexManager {
113 #[must_use]
115 pub const fn new(git: Git) -> Self {
116 Self { git }
117 }
118
119 pub fn load(&self) -> Result<SearchIndex, Error> {
125 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 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 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 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 fn get_index_commit(&self) -> Result<String, Error> {
183 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 self.git.head()
194 } else {
195 Ok(first_line.to_string())
196 }
197 },
198 Err(_) => {
199 self.git.head().or_else(|_| {
201 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); 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 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 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 }
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 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 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 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 let adr = Adr::new("ADR-0001".to_string(), "Test Decision".to_string());
527 notes.create(&adr).expect("Should create ADR");
528
529 let index = index_manager.rebuild(¬es).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 let adr1 = Adr::new("ADR-0001".to_string(), "Use Rust for CLI".to_string());
545 notes.create(&adr1).expect("Should create ADR");
546
547 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 index_manager.rebuild(¬es).expect("Should rebuild");
565
566 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 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 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}