1use sqry_core::graph::unified::persistence::GraphStorage;
8use std::path::{Path, PathBuf};
9
10const MAX_ANCESTOR_DEPTH: usize = 64;
12
13pub const INDEX_FILE_NAME: &str = ".sqry-index";
15
16const PATH_ESCAPE_CHARS: &[char] = &['*', '?', '[', ']', '{', '}', '\\'];
19
20#[derive(Debug, Clone)]
22pub struct IndexLocation {
23 pub index_root: PathBuf,
25
26 pub query_scope: PathBuf,
28
29 pub is_ancestor: bool,
31
32 pub is_file_query: bool,
34
35 pub requires_scope_filter: bool,
44}
45
46impl IndexLocation {
47 #[must_use]
57 pub fn relative_scope(&self) -> Option<PathBuf> {
58 if self.requires_scope_filter {
59 self.query_scope
60 .strip_prefix(&self.index_root)
61 .ok()
62 .map(Path::to_path_buf)
63 } else {
64 None
65 }
66 }
67}
68
69#[must_use]
84pub fn find_nearest_index(start: &Path) -> Option<IndexLocation> {
85 let query_scope = start.to_path_buf();
86
87 let canonical_start = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
90
91 let (mut ancestor_dir, is_file_query) = if canonical_start.is_file() {
94 let parent = canonical_start
95 .parent()
96 .map_or_else(|| canonical_start.clone(), Path::to_path_buf);
97 (parent, true)
98 } else {
99 (canonical_start, false)
100 };
101
102 if ancestor_dir.is_relative()
104 && let Ok(cwd) = std::env::current_dir()
105 {
106 ancestor_dir = cwd.join(&ancestor_dir);
107 }
108
109 for ancestor_depth in 0..MAX_ANCESTOR_DEPTH {
110 let storage = GraphStorage::new(&ancestor_dir);
112 if storage.exists() {
113 let is_ancestor = ancestor_depth > 0;
114 return Some(IndexLocation {
115 index_root: ancestor_dir,
116 query_scope: query_scope.canonicalize().unwrap_or(query_scope),
117 is_ancestor,
118 is_file_query,
119 requires_scope_filter: is_ancestor || is_file_query,
121 });
122 }
123
124 let legacy_index_path = ancestor_dir.join(INDEX_FILE_NAME);
126 if legacy_index_path.exists() && legacy_index_path.is_file() {
127 let is_ancestor = ancestor_depth > 0;
128 return Some(IndexLocation {
129 index_root: ancestor_dir,
130 query_scope: query_scope.canonicalize().unwrap_or(query_scope),
131 is_ancestor,
132 is_file_query,
133 requires_scope_filter: is_ancestor || is_file_query,
134 });
135 }
136
137 if !ancestor_dir.pop() {
139 break;
141 }
142 }
143
144 None
145}
146
147fn escape_path_for_query(path: &Path) -> String {
159 let path_str = path.to_string_lossy();
160 let mut escaped = String::with_capacity(path_str.len() + 20);
161
162 for ch in path_str.chars() {
163 if ch == '\\' && cfg!(windows) {
165 escaped.push('/');
166 continue;
167 }
168 if ch == '\\' {
169 escaped.push_str("\\\\\\\\");
171 } else if PATH_ESCAPE_CHARS.contains(&ch) {
172 escaped.push_str("\\\\");
174 escaped.push(ch);
175 } else {
176 escaped.push(ch);
177 }
178 }
179
180 escaped
181}
182
183fn path_needs_quoting(path: &Path) -> bool {
188 let path_str = path.to_string_lossy();
189 path_str
190 .chars()
191 .any(|c| c == ' ' || c == '"' || PATH_ESCAPE_CHARS.contains(&c))
192}
193
194#[must_use]
211pub fn augment_query_with_scope(query: &str, relative_scope: &Path, is_file_query: bool) -> String {
212 if relative_scope.as_os_str().is_empty() {
214 return query.to_string();
215 }
216
217 let scope_pattern = if path_needs_quoting(relative_scope) {
221 let escaped_path = escape_path_for_query(relative_scope);
223 let quoted = escaped_path.replace('"', "\\\"");
225 if is_file_query {
226 format!("\"{quoted}\"")
227 } else {
228 format!("\"{quoted}/**\"")
229 }
230 } else {
231 let path_str = relative_scope.to_string_lossy();
233 if is_file_query {
234 path_str.into_owned()
235 } else {
236 format!("{path_str}/**")
237 }
238 };
239
240 let path_filter = format!("path:{scope_pattern}");
241
242 if query.trim().is_empty() {
243 path_filter
244 } else {
245 format!("({query}) AND {path_filter}")
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use std::fs;
255 use tempfile::TempDir;
256
257 fn create_test_index(path: &Path) {
259 let index_path = path.join(INDEX_FILE_NAME);
260 fs::write(&index_path, "test-index-marker").unwrap();
261 }
262
263 #[test]
264 fn find_nearest_index_at_current_dir() {
265 let tmp = TempDir::new().unwrap();
266 create_test_index(tmp.path());
267
268 let result = find_nearest_index(tmp.path());
269
270 assert!(result.is_some());
271 let loc = result.unwrap();
272 assert_eq!(loc.index_root, tmp.path().canonicalize().unwrap());
273 assert!(!loc.is_ancestor);
274 assert!(!loc.is_file_query);
275 assert!(!loc.requires_scope_filter);
276 }
277
278 #[test]
279 fn find_nearest_index_in_parent() {
280 let tmp = TempDir::new().unwrap();
281 create_test_index(tmp.path());
282
283 let subdir = tmp.path().join("src");
284 fs::create_dir(&subdir).unwrap();
285
286 let result = find_nearest_index(&subdir);
287
288 assert!(result.is_some());
289 let loc = result.unwrap();
290 assert_eq!(loc.index_root, tmp.path().canonicalize().unwrap());
291 assert!(loc.is_ancestor);
292 assert!(!loc.is_file_query);
293 assert!(loc.requires_scope_filter);
294 }
295
296 #[test]
297 fn find_nearest_index_in_grandparent() {
298 let tmp = TempDir::new().unwrap();
299 create_test_index(tmp.path());
300
301 let deep = tmp.path().join("src").join("utils");
302 fs::create_dir_all(&deep).unwrap();
303
304 let result = find_nearest_index(&deep);
305
306 assert!(result.is_some());
307 let loc = result.unwrap();
308 assert_eq!(loc.index_root, tmp.path().canonicalize().unwrap());
309 assert!(loc.is_ancestor);
310 assert!(loc.requires_scope_filter);
311 }
312
313 #[test]
314 fn find_nearest_index_none_found() {
315 let tmp = TempDir::new().unwrap();
316 let result = find_nearest_index(tmp.path());
319
320 match &result {
325 None => {} Some(loc) => {
327 let tmp_canonical = tmp.path().canonicalize().unwrap();
328 assert!(
329 !loc.index_root.starts_with(&tmp_canonical),
330 "found unexpected index inside temp dir: {:?}",
331 loc.index_root
332 );
333 }
334 }
335 }
336
337 #[test]
338 fn find_nearest_index_nested_repos() {
339 let tmp = TempDir::new().unwrap();
340 create_test_index(tmp.path()); let inner = tmp.path().join("packages").join("web");
343 fs::create_dir_all(&inner).unwrap();
344 create_test_index(&inner); let query_path = inner.join("src");
347 fs::create_dir(&query_path).unwrap();
348
349 let result = find_nearest_index(&query_path);
350
351 assert!(result.is_some());
353 let loc = result.unwrap();
354 assert_eq!(loc.index_root, inner.canonicalize().unwrap());
355 assert!(loc.is_ancestor);
356 }
357
358 #[test]
359 fn find_nearest_index_file_input() {
360 let tmp = TempDir::new().unwrap();
361 create_test_index(tmp.path());
362
363 let subdir = tmp.path().join("src");
364 fs::create_dir(&subdir).unwrap();
365 let file = subdir.join("main.rs");
366 fs::write(&file, "fn main() {}").unwrap();
367
368 let result = find_nearest_index(&file);
369
370 assert!(result.is_some());
371 let loc = result.unwrap();
372 assert!(loc.is_file_query);
373 assert!(loc.is_ancestor); assert!(loc.requires_scope_filter);
375 }
376
377 #[test]
378 fn find_nearest_index_file_in_index_dir() {
379 let tmp = TempDir::new().unwrap();
380 create_test_index(tmp.path());
381
382 let file = tmp.path().join("main.rs");
383 fs::write(&file, "fn main() {}").unwrap();
384
385 let result = find_nearest_index(&file);
386
387 assert!(result.is_some());
388 let loc = result.unwrap();
389 assert!(!loc.is_ancestor); assert!(loc.is_file_query);
391 assert!(loc.requires_scope_filter); }
393
394 #[test]
395 fn relative_scope_calculation() {
396 let loc = IndexLocation {
397 index_root: PathBuf::from("/project"),
398 query_scope: PathBuf::from("/project/src/utils"),
399 is_ancestor: true,
400 is_file_query: false,
401 requires_scope_filter: true,
402 };
403
404 let scope = loc.relative_scope();
405 assert_eq!(scope, Some(PathBuf::from("src/utils")));
406 }
407
408 #[test]
409 fn relative_scope_same_dir() {
410 let loc = IndexLocation {
411 index_root: PathBuf::from("/project"),
412 query_scope: PathBuf::from("/project"),
413 is_ancestor: false,
414 is_file_query: false,
415 requires_scope_filter: false,
416 };
417
418 let scope = loc.relative_scope();
419 assert!(scope.is_none());
420 }
421
422 #[test]
423 fn relative_scope_file_in_root() {
424 let loc = IndexLocation {
425 index_root: PathBuf::from("/project"),
426 query_scope: PathBuf::from("/project/main.rs"),
427 is_ancestor: false,
428 is_file_query: true,
429 requires_scope_filter: true,
430 };
431
432 let scope = loc.relative_scope();
433 assert_eq!(scope, Some(PathBuf::from("main.rs")));
434 }
435
436 #[test]
437 fn augment_query_with_scope_basic() {
438 let result = augment_query_with_scope("kind:function", Path::new("src"), false);
439 assert_eq!(result, "(kind:function) AND path:src/**");
440 }
441
442 #[test]
443 fn augment_query_with_scope_empty_query() {
444 let result = augment_query_with_scope("", Path::new("src"), false);
445 assert_eq!(result, "path:src/**");
446 }
447
448 #[test]
449 fn augment_query_with_scope_empty_path() {
450 let result = augment_query_with_scope("kind:fn", Path::new(""), false);
451 assert_eq!(result, "kind:fn");
452 }
453
454 #[test]
455 fn augment_query_with_scope_file_query() {
456 let result = augment_query_with_scope("kind:function", Path::new("src/main.rs"), true);
457 assert_eq!(result, "(kind:function) AND path:src/main.rs");
458 }
459
460 #[test]
461 fn augment_query_with_scope_directory_query() {
462 let result = augment_query_with_scope("kind:function", Path::new("src"), false);
463 assert_eq!(result, "(kind:function) AND path:src/**");
464 }
465
466 #[test]
467 fn augment_query_file_with_spaces() {
468 let result =
469 augment_query_with_scope("kind:function", Path::new("my project/main.rs"), true);
470 assert_eq!(result, "(kind:function) AND path:\"my project/main.rs\"");
471 }
472
473 #[test]
474 fn augment_query_with_scope_path_with_spaces() {
475 let result = augment_query_with_scope("kind:function", Path::new("my project/src"), false);
476 assert_eq!(result, "(kind:function) AND path:\"my project/src/**\"");
477 }
478
479 #[test]
480 fn augment_query_with_scope_path_with_glob_chars() {
481 let result = augment_query_with_scope("kind:function", Path::new("src/[test]"), false);
484 assert_eq!(result, "(kind:function) AND path:\"src/\\\\[test\\\\]/**\"");
485 }
486
487 #[test]
488 fn augment_query_preserves_precedence() {
489 let result = augment_query_with_scope("kind:fn OR kind:method", Path::new("src"), false);
490 assert_eq!(result, "(kind:fn OR kind:method) AND path:src/**");
491 }
492
493 #[test]
494 fn augment_query_with_existing_path_predicate() {
495 let result =
496 augment_query_with_scope("kind:fn AND path:*.rs", Path::new("src/utils"), false);
497 assert_eq!(result, "(kind:fn AND path:*.rs) AND path:src/utils/**");
498 }
499
500 #[test]
501 #[cfg(unix)]
502 fn escape_path_with_backslash_on_unix() {
503 let result = escape_path_for_query(Path::new("src/file\\name"));
506 assert_eq!(result, "src/file\\\\\\\\name");
507 }
508
509 #[test]
512 fn augmented_queries_are_parseable() {
513 use sqry_core::query::Lexer;
514
515 let test_cases = [
516 ("kind:fn", Path::new("src"), false),
518 ("kind:fn", Path::new("my project/src"), false),
520 ("kind:fn", Path::new("src/[test]"), false),
522 ("kind:fn", Path::new("src/test*"), false),
523 ("kind:fn", Path::new("src/test?"), false),
524 ("kind:fn", Path::new("src/{a,b}"), false),
525 ("kind:fn", Path::new("src/main.rs"), true),
527 ("kind:fn", Path::new("src/[test]/main.rs"), true),
528 ("kind:fn OR kind:method", Path::new("src/[utils]"), false),
530 ];
531
532 for (query, path, is_file) in test_cases {
533 let augmented = augment_query_with_scope(query, path, is_file);
534 let mut lexer = Lexer::new(&augmented);
535 let result = lexer.tokenize();
536 assert!(
537 result.is_ok(),
538 "Failed to parse augmented query for path {:?}: {:?}\nQuery: {}",
539 path,
540 result.err(),
541 augmented
542 );
543 }
544 }
545}