Skip to main content

fur_cli/commands/
search.rs

1use clap::Parser;
2use colored::*;
3use serde_json::{json, Value};
4use std::path::Path;
5
6use crate::helpers::search::{list_conversations, parse_queries, search_messages_in_conversation};
7
8/// Arguments for `fur search`
9#[derive(Parser)]
10pub struct SearchArgs {
11    /// Search query (supports comma-separated list)
12    pub query: String,
13
14    /// Maximum matches per conversation (default: unlimited)
15    #[arg(long)]
16    pub limit: Option<usize>,
17
18    /// Output as JSON
19    #[arg(long)]
20    pub json: bool,
21}
22
23/// Entrypoint
24pub fn run_search(args: SearchArgs) {
25    let fur_dir = Path::new(".fur");
26    if !fur_dir.exists() {
27        eprintln!("❌ No .fur/ directory found. Run `fur new` first.");
28        return;
29    }
30
31    let threads_dir = fur_dir.join("threads");
32    let messages_dir = fur_dir.join("messages");
33
34    if !threads_dir.exists() || !messages_dir.exists() {
35        eprintln!("❌ Invalid .fur project structure.");
36        return;
37    }
38
39    let queries = parse_queries(&args.query);
40    if queries.is_empty() {
41        eprintln!("❌ No valid search query provided.");
42        return;
43    }
44
45    let mut output_json: Vec<Value> = Vec::new();
46
47    let threads = list_conversations(&threads_dir);
48    for (tid, convo_json) in threads {
49        let title = convo_json["title"]
50            .as_str()
51            .unwrap_or("Untitled")
52            .to_string();
53        let msg_ids: Vec<String> = convo_json["messages"]
54            .as_array()
55            .unwrap_or(&vec![])
56            .iter()
57            .filter_map(|v| v.as_str().map(|s| s.to_string()))
58            .collect();
59
60        let mut matches = search_messages_in_conversation(&msg_ids, &messages_dir, &queries);
61
62        if let Some(limit) = args.limit {
63            if matches.len() > limit {
64                matches.truncate(limit);
65            }
66        }
67
68        if args.json {
69            if !matches.is_empty() {
70                output_json.push(json!({
71                    "conversation_id": tid,
72                    "title": title,
73                    "matches": matches
74                }));
75            }
76        } else {
77            print_conversation_results(&tid, &title, &matches);
78        }
79    }
80
81    if args.json {
82        println!("{}", serde_json::to_string_pretty(&output_json).unwrap());
83    }
84}
85
86/// Pretty print results for one conversation
87fn print_conversation_results(tid: &str, title: &str, matches: &[Value]) {
88    if matches.is_empty() {
89        return;
90    }
91
92    println!(
93        "\n{} {} ({})",
94        "📘 Conversation:".bright_cyan().bold(),
95        title.bold(),
96        tid[..8].bright_black()
97    );
98    println!("{}", "─".repeat(60).dimmed());
99
100    for m in matches {
101        let mid = m["message_id"].as_str().unwrap_or("-");
102        let avatar = m["avatar"].as_str().unwrap_or("-");
103        let source = m["source"].as_str().unwrap_or("-");
104        let snippet = m["snippet"].as_str().unwrap_or("-");
105
106        println!(
107            "{} {} {}",
108            format!("[{}]", &mid[..8]).bright_yellow(),
109            avatar.bright_green(),
110            format!("({})", source).bright_black()
111        );
112        println!("  • {}\n", snippet);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use serde_json::Value;
120    use std::fs;
121    use std::path::PathBuf;
122    use tempfile::tempdir;
123
124    fn setup_fur_project() -> (tempfile::TempDir, PathBuf) {
125        let dir = tempdir().unwrap();
126        let root = dir.path().to_path_buf();
127
128        // Create .fur structure
129        let fur = root.join(".fur");
130        fs::create_dir_all(fur.join("threads")).unwrap();
131        fs::create_dir_all(fur.join("messages")).unwrap();
132
133        // Create index.json
134        fs::write(
135            fur.join("index.json"),
136            r#"{
137                "threads": ["t1","t2"],
138                "active_thread": "t1",
139                "current_message": null
140            }"#,
141        )
142        .unwrap();
143
144        // Conversation t1
145        fs::write(
146            fur.join("threads/t1.json"),
147            r#"{
148                "id": "t1",
149                "title": "Deep Learning Notes",
150                "created_at": "2024-01-01T00:00:00Z",
151                "messages": ["m1","m2"],
152                "tags": [],
153                "schema_version": "0.2"
154            }"#,
155        )
156        .unwrap();
157
158        // Conversation t2
159        fs::write(
160            fur.join("threads/t2.json"),
161            r#"{
162                "id": "t2",
163                "title": "Physics Notebook",
164                "created_at": "2024-01-01T00:00:00Z",
165                "messages": ["m3"],
166                "tags": ["science"],
167                "schema_version": "0.2"
168            }"#,
169        )
170        .unwrap();
171
172        // Message m1 (plain text hit)
173        fs::write(
174            fur.join("messages/m1.json"),
175            r#"{
176                "id": "m1",
177                "avatar": "me",
178                "timestamp": "2024-01-01T00:00:00Z",
179                "text": "I am studying deep learning today.",
180                "markdown": null,
181                "attachment": null,
182                "parent": null,
183                "children": [],
184                "branches": []
185            }"#,
186        )
187        .unwrap();
188
189        // Message m2 (markdown hit)
190        fs::write(
191            root.join("notes.md"),
192            "Neural networks are universal function approximators.",
193        )
194        .unwrap();
195
196        fs::write(
197            fur.join("messages/m2.json"),
198            format!(
199                r#"{{
200                    "id": "m2",
201                    "avatar": "ai",
202                    "timestamp": "2024-01-01T00:00:00Z",
203                    "text": null,
204                    "markdown": "{}",
205                    "attachment": null,
206                    "parent": null,
207                    "children": [],
208                    "branches": []
209                }}"#,
210                root.join("notes.md").display()
211            ),
212        )
213        .unwrap();
214
215        // Message m3 (no hit)
216        fs::write(
217            fur.join("messages/m3.json"),
218            r#"{
219                "id": "m3",
220                "avatar": "me",
221                "timestamp": "2024-01-01T00:00:00Z",
222                "text": "Quantum mechanics is elegant.",
223                "markdown": null,
224                "attachment": null,
225                "parent": null,
226                "children": [],
227                "branches": []
228            }"#,
229        )
230        .unwrap();
231
232        (dir, root)
233    }
234
235    #[test]
236    fn test_search_simple_text() {
237        let (_tmp, root) = setup_fur_project();
238        std::env::set_current_dir(&root).unwrap();
239
240        let args = SearchArgs {
241            query: "deep learning".to_string(),
242            limit: None,
243            json: true,
244        };
245
246        // Capture output
247        let out = capture_search_output(args, &root);
248
249        let json: Value = serde_json::from_str(&out).unwrap();
250
251        // Should have 1 conversation hit
252        assert_eq!(json.as_array().unwrap().len(), 1);
253
254        let matches = json[0]["matches"].as_array().unwrap();
255        assert_eq!(matches.len(), 1);
256
257        assert_eq!(matches[0]["source"], "text");
258    }
259
260    #[test]
261    fn test_search_markdown() {
262        let (_tmp, root) = setup_fur_project();
263        std::env::set_current_dir(&root).unwrap();
264
265        let args = SearchArgs {
266            query: "universal".to_string(),
267            limit: None,
268            json: true,
269        };
270
271        // Capture output
272        let out = capture_search_output(args, &root);
273        let json: Value = serde_json::from_str(&out).unwrap();
274
275        let matches = json[0]["matches"].as_array().unwrap();
276        assert_eq!(matches.len(), 1);
277
278        assert_eq!(matches[0]["source"], "markdown");
279    }
280
281    fn capture_search_output(args: SearchArgs, root: &Path) -> String {
282        use assert_cmd::Command;
283
284        let mut cmd = Command::cargo_bin("fur").expect("Binary exists");
285
286        // The fixture intentionally uses a minimal historical schema.
287        // Suppress the interactive migration prompt so stdout remains pure JSON.
288        let c = cmd
289            .env("FUR_SKIP_SCHEMA_MIGRATION", "1")
290            .current_dir(root)
291            .arg("search");
292
293        c.arg(&args.query);
294
295        if args.json {
296            c.arg("--json");
297        }
298        if let Some(limit) = args.limit {
299            c.arg("--limit").arg(limit.to_string());
300        }
301
302        let out = c.assert().success().get_output().stdout.clone();
303
304        String::from_utf8(out).unwrap()
305    }
306}