1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::bean::{Bean, Status};
6use crate::discovery::{find_archived_bean, find_bean_file};
7use crate::index::Index;
8
9pub fn cmd_recall(beans_dir: &Path, query: &str, all: bool, json: bool) -> Result<()> {
14 let query_lower = query.to_lowercase();
15 let index = Index::load_or_rebuild(beans_dir)?;
16
17 let mut matches: Vec<(Bean, u32)> = Vec::new(); for entry in &index.beans {
21 if !all && entry.status == Status::Closed {
22 continue;
23 }
24
25 let bean_path = match find_bean_file(beans_dir, &entry.id) {
26 Ok(p) => p,
27 Err(_) => continue,
28 };
29
30 let bean = match Bean::from_file(&bean_path) {
31 Ok(b) => b,
32 Err(_) => continue,
33 };
34
35 if let Some(score) = score_match(&bean, &query_lower) {
36 matches.push((bean, score));
37 }
38 }
39
40 if all {
42 let archived = Index::collect_archived(beans_dir).unwrap_or_default();
43 for entry in &archived {
44 let bean_path = match find_archived_bean(beans_dir, &entry.id) {
45 Ok(p) => p,
46 Err(_) => continue,
47 };
48
49 let bean = match Bean::from_file(&bean_path) {
50 Ok(b) => b,
51 Err(_) => continue,
52 };
53
54 if let Some(score) = score_match(&bean, &query_lower) {
55 matches.push((bean, score));
56 }
57 }
58 }
59
60 matches.sort_by(|a, b| {
62 b.1.cmp(&a.1)
63 .then_with(|| b.0.updated_at.cmp(&a.0.updated_at))
64 });
65
66 if json {
67 let results: Vec<serde_json::Value> = matches
68 .iter()
69 .map(|(bean, score)| {
70 serde_json::json!({
71 "id": bean.id,
72 "title": bean.title,
73 "type": bean.bean_type,
74 "status": bean.status,
75 "score": score,
76 "close_reason": bean.close_reason,
77 })
78 })
79 .collect();
80 println!("{}", serde_json::to_string_pretty(&results)?);
81 } else {
82 if matches.is_empty() {
83 println!("No matches for \"{}\"", query);
84 return Ok(());
85 }
86
87 println!("Found {} result(s) for \"{}\":\n", matches.len(), query);
88
89 for (bean, _score) in &matches {
90 let type_icon = if bean.bean_type == "fact" {
91 "📌"
92 } else {
93 match bean.status {
94 Status::Closed => "✓",
95 Status::InProgress => "►",
96 Status::Open => "○",
97 }
98 };
99
100 let status_str = match bean.status {
101 Status::Closed => {
102 let reason = bean.close_reason.as_deref().unwrap_or("closed");
103 format!("({})", reason)
104 }
105 _ => format!("({})", bean.status),
106 };
107
108 println!(
109 " {} [{}] {} {}",
110 type_icon, bean.id, bean.title, status_str
111 );
112
113 let failed_attempts: Vec<_> = bean
115 .attempt_log
116 .iter()
117 .filter(|a| a.outcome == crate::bean::AttemptOutcome::Failed)
118 .collect();
119
120 for attempt in &failed_attempts {
121 if let Some(ref notes) = attempt.notes {
122 println!(" ⚠ Attempt #{} failed: {}", attempt.num, notes);
123 }
124 }
125
126 if let Some(ref desc) = bean.description {
128 let preview: String = desc.chars().take(120).collect();
129 let preview = preview.lines().next().unwrap_or("");
130 if !preview.is_empty() {
131 println!(" {}", preview);
132 }
133 }
134 }
135 }
136
137 Ok(())
138}
139
140fn score_match(bean: &Bean, query_lower: &str) -> Option<u32> {
142 let mut score = 0u32;
143
144 if bean.title.to_lowercase().contains(query_lower) {
146 score += 10;
147 }
148
149 if let Some(ref desc) = bean.description {
151 if desc.to_lowercase().contains(query_lower) {
152 score += 5;
153 }
154 }
155
156 if let Some(ref notes) = bean.notes {
158 if notes.to_lowercase().contains(query_lower) {
159 score += 3;
160 }
161 }
162
163 if let Some(ref reason) = bean.close_reason {
165 if reason.to_lowercase().contains(query_lower) {
166 score += 3;
167 }
168 }
169
170 for path in &bean.paths {
172 if path.to_lowercase().contains(query_lower) {
173 score += 4;
174 break;
175 }
176 }
177
178 for label in &bean.labels {
180 if label.to_lowercase().contains(query_lower) {
181 score += 2;
182 break;
183 }
184 }
185
186 for attempt in &bean.attempt_log {
188 if let Some(ref notes) = attempt.notes {
189 if notes.to_lowercase().contains(query_lower) {
190 score += 4;
191 break;
192 }
193 }
194 }
195
196 if score > 0 {
197 Some(score)
198 } else {
199 None
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn make_bean(id: &str, title: &str) -> Bean {
208 Bean::new(id, title)
209 }
210
211 #[test]
212 fn score_match_title() {
213 let bean = make_bean("1", "Auth uses RS256");
214 assert!(score_match(&bean, "rs256").is_some());
215 assert!(score_match(&bean, "auth").is_some());
216 assert!(score_match(&bean, "xyz").is_none());
217 }
218
219 #[test]
220 fn score_match_description() {
221 let mut bean = make_bean("1", "Config");
222 bean.description = Some("Uses YAML format for configuration".to_string());
223 assert!(score_match(&bean, "yaml").is_some());
224 }
225
226 #[test]
227 fn score_match_paths() {
228 let mut bean = make_bean("1", "Config");
229 bean.paths = vec!["src/auth.rs".to_string()];
230 assert!(score_match(&bean, "auth").is_some());
231 }
232
233 #[test]
234 fn score_match_notes() {
235 let mut bean = make_bean("1", "Task");
236 bean.notes = Some("Blocked by database migration".to_string());
237 assert!(score_match(&bean, "migration").is_some());
238 }
239
240 #[test]
241 fn score_match_close_reason() {
242 let mut bean = make_bean("1", "Task");
243 bean.close_reason = Some("Superseded by new approach".to_string());
244 assert!(score_match(&bean, "superseded").is_some());
245 }
246
247 #[test]
248 fn title_scores_higher_than_description() {
249 let mut bean = make_bean("1", "Auth module");
250 bean.description = Some("Auth is important".to_string());
251
252 let score = score_match(&bean, "auth").unwrap();
253 assert_eq!(score, 15);
255 }
256}