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#[derive(Parser)]
10pub struct SearchArgs {
11 pub query: String,
13
14 #[arg(long)]
16 pub limit: Option<usize>,
17
18 #[arg(long)]
20 pub json: bool,
21}
22
23pub 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
86fn 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 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 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 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 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 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 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 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 let out = capture_search_output(args, &root);
248
249 let json: Value = serde_json::from_str(&out).unwrap();
250
251 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 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 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}