Skip to main content

lit/commands/
search.rs

1use crate::core::{find_repo_root, read_head, Object, ObjectHash};
2use crate::response::{SearchMatch, SearchResponse};
3use crate::storage::ObjectStore;
4use std::fs;
5use std::path::Path;
6use walkdir::WalkDir;
7
8pub fn execute(
9    query: String,
10    messages: bool,
11    metadata_filter: Option<String>,
12    max_results: usize,
13) -> Result<SearchResponse, crate::errors::LitError> {
14    let repo_root = find_repo_root()?;
15    let store = ObjectStore::new(&repo_root);
16
17    if messages {
18        return search_commit_messages(&repo_root, &store, &query, max_results);
19    }
20
21    if let Some(filter) = metadata_filter {
22        return search_metadata(&repo_root, &store, &filter, max_results);
23    }
24
25    // Default: search file contents in working tree
26    search_file_contents(&repo_root, &query, max_results)
27}
28
29fn search_file_contents(
30    repo_root: &Path,
31    query: &str,
32    max_results: usize,
33) -> Result<SearchResponse, crate::errors::LitError> {
34    let query_lower = query.to_lowercase();
35    let mut matches = Vec::new();
36
37    for entry in WalkDir::new(repo_root).into_iter().filter_entry(|e| {
38        let name = e.file_name().to_string_lossy();
39        !name.starts_with('.') && name != "target" && name != "node_modules"
40    }) {
41        let entry = entry.map_err(|e| format!("Walk error: {}", e))?;
42        if !entry.file_type().is_file() {
43            continue;
44        }
45        let path = entry.path();
46        if path.starts_with(repo_root.join(".lit")) {
47            continue;
48        }
49
50        // Skip binary files by checking for null bytes in first 512 bytes
51        let content = match fs::read(path) {
52            Ok(c) => c,
53            Err(_) => continue,
54        };
55        if content.iter().take(512).any(|&b| b == 0) {
56            continue;
57        }
58        let text = match String::from_utf8(content) {
59            Ok(t) => t,
60            Err(_) => continue,
61        };
62
63        let rel_path = path
64            .strip_prefix(repo_root)
65            .unwrap_or(path)
66            .to_string_lossy()
67            .replace('\\', "/");
68
69        for (line_num, line) in text.lines().enumerate() {
70            if line.to_lowercase().contains(&query_lower) {
71                matches.push(SearchMatch {
72                    file: rel_path.clone(),
73                    line_number: line_num + 1,
74                    content: line.to_string(),
75                    commit: None,
76                    match_type: "content".to_string(),
77                });
78                if matches.len() >= max_results {
79                    break;
80                }
81            }
82        }
83        if matches.len() >= max_results {
84            break;
85        }
86    }
87
88    let total = matches.len();
89    Ok(SearchResponse {
90        query: query.to_string(),
91        match_type: "content".to_string(),
92        matches,
93        total,
94    })
95}
96
97fn search_commit_messages(
98    repo_root: &Path,
99    store: &ObjectStore,
100    query: &str,
101    max_results: usize,
102) -> Result<SearchResponse, crate::errors::LitError> {
103    let query_lower = query.to_lowercase();
104    let mut matches = Vec::new();
105
106    let head = match read_head(repo_root) {
107        Ok(h) => h,
108        Err(_) => {
109            return Ok(SearchResponse {
110                query: query.to_string(),
111                match_type: "message".to_string(),
112                matches: Vec::new(),
113                total: 0,
114            })
115        }
116    };
117
118    let mut current = Some(head);
119    while let Some(hash) = current {
120        if matches.len() >= max_results {
121            break;
122        }
123        let hash_obj = ObjectHash::from_hex(hash.clone());
124        match store.read(&hash_obj) {
125            Ok(Object::Commit(commit)) => {
126                if commit.message.to_lowercase().contains(&query_lower) {
127                    for (i, line) in commit.message.lines().enumerate() {
128                        if line.to_lowercase().contains(&query_lower) {
129                            matches.push(SearchMatch {
130                                file: String::new(),
131                                line_number: i + 1,
132                                content: line.to_string(),
133                                commit: Some(hash.clone()),
134                                match_type: "message".to_string(),
135                            });
136                        }
137                    }
138                }
139                current = commit.parents.first().map(|p| p.to_string());
140            }
141            _ => break,
142        }
143    }
144
145    let total = matches.len();
146    Ok(SearchResponse {
147        query: query.to_string(),
148        match_type: "message".to_string(),
149        matches,
150        total,
151    })
152}
153
154fn search_metadata(
155    repo_root: &Path,
156    store: &ObjectStore,
157    filter: &str,
158    max_results: usize,
159) -> Result<SearchResponse, crate::errors::LitError> {
160    // Parse key=value filter
161    let (key, value) = filter
162        .split_once('=')
163        .ok_or("Metadata filter must be in key=value format")?;
164
165    let mut matches = Vec::new();
166
167    let head = match read_head(repo_root) {
168        Ok(h) => h,
169        Err(_) => {
170            return Ok(SearchResponse {
171                query: filter.to_string(),
172                match_type: "metadata".to_string(),
173                matches: Vec::new(),
174                total: 0,
175            })
176        }
177    };
178
179    let mut current = Some(head);
180    while let Some(hash) = current {
181        if matches.len() >= max_results {
182            break;
183        }
184        let hash_obj = ObjectHash::from_hex(hash.clone());
185        match store.read(&hash_obj) {
186            Ok(Object::Commit(commit)) => {
187                if let Some(meta) = &commit.metadata {
188                    if let Some(meta_value) = meta.get(key) {
189                        let meta_str = match meta_value {
190                            serde_json::Value::String(s) => s.clone(),
191                            other => other.to_string(),
192                        };
193                        if meta_str.to_lowercase() == value.to_lowercase() {
194                            matches.push(SearchMatch {
195                                file: String::new(),
196                                line_number: 0,
197                                content: format!("{}: {}", commit.message, meta),
198                                commit: Some(hash.clone()),
199                                match_type: "metadata".to_string(),
200                            });
201                        }
202                    }
203                }
204                current = commit.parents.first().map(|p| p.to_string());
205            }
206            _ => break,
207        }
208    }
209
210    let total = matches.len();
211    Ok(SearchResponse {
212        query: filter.to_string(),
213        match_type: "metadata".to_string(),
214        matches,
215        total,
216    })
217}