Skip to main content

fur_cli/commands/
search.rs

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