Skip to main content

mana_core/ops/
recall.rs

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/// A matched unit with its relevance score.
10#[derive(Debug)]
11pub struct RecallMatch {
12    pub unit: Unit,
13    pub score: u32,
14}
15
16/// Search units by substring matching.
17///
18/// Searches title, description, notes, close_reason, paths, labels, and
19/// attempt notes. Returns matching units sorted by score (descending)
20/// then recency (descending).
21///
22/// When `all` is true, also searches archived units.
23pub 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
77/// Score how well a unit matches a query. Returns None if no match.
78fn 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); // Title (10) + Description (5)
169    }
170}