Skip to main content

bn/commands/
recall.rs

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
9/// Search beans by substring matching (MVP — no embeddings).
10///
11/// Searches title, description, notes, close_reason, and paths.
12/// Returns matching beans sorted by relevance (title match first, then recency).
13pub 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(); // (bean, score)
18
19    // Search active beans
20    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    // Search archived beans too
41    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    // Sort by score (descending), then by recency (descending)
61    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            // Show failed attempts as negative memory
114            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            // Show description preview
127            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
140/// Score how well a bean matches a query. Returns None if no match.
141fn score_match(bean: &Bean, query_lower: &str) -> Option<u32> {
142    let mut score = 0u32;
143
144    // Title match (highest weight)
145    if bean.title.to_lowercase().contains(query_lower) {
146        score += 10;
147    }
148
149    // Description match
150    if let Some(ref desc) = bean.description {
151        if desc.to_lowercase().contains(query_lower) {
152            score += 5;
153        }
154    }
155
156    // Notes match
157    if let Some(ref notes) = bean.notes {
158        if notes.to_lowercase().contains(query_lower) {
159            score += 3;
160        }
161    }
162
163    // Close reason match
164    if let Some(ref reason) = bean.close_reason {
165        if reason.to_lowercase().contains(query_lower) {
166            score += 3;
167        }
168    }
169
170    // Path match
171    for path in &bean.paths {
172        if path.to_lowercase().contains(query_lower) {
173            score += 4;
174            break;
175        }
176    }
177
178    // Labels match
179    for label in &bean.labels {
180        if label.to_lowercase().contains(query_lower) {
181            score += 2;
182            break;
183        }
184    }
185
186    // Attempt notes match (negative memory search)
187    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        // Title (10) + Description (5) = 15
254        assert_eq!(score, 15);
255    }
256}