fur_cli/helpers/
search.rs1use std::fs;
2use std::path::{Path, PathBuf};
3use serde_json::{Value, json};
4
5
6pub fn search_messages_in_conversation(
7 msg_ids: &[String],
8 messages_dir: &Path,
9 queries: &[String],
10) -> Vec<Value> {
11 let mut matches = Vec::new();
12
13 for mid in msg_ids {
14 let msg_path = messages_dir.join(format!("{}.json", mid));
15 let content = match fs::read_to_string(&msg_path) {
16 Ok(c) => c,
17 Err(_) => continue,
18 };
19
20 let msg: Value = match serde_json::from_str(&content) {
21 Ok(j) => j,
22 Err(_) => continue,
23 };
24
25 let avatar = msg["avatar"].as_str().unwrap_or("unknown").to_string();
26
27 if let Some(hit) = search_text_field(&msg, mid, &avatar, queries) {
28 matches.push(hit);
29 continue;
30 }
31
32 if let Some(hit) = search_markdown_field(&msg, mid, &avatar, queries) {
33 matches.push(hit);
34 }
35 }
36
37 matches
38}
39
40pub fn search_text_field(
41 msg: &Value,
42 mid: &str,
43 avatar: &str,
44 queries: &[String],
45) -> Option<Value> {
46 let text = msg["text"].as_str()?;
47
48 if let Some((q, snippet)) = match_any_query(text, queries) {
49 return Some(json!({
50 "message_id": mid,
51 "avatar": avatar,
52 "source": "text",
53 "query": q,
54 "snippet": snippet
55 }));
56 }
57
58 None
59}
60
61pub fn search_markdown_field(
62 msg: &Value,
63 mid: &str,
64 avatar: &str,
65 queries: &[String],
66) -> Option<Value> {
67 let md_path_raw = msg["markdown"].as_str()?;
68
69 if let Some((q, snippet)) = search_markdown(md_path_raw, queries) {
70 return Some(json!({
71 "message_id": mid,
72 "avatar": avatar,
73 "source": "markdown",
74 "query": q,
75 "snippet": snippet
76 }));
77 }
78
79 None
80}
81
82
83pub fn parse_queries(q: &str) -> Vec<String> {
89 let lowered = q.trim().to_lowercase();
90
91 if lowered.contains(',') {
93 return lowered
94 .split(',')
95 .map(|s| s.trim().to_string())
96 .filter(|s| !s.is_empty())
97 .collect();
98 }
99
100 vec![lowered]
102}
103
104
105pub fn list_conversations(threads_dir: &Path) -> Vec<(String, Value)> {
107 let mut out = Vec::new();
108
109 for entry in fs::read_dir(threads_dir).unwrap_or_else(|_| panic!("Cannot read threads dir")) {
110 if let Ok(e) = entry {
111 let path = e.path();
112 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
113 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
114 if let Ok(content) = fs::read_to_string(&path) {
115 if let Ok(json) = serde_json::from_str::<Value>(&content) {
116 out.push((stem.to_string(), json));
117 }
118 }
119 }
120 }
121 }
122 }
123
124 out
125}
126
127
128
129pub fn match_any_query(text: &str, queries: &[String]) -> Option<(String, String)> {
131 let lower = text.to_lowercase();
132
133 for q in queries {
134 if let Some(pos) = lower.find(q) {
135 return Some((q.clone(), snippet_around(text, pos, q.len())));
136 }
137 }
138
139 None
140}
141
142pub fn search_markdown(md_path_raw: &str, queries: &[String]) -> Option<(String, String)> {
144 let md_path = PathBuf::from(md_path_raw);
145 let path = if md_path.is_absolute() {
146 md_path
147 } else {
148 PathBuf::from(".").join(md_path_raw)
149 };
150
151 let content = fs::read_to_string(&path).ok()?;
152 match_any_query(&content, queries)
153}
154
155pub fn snippet_around(text: &str, pos: usize, len: usize) -> String {
157 let start = pos.saturating_sub(40);
158 let end = pos + len + 40;
159
160 if let Some(s) = text.get(start..end) {
162 return s.replace('\n', " ");
163 }
164
165 let chars: Vec<char> = text.chars().collect();
167 let total = chars.len();
168
169 let cpos = text[..pos].chars().count(); let clen = text[pos..pos+len].chars().count();
171
172 let cstart = cpos.saturating_sub(40);
173 let cend = (cpos + clen + 40).min(total);
174
175 chars[cstart..cend]
176 .iter()
177 .collect::<String>()
178 .replace('\n', " ")
179}
180
181