1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::discovery::{find_archived_unit, find_unit_file};
6use crate::index::Index;
7use crate::unit::{Status, Unit};
8
9#[derive(Debug)]
11pub struct RecallMatch {
12 pub unit: Unit,
13 pub score: u32,
14}
15
16pub fn recall(mana_dir: &Path, query: &str, all: bool) -> Result<Vec<RecallMatch>> {
24 let query_lower = query.to_lowercase();
25 let index = Index::load_or_rebuild(mana_dir)?;
26
27 let mut matches: Vec<RecallMatch> = Vec::new();
28
29 for entry in &index.units {
30 if !all && entry.status == Status::Closed {
31 continue;
32 }
33
34 let unit_path = match find_unit_file(mana_dir, &entry.id) {
35 Ok(p) => p,
36 Err(_) => continue,
37 };
38
39 let unit = match Unit::from_file(&unit_path) {
40 Ok(b) => b,
41 Err(_) => continue,
42 };
43
44 if let Some(score) = score_match(&unit, &query_lower) {
45 matches.push(RecallMatch { unit, score });
46 }
47 }
48
49 if all {
50 let archived = Index::collect_archived(mana_dir).unwrap_or_default();
51 for entry in &archived {
52 let unit_path = match find_archived_unit(mana_dir, &entry.id) {
53 Ok(p) => p,
54 Err(_) => continue,
55 };
56
57 let unit = match Unit::from_file(&unit_path) {
58 Ok(b) => b,
59 Err(_) => continue,
60 };
61
62 if let Some(score) = score_match(&unit, &query_lower) {
63 matches.push(RecallMatch { unit, score });
64 }
65 }
66 }
67
68 matches.sort_by(|a, b| {
69 b.score
70 .cmp(&a.score)
71 .then_with(|| b.unit.updated_at.cmp(&a.unit.updated_at))
72 });
73
74 Ok(matches)
75}
76
77fn score_match(unit: &Unit, query_lower: &str) -> Option<u32> {
79 let mut score = 0u32;
80
81 if unit.title.to_lowercase().contains(query_lower) {
82 score += 10;
83 }
84
85 if let Some(ref desc) = unit.description {
86 if desc.to_lowercase().contains(query_lower) {
87 score += 5;
88 }
89 }
90
91 if let Some(ref notes) = unit.notes {
92 if notes.to_lowercase().contains(query_lower) {
93 score += 3;
94 }
95 }
96
97 if let Some(ref reason) = unit.close_reason {
98 if reason.to_lowercase().contains(query_lower) {
99 score += 3;
100 }
101 }
102
103 for path in &unit.paths {
104 if path.to_lowercase().contains(query_lower) {
105 score += 4;
106 break;
107 }
108 }
109
110 for label in &unit.labels {
111 if label.to_lowercase().contains(query_lower) {
112 score += 2;
113 break;
114 }
115 }
116
117 for attempt in &unit.attempt_log {
118 if let Some(ref notes) = attempt.notes {
119 if notes.to_lowercase().contains(query_lower) {
120 score += 4;
121 break;
122 }
123 }
124 }
125
126 if score > 0 {
127 Some(score)
128 } else {
129 None
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn make_unit(id: &str, title: &str) -> Unit {
138 Unit::new(id, title)
139 }
140
141 #[test]
142 fn score_match_title() {
143 let unit = make_unit("1", "Auth uses RS256");
144 assert!(score_match(&unit, "rs256").is_some());
145 assert!(score_match(&unit, "auth").is_some());
146 assert!(score_match(&unit, "xyz").is_none());
147 }
148
149 #[test]
150 fn score_match_description() {
151 let mut unit = make_unit("1", "Config");
152 unit.description = Some("Uses YAML format for configuration".to_string());
153 assert!(score_match(&unit, "yaml").is_some());
154 }
155
156 #[test]
157 fn score_match_paths() {
158 let mut unit = make_unit("1", "Config");
159 unit.paths = vec!["src/auth.rs".to_string()];
160 assert!(score_match(&unit, "auth").is_some());
161 }
162
163 #[test]
164 fn title_scores_higher_than_description() {
165 let mut unit = make_unit("1", "Auth module");
166 unit.description = Some("Auth is important".to_string());
167 let score = score_match(&unit, "auth").unwrap();
168 assert_eq!(score, 15); }
170}