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#[derive(Parser)]
15pub struct SearchArgs {
16 pub query: String,
18
19 #[arg(long)]
21 pub limit: Option<usize>,
22
23 #[arg(long)]
25 pub json: bool,
26}
27
28pub 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
93fn 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 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 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 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 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 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 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 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 let out = capture_search_output(args, &root);
257
258 let json: Value = serde_json::from_str(&out).unwrap();
259
260 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 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 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}