Skip to main content

fur_cli/helpers/
search.rs

1use 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
83/// Parse a search string into individual queries.
84/// Supports:
85///   "deep learning"
86///   "deep learning, neural models"
87///   "deep, learning"
88pub fn parse_queries(q: &str) -> Vec<String> {
89    let lowered = q.trim().to_lowercase();
90
91    // Case 1: contains commas → split
92    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    // Case 2: no comma → single query
101    vec![lowered]
102}
103
104
105/// Load all conversation JSONs in threads/
106pub 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
129/// Search plain text for any query. Returns (query, snippet)
130pub 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
142/// Search markdown content safely; returns Some((query, snippet)) or None
143pub 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
155/// Extract a snippet of ±40 characters around the match
156pub 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    // Try a cheap, fast byte slice first (safe)
161    if let Some(s) = text.get(start..end) {
162        return s.replace('\n', " ");
163    }
164
165    // If that failed, fall back to UTF-8 safe slicing
166    let chars: Vec<char> = text.chars().collect();
167    let total = chars.len();
168
169    let cpos = text[..pos].chars().count(); // convert byte offset → char index
170    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