1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::analyzer::Analyzer;
6use crate::api::*;
7use crate::language::LanguageRegistry;
8use crate::lsp::TsJsConfig;
9use crate::lsp::{
10 GoplsConfig, JdtlsConfig, LspAnalyzer, LspServerConfig, PyrightConfig, RustAnalyzerConfig,
11};
12use crate::patch;
13use crate::state::PropagationState;
14use crate::treesitter::TreeSitterAnalyzer;
15use globset::{Glob, GlobSet, GlobSetBuilder};
16use ignore::{DirEntry, WalkBuilder};
17use regex::{Regex, RegexBuilder};
18use std::sync::Mutex;
19
20const DEFAULT_SEARCH_LIMIT: usize = 100;
21const MAX_SEARCH_LIMIT: usize = 1000;
22const DEFAULT_REVIEW_LIMIT: usize = 1;
23const MAX_REVIEW_LIMIT: usize = 100;
24const MAX_LSP_DID_OPEN_FILES: usize = 500;
25const READ_AROUND_CONTEXT_LINES: usize = 12;
26
27fn lsp_config_for_language(lsp_lang: &str) -> Option<Box<dyn LspServerConfig>> {
28 match lsp_lang {
29 "rust" => Some(Box::new(RustAnalyzerConfig)),
30 "python" => Some(Box::new(PyrightConfig)),
31 "typescript" | "javascript" => Some(Box::new(TsJsConfig)),
32 "go" => Some(Box::new(GoplsConfig)),
33 "java" => Some(Box::new(JdtlsConfig)),
34 _ => None,
35 }
36}
37
38fn lsp_extensions_for_language(lsp_lang: &str) -> &'static [&'static str] {
39 match lsp_lang {
40 "rust" => &["rs"],
41 "python" => &["py"],
42 "typescript" | "javascript" => &["ts", "tsx", "js", "jsx"],
43 "go" => &["go"],
44 "java" => &["java"],
45 _ => &[],
46 }
47}
48
49fn lsp_language_for_extension(ext: &str) -> Option<&'static str> {
50 match ext {
51 "rs" => Some("rust"),
52 "py" => Some("python"),
53 "ts" | "tsx" => Some("typescript"),
54 "js" | "jsx" => Some("javascript"),
55 "go" => Some("go"),
56 "java" => Some("java"),
57 _ => None,
58 }
59}
60
61fn detect_project_lsp_language(root: &Path) -> Option<&'static str> {
62 if root.join("Cargo.toml").is_file() {
63 return Some("rust");
64 }
65 if root.join("pyproject.toml").is_file()
66 || root.join("requirements.txt").is_file()
67 || root.join("setup.py").is_file()
68 {
69 return Some("python");
70 }
71 if root.join("go.mod").is_file() {
72 return Some("go");
73 }
74 if root.join("pom.xml").is_file()
75 || root.join("build.gradle").is_file()
76 || root.join("build.gradle.kts").is_file()
77 {
78 return Some("java");
79 }
80 if root.join("tsconfig.json").is_file() {
81 return Some("typescript");
82 }
83 if root.join("package.json").is_file() {
84 return Some("typescript");
85 }
86
87 let mut counts: HashMap<&'static str, usize> = HashMap::new();
88 for entry in WalkBuilder::new(root)
89 .hidden(false)
90 .git_ignore(true)
91 .ignore(true)
92 .parents(true)
93 .build()
94 .filter_map(Result::ok)
95 .filter(|entry| {
96 entry
97 .file_type()
98 .is_some_and(|file_type| file_type.is_file())
99 })
100 .take(MAX_LSP_DID_OPEN_FILES)
101 {
102 let Some(ext) = entry.path().extension().and_then(|ext| ext.to_str()) else {
103 continue;
104 };
105 if let Some(language) = lsp_language_for_extension(ext) {
106 *counts.entry(language).or_default() += 1;
107 }
108 }
109
110 ["rust", "typescript", "javascript", "python", "go", "java"]
111 .into_iter()
112 .max_by_key(|language| counts.get(language).copied().unwrap_or(0))
113 .filter(|language| counts.get(language).copied().unwrap_or(0) > 0)
114}
115
116fn open_existing_source_files_for_lsp(lsp: &dyn Analyzer, root: &Path, lsp_lang: &str) {
117 let exts = lsp_extensions_for_language(lsp_lang);
118 if exts.is_empty() {
119 return;
120 }
121 let scan_root = {
122 let src_dir = root.join("src");
123 if src_dir.exists() {
124 src_dir
125 } else {
126 root.to_path_buf()
127 }
128 };
129 for entry in WalkBuilder::new(scan_root)
130 .hidden(false)
131 .git_ignore(true)
132 .ignore(true)
133 .parents(true)
134 .build()
135 .filter_map(Result::ok)
136 .filter(|entry| {
137 entry
138 .file_type()
139 .is_some_and(|file_type| file_type.is_file())
140 })
141 .take(MAX_LSP_DID_OPEN_FILES)
142 {
143 let path = entry.path();
144 if let Some(ext) = path.extension().and_then(|ext| ext.to_str())
145 && exts.contains(&ext)
146 && let Ok(content) = std::fs::read_to_string(path)
147 {
148 lsp.notify_did_open(path, &content);
149 }
150 }
151}
152
153pub fn open_project(
154 project_root: &Path,
155 current_project_root: Option<&Path>,
156 lsp_analyzer: &Mutex<Option<Box<dyn Analyzer + Send>>>,
157) -> Result<OpenProjectResponse, String> {
158 if current_project_root == Some(project_root) {
159 return Ok(OpenProjectResponse {
160 status: "already_open".to_string(),
161 project_root: project_root.to_string_lossy().into_owned(),
162 detected_lsp_language: None,
163 lsp: None,
164 });
165 }
166
167 let detected_lsp_language = detect_project_lsp_language(project_root);
168 let Some(config) = detected_lsp_language.and_then(lsp_config_for_language) else {
169 let mut lsp_guard = lsp_analyzer
170 .lock()
171 .map_err(|_| "lock poisoned".to_string())?;
172 *lsp_guard = None;
173 return Ok(OpenProjectResponse {
174 status: "opened".to_string(),
175 project_root: project_root.to_string_lossy().into_owned(),
176 detected_lsp_language: detected_lsp_language.map(str::to_string),
177 lsp: Some("unsupported".to_string()),
178 });
179 };
180
181 {
182 let mut lsp_guard = lsp_analyzer
183 .lock()
184 .map_err(|_| "lock poisoned".to_string())?;
185 *lsp_guard = None;
186 let new_lsp = LspAnalyzer::new(project_root, config.as_ref());
187 *lsp_guard = Some(Box::new(new_lsp));
188 }
189
190 {
191 let lsp_guard = lsp_analyzer
192 .lock()
193 .map_err(|_| "lock poisoned".to_string())?;
194 if let (Some(lsp), Some(lsp_lang)) = (&*lsp_guard, detected_lsp_language) {
195 open_existing_source_files_for_lsp(lsp.as_ref(), project_root, lsp_lang);
196 }
197 }
198
199 Ok(OpenProjectResponse {
200 status: "opened".to_string(),
201 project_root: project_root.to_string_lossy().into_owned(),
202 detected_lsp_language: detected_lsp_language.map(str::to_string),
203 lsp: None,
204 })
205}
206
207pub fn search_code(
208 project_root: &Path,
209 params: &SearchCodeRequest,
210) -> Result<SearchCodeResponse, String> {
211 if params.query.is_empty() {
212 return Err("query is required".to_string());
213 }
214
215 let limit = normalize_search_limit(params.limit);
216 let target = project_relative_arg(project_root, params.path.as_deref())?;
217 let matches = search_project_matches(project_root, params, target.as_deref(), limit)?;
218 Ok(SearchCodeResponse { matches })
219}
220
221pub fn is_responsible_source(
222 project_root: &Path,
223 params: &IsResponsibleSourceRequest,
224) -> Result<IsResponsibleSourceResponse, String> {
225 let relative = project_relative_arg(project_root, Some(¶ms.path))?
226 .ok_or_else(|| "path is required".to_string())?;
227 let path = project_root.join(&relative);
228 let extension = path
229 .extension()
230 .and_then(|ext| ext.to_str())
231 .map(str::to_string);
232 let analyzer = TreeSitterAnalyzer::new();
233 let language = extension
234 .as_deref()
235 .and_then(|ext| analyzer.responsible_language_for_extension(ext))
236 .map(str::to_string);
237 let is_responsible = language.is_some();
238 let reason = match (&extension, &language) {
239 (Some(extension), Some(language)) => {
240 format!("SCOPE recognizes .{extension} as {language} source")
241 }
242 (Some(extension), None) => {
243 format!("SCOPE has no source adapter for .{extension}")
244 }
245 (None, _) => "path has no file extension for SCOPE source ownership".to_string(),
246 };
247
248 Ok(IsResponsibleSourceResponse {
249 is_responsible,
250 path: relative,
251 extension,
252 language,
253 reason,
254 })
255}
256
257fn normalize_search_limit(limit: Option<usize>) -> usize {
258 limit
259 .unwrap_or(DEFAULT_SEARCH_LIMIT)
260 .clamp(1, MAX_SEARCH_LIMIT)
261}
262
263fn search_project_matches(
264 project_root: &Path,
265 params: &SearchCodeRequest,
266 target: Option<&str>,
267 limit: usize,
268) -> Result<Vec<SearchHit>, String> {
269 let regex = build_search_regex(params)?;
270 let filters = SearchFileFilters::from_request(params)?;
271 let mut matches = Vec::new();
272
273 for entry in project_files(project_root, target, &filters)? {
274 let path = entry.path();
275 let relative = relative_file_path(project_root, path);
276 if !filters.matches(&relative, path) {
277 continue;
278 }
279
280 let Ok(content) = fs::read_to_string(path) else {
281 continue;
282 };
283 for (line_index, text) in content.lines().enumerate() {
284 if !regex.is_match(text) {
285 continue;
286 }
287 let line = line_index + 1;
288 matches.push(SearchHit {
289 path: relative.clone(),
290 hit: format_line_with_hash(line, text),
291 });
292 if matches.len() >= limit {
293 return Ok(matches);
294 }
295 }
296 }
297
298 Ok(matches)
299}
300
301fn build_search_regex(params: &SearchCodeRequest) -> Result<Regex, String> {
302 let mut pattern = match params.mode {
303 SearchMode::Literal => regex::escape(¶ms.query),
304 SearchMode::Regex => params.query.clone(),
305 };
306 if params.line {
307 pattern = format!("^(?:{pattern})$");
308 } else if params.word {
309 pattern = format!(r"\b(?:{pattern})\b");
310 }
311
312 let case_insensitive = match params.case_mode {
313 SearchCase::Sensitive => false,
314 SearchCase::Insensitive => true,
315 SearchCase::Smart => !params.query.chars().any(char::is_uppercase),
316 };
317
318 RegexBuilder::new(&pattern)
319 .case_insensitive(case_insensitive)
320 .build()
321 .map_err(|err| match params.mode {
322 SearchMode::Literal => format!("search pattern error: {err}"),
323 SearchMode::Regex => {
324 format!("search regex error: {err}; use mode=\"literal\" for code fragments")
325 }
326 })
327}
328
329struct SearchFileFilters {
330 include: Option<GlobSet>,
331 exclude: Option<GlobSet>,
332 type_include_exts: Option<HashSet<String>>,
333 type_exclude_exts: HashSet<String>,
334 hidden: bool,
335 respect_ignore: bool,
336 follow: bool,
337}
338
339impl SearchFileFilters {
340 fn from_request(params: &SearchCodeRequest) -> Result<Self, String> {
341 Ok(Self {
342 include: build_optional_glob_set(¶ms.include)?,
343 exclude: build_optional_glob_set(¶ms.exclude)?,
344 type_include_exts: build_optional_type_exts(¶ms.types)?,
345 type_exclude_exts: build_optional_type_exts(¶ms.type_not)?.unwrap_or_default(),
346 hidden: params.hidden,
347 respect_ignore: params.respect_ignore,
348 follow: params.follow,
349 })
350 }
351
352 fn matches(&self, relative: &str, path: &Path) -> bool {
353 if let Some(include) = self.include.as_ref()
354 && !include.is_match(relative)
355 {
356 return false;
357 }
358 if let Some(exclude) = self.exclude.as_ref()
359 && exclude.is_match(relative)
360 {
361 return false;
362 }
363 let ext = path
364 .extension()
365 .and_then(|ext| ext.to_str())
366 .map(|ext| ext.to_ascii_lowercase());
367 if let Some(type_include_exts) = self.type_include_exts.as_ref()
368 && !ext
369 .as_ref()
370 .is_some_and(|ext| type_include_exts.contains(ext))
371 {
372 return false;
373 }
374 if ext
375 .as_ref()
376 .is_some_and(|ext| self.type_exclude_exts.contains(ext))
377 {
378 return false;
379 }
380 true
381 }
382}
383
384fn project_files(
385 project_root: &Path,
386 target: Option<&str>,
387 filters: &SearchFileFilters,
388) -> Result<Vec<DirEntry>, String> {
389 let root = project_root.to_path_buf();
390 let walk_root = match target {
391 Some(target) => root.join(target),
392 None => root.clone(),
393 };
394
395 let mut entries = Vec::new();
396 let mut builder = WalkBuilder::new(walk_root);
397 builder
398 .hidden(!filters.hidden)
399 .follow_links(filters.follow)
400 .require_git(true);
401 if !filters.respect_ignore {
402 builder
403 .git_ignore(false)
404 .git_global(false)
405 .git_exclude(false)
406 .ignore(false)
407 .parents(false);
408 }
409
410 for entry in builder.build() {
411 let entry = entry.map_err(|e| format!("failed to walk project files: {e}"))?;
412 if !entry
413 .file_type()
414 .is_some_and(|file_type| file_type.is_file())
415 {
416 continue;
417 }
418 entries.push(entry);
419 }
420
421 entries.sort_by(|left, right| {
422 relative_file_path(&root, left.path()).cmp(&relative_file_path(&root, right.path()))
423 });
424 Ok(entries)
425}
426
427fn build_optional_glob_set(patterns: &[String]) -> Result<Option<GlobSet>, String> {
428 let patterns = patterns
429 .iter()
430 .map(|pattern| pattern.trim())
431 .filter(|pattern| !pattern.is_empty())
432 .collect::<Vec<_>>();
433 if patterns.is_empty() {
434 return Ok(None);
435 }
436 build_glob_set(&patterns).map(Some)
437}
438
439fn build_glob_set(patterns: &[&str]) -> Result<GlobSet, String> {
440 let mut builder = GlobSetBuilder::new();
441 for pattern in patterns {
442 builder.add(Glob::new(pattern).map_err(|e| format!("glob error: {e}"))?);
443 if !pattern.contains('/') && !pattern.contains('\\') {
444 builder
445 .add(Glob::new(&format!("**/{pattern}")).map_err(|e| format!("glob error: {e}"))?);
446 }
447 }
448 builder.build().map_err(|e| format!("glob error: {e}"))
449}
450
451fn build_optional_type_exts(types: &[String]) -> Result<Option<HashSet<String>>, String> {
452 let requested = types
453 .iter()
454 .map(|type_name| {
455 type_name
456 .trim()
457 .trim_start_matches('.')
458 .to_ascii_lowercase()
459 })
460 .filter(|type_name| !type_name.is_empty())
461 .collect::<Vec<_>>();
462 if requested.is_empty() {
463 return Ok(None);
464 }
465
466 let registry = LanguageRegistry::new();
467 let languages = registry.list_languages();
468 let supported = languages
469 .iter()
470 .map(|(name, _)| *name)
471 .collect::<Vec<_>>()
472 .join(", ");
473 let mut exts = HashSet::new();
474 for requested_type in requested {
475 if let Some((_, language_exts)) = languages
476 .iter()
477 .find(|(name, _)| name.eq_ignore_ascii_case(&requested_type))
478 {
479 exts.extend(language_exts.iter().map(|ext| ext.to_ascii_lowercase()));
480 continue;
481 }
482 if registry.get(&requested_type).is_some() {
483 exts.insert(requested_type);
484 continue;
485 }
486 return Err(format!(
487 "unknown search type `{requested_type}`; supported SCOPE types: {supported}"
488 ));
489 }
490 Ok(Some(exts))
491}
492
493fn relative_file_path(project_root: &Path, path: &Path) -> String {
494 path.strip_prefix(project_root)
495 .ok()
496 .map(|path| normalize_relative_path(&path.to_string_lossy()))
497 .unwrap_or_else(|| normalize_relative_path(&path.to_string_lossy()))
498}
499
500fn project_relative_arg(root: &Path, path: Option<&str>) -> Result<Option<String>, String> {
501 let Some(path) = path.map(str::trim).filter(|path| !path.is_empty()) else {
502 return Ok(None);
503 };
504 let path = PathBuf::from(path);
505 if path.is_absolute() {
506 let relative = path.strip_prefix(root).map_err(|_| {
507 format!(
508 "path {} is outside project root {}",
509 path.display(),
510 root.display()
511 )
512 })?;
513 Ok(Some(normalize_relative_path(&relative.to_string_lossy())))
514 } else {
515 Ok(Some(normalize_relative_path(&path.to_string_lossy())))
516 }
517}
518
519fn normalize_relative_path(path: &str) -> String {
520 path.replace('\\', "/")
521}
522
523fn clamp_range(
524 start_line: usize,
525 end_line: usize,
526 line_count: usize,
527) -> Result<(usize, usize), String> {
528 if start_line == 0 || end_line == 0 || start_line > end_line {
529 return Err(format!("invalid line range {start_line}-{end_line}"));
530 }
531 if start_line > line_count {
532 return Err(format!(
533 "line range starts after end of file: {start_line} > {line_count}"
534 ));
535 }
536 Ok((start_line, end_line.min(line_count)))
537}
538
539fn read_line_range(content: &str, start_line: usize, end_line: usize) -> String {
540 let lines = content.lines().collect::<Vec<_>>();
541 if start_line == 0 || end_line < start_line || start_line > lines.len() {
542 return String::new();
543 }
544 let mut snippet = lines[(start_line - 1)..end_line.min(lines.len())].join("\n");
545 if content.ends_with('\n') || end_line < lines.len() {
546 snippet.push('\n');
547 }
548 snippet
549}
550
551pub fn read_code(
552 project_root: &Path,
553 params: &ReadCodeRequest,
554) -> Result<ReadCodeResponse, String> {
555 let relative = project_relative_arg(project_root, Some(¶ms.path))?
556 .ok_or_else(|| "path is required".to_string())?;
557 let full_path = project_root.join(&relative);
558 let file_content = fs::read_to_string(&full_path)
559 .map_err(|e| format!("Failed to read {}: {e}", full_path.display()))?;
560 let (anchor_line, anchor_hash) = parse_line_anchor(¶ms.anchor)?;
561 verify_anchor_line(&file_content, anchor_line, &anchor_hash)?;
562 let line_count = file_content.lines().count().max(1);
563 let (start_line, end_line) =
564 read_range_for_mode(params.mode, &full_path, anchor_line, line_count)?;
565 let raw_content = read_line_range(&file_content, start_line, end_line);
566 let prefixed_content = prefix_lines_with_hash(&raw_content, start_line);
567
568 Ok(ReadCodeResponse {
569 content: prefixed_content,
570 })
571}
572
573fn read_range_for_mode(
574 mode: ReadCodeMode,
575 full_path: &Path,
576 anchor_line: usize,
577 line_count: usize,
578) -> Result<(usize, usize), String> {
579 match mode {
580 ReadCodeMode::Around => {
581 let start_line = anchor_line.saturating_sub(READ_AROUND_CONTEXT_LINES).max(1);
582 let end_line = (anchor_line + READ_AROUND_CONTEXT_LINES).min(line_count);
583 clamp_range(start_line, end_line, line_count)
584 }
585 ReadCodeMode::Full => {
586 let analyzer = TreeSitterAnalyzer::new();
587 if let Some(symbol) = analyzer.find_containing_symbol_match(full_path, anchor_line) {
588 return clamp_range(symbol.start_line, symbol.end_line, line_count);
589 }
590 read_range_for_mode(ReadCodeMode::Around, full_path, anchor_line, line_count)
591 }
592 }
593}
594
595fn parse_line_anchor(anchor: &str) -> Result<(usize, String), String> {
596 let (line_str, hash_str) = anchor
597 .split_once('#')
598 .ok_or_else(|| format!("invalid anchor (expected line#hash): {anchor}"))?;
599 let line = line_str
600 .parse::<usize>()
601 .map_err(|_| format!("invalid line number in anchor: {anchor}"))?;
602 if line == 0 {
603 return Err(format!("line number must be >= 1 in anchor: {anchor}"));
604 }
605 if hash_str.is_empty() {
606 return Err(format!("missing hash in anchor: {anchor}"));
607 }
608 Ok((line, hash_str.to_string()))
609}
610
611fn verify_anchor_line(content: &str, line_num: usize, expected_hash: &str) -> Result<(), String> {
612 let lines: Vec<&str> = content.lines().collect();
613 if line_num > lines.len() {
614 return Err(format!(
615 "line {line_num} out of bounds (file has {} lines); search or read again",
616 lines.len()
617 ));
618 }
619 let actual = lines[line_num - 1];
620 let actual_hash = patch::line_hash(actual);
621 if actual_hash != expected_hash {
622 return Err(format!(
623 "line {line_num} hash mismatch: expected {expected_hash}, got {actual_hash} — file may have changed; search or read again"
624 ));
625 }
626 Ok(())
627}
628
629fn format_line_with_hash(line_num: usize, line: &str) -> String {
630 let hash = patch::line_hash(line);
631 format!("{line_num}#{hash}|{line}")
632}
633
634fn prefix_lines_with_hash(content: &str, start_line: usize) -> String {
635 content
636 .lines()
637 .enumerate()
638 .map(|(i, line)| format_line_with_hash(start_line + i, line))
639 .collect::<Vec<_>>()
640 .join("\n")
641 + if content.ends_with('\n') || content.is_empty() {
642 "\n"
643 } else {
644 ""
645 }
646}
647
648pub fn edit_code(
649 project_root: &Path,
650 params: &EditCodeRequest,
651 propagation_state: &Mutex<PropagationState>,
652 lsp_analyzer: &Mutex<Option<Box<dyn Analyzer + Send>>>,
653) -> Result<PropagationResponse, String> {
654 match patch::edit_code_apply(¶ms.edits, project_root, lsp_analyzer) {
655 Ok((results, applied_summary)) => {
656 if !results.is_empty()
657 && let Ok(mut state) = propagation_state.lock()
658 {
659 state.accumulate(results.clone());
660 }
661 Ok(PropagationResponse {
662 propagation_results: results,
663 applied_summary,
664 })
665 }
666 Err(e) => Err(e),
667 }
668}
669
670pub fn config_hints() -> serde_json::Value {
671 use crate::language::LanguageRegistry;
672 use crate::lsp::{
673 GoplsConfig, JdtlsConfig, LspServerConfig, PyrightConfig, RustAnalyzerConfig, TsJsConfig,
674 };
675
676 let registry = LanguageRegistry::new();
677 let configs: Vec<Box<dyn LspServerConfig>> = vec![
678 Box::new(RustAnalyzerConfig),
679 Box::new(PyrightConfig),
680 Box::new(TsJsConfig), Box::new(GoplsConfig),
682 Box::new(JdtlsConfig),
683 ];
684
685 let mut languages = Vec::new();
686
687 let ts_langs: Vec<serde_json::Value> = registry
689 .list_languages()
690 .into_iter()
691 .map(|(name, exts)| {
692 serde_json::json!({
693 "name": name,
694 "extensions": exts,
695 })
696 })
697 .collect();
698
699 for cfg in &configs {
701 let binary_found = std::process::Command::new("which")
702 .arg(cfg.binary_name())
703 .output()
704 .map(|o| o.status.success())
705 .unwrap_or(false);
706
707 let mut lang_entry = serde_json::json!({
708 "language": cfg.language_id(),
709 "lsp_server": cfg.server_name(),
710 "lsp_binary": cfg.binary_name(),
711 "lsp_available": binary_found,
712 "setup_hints": cfg.setup_hints(),
713 });
714
715 if let Some((cmd, args)) = cfg.install_command() {
716 lang_entry["install_command"] = serde_json::json!({
717 "command": cmd,
718 "args": args,
719 });
720 }
721
722 if let Some(url) = cfg.download_url() {
723 lang_entry["download_url"] = serde_json::json!(url);
724 }
725
726 languages.push(lang_entry);
727 }
728
729 serde_json::json!({
730 "tree_sitter_languages": ts_langs,
731 "lsp_languages": languages,
732 })
733}
734
735pub fn ack_next_events(
736 propagation_state: &Mutex<PropagationState>,
737 limit: Option<usize>,
738) -> Result<NextReviewResponse, String> {
739 let limit = limit
740 .unwrap_or(DEFAULT_REVIEW_LIMIT)
741 .clamp(1, MAX_REVIEW_LIMIT);
742 let mut state = propagation_state
743 .lock()
744 .map_err(|_| "lock poisoned".to_string())?;
745 let reviews = state.next_reviews(limit);
746 let review = reviews.first().cloned();
747 let returned = reviews.len();
748 let remaining = state.pending_count();
749 Ok(NextReviewResponse {
750 review,
751 reviews,
752 returned,
753 remaining,
754 })
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use crate::state::PropagationState;
761 use std::sync::Mutex;
762
763 #[test]
764 fn open_project_detects_lsp_language_from_project_files() {
765 let dir = tempfile::tempdir().unwrap();
766 std::fs::write(
767 dir.path().join("Cargo.toml"),
768 "[package]\nname = \"tmp\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
769 )
770 .unwrap();
771 let lsp_analyzer: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
772
773 let response = open_project(dir.path(), None, &lsp_analyzer).unwrap();
774
775 assert_eq!(response.detected_lsp_language.as_deref(), Some("rust"));
776 }
777
778 #[test]
779 fn open_project_is_idempotent_for_current_project_root() {
780 let dir = tempfile::tempdir().unwrap();
781 let lsp_analyzer: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
782
783 let response = open_project(dir.path(), Some(dir.path()), &lsp_analyzer).unwrap();
784
785 assert_eq!(response.status, "already_open");
786 assert_eq!(response.detected_lsp_language, None);
787 }
788
789 #[test]
790 fn search_code_returns_matched_line_hits() {
791 let dir = tempfile::tempdir().unwrap();
792 std::fs::create_dir_all(dir.path().join("src/nested")).unwrap();
793 std::fs::create_dir_all(dir.path().join("tests")).unwrap();
794 std::fs::write(
795 dir.path().join("src/lib.rs"),
796 "pub fn lib() { let needle = true; }\n",
797 )
798 .unwrap();
799 std::fs::write(
800 dir.path().join("src/nested/mod.rs"),
801 "pub fn nested() { let needle = true; }\n",
802 )
803 .unwrap();
804 std::fs::write(
805 dir.path().join("tests/lib_test.rs"),
806 "pub fn test() { let needle = true; }\n",
807 )
808 .unwrap();
809
810 let response = search_code(
811 dir.path(),
812 &SearchCodeRequest {
813 query: "needle".to_string(),
814 path: Some("src".to_string()),
815 include: vec!["*.rs".to_string()],
816 ..SearchCodeRequest::default()
817 },
818 )
819 .unwrap();
820
821 let hits = response
822 .matches
823 .iter()
824 .map(|item| (item.path.clone(), item.hit.clone()))
825 .collect::<Vec<_>>();
826 assert_eq!(
827 hits,
828 vec![
829 (
830 "src/lib.rs".to_string(),
831 format_line_with_hash(1, "pub fn lib() { let needle = true; }")
832 ),
833 (
834 "src/nested/mod.rs".to_string(),
835 format_line_with_hash(1, "pub fn nested() { let needle = true; }")
836 )
837 ]
838 );
839 }
840
841 #[test]
842 fn search_code_defaults_to_literal_smart_case() {
843 let dir = tempfile::tempdir().unwrap();
844 std::fs::write(
845 dir.path().join("lib.rs"),
846 "pub fn matching_commands() {}\nlet Needle = true;\n",
847 )
848 .unwrap();
849
850 let response = search_code(
851 dir.path(),
852 &SearchCodeRequest {
853 query: "matching_commands(".to_string(),
854 ..SearchCodeRequest::default()
855 },
856 )
857 .unwrap();
858 assert_eq!(response.matches.len(), 1);
859 assert_eq!(response.matches[0].path, "lib.rs");
860 assert!(response.matches[0].hit.contains("matching_commands"));
861
862 let response = search_code(
863 dir.path(),
864 &SearchCodeRequest {
865 query: "needle".to_string(),
866 ..SearchCodeRequest::default()
867 },
868 )
869 .unwrap();
870 assert_eq!(response.matches.len(), 1);
871 }
872
873 #[test]
874 fn search_code_supports_regex_mode_opt_in() {
875 let dir = tempfile::tempdir().unwrap();
876 std::fs::write(dir.path().join("lib.rs"), "let needle = true;\n").unwrap();
877
878 let literal_response = search_code(
879 dir.path(),
880 &SearchCodeRequest {
881 query: r"needle\s+=\s+true".to_string(),
882 ..SearchCodeRequest::default()
883 },
884 )
885 .unwrap();
886 assert!(literal_response.matches.is_empty());
887
888 let regex_response = search_code(
889 dir.path(),
890 &SearchCodeRequest {
891 query: r"needle\s+=\s+true".to_string(),
892 mode: SearchMode::Regex,
893 ..SearchCodeRequest::default()
894 },
895 )
896 .unwrap();
897 assert_eq!(regex_response.matches.len(), 1);
898 }
899
900 #[test]
901 fn search_code_honors_case_word_and_line_modes() {
902 let dir = tempfile::tempdir().unwrap();
903 std::fs::write(dir.path().join("lower.rs"), "let needle = true;\n").unwrap();
904 std::fs::write(dir.path().join("upper.rs"), "let Needle = true;\n").unwrap();
905 std::fs::write(dir.path().join("plural.rs"), "let needles = true;\n").unwrap();
906 std::fs::write(dir.path().join("line.rs"), "needle extra\nneedle\n").unwrap();
907
908 let smart_lower = search_code(
909 dir.path(),
910 &SearchCodeRequest {
911 query: "needle".to_string(),
912 ..SearchCodeRequest::default()
913 },
914 )
915 .unwrap();
916 assert_eq!(smart_lower.matches.len(), 5);
917
918 let smart_upper = search_code(
919 dir.path(),
920 &SearchCodeRequest {
921 query: "Needle".to_string(),
922 ..SearchCodeRequest::default()
923 },
924 )
925 .unwrap();
926 assert_eq!(
927 smart_upper
928 .matches
929 .iter()
930 .map(|hit| hit.path.as_str())
931 .collect::<Vec<_>>(),
932 vec!["upper.rs"]
933 );
934
935 let word = search_code(
936 dir.path(),
937 &SearchCodeRequest {
938 query: "needle".to_string(),
939 word: true,
940 case_mode: SearchCase::Sensitive,
941 ..SearchCodeRequest::default()
942 },
943 )
944 .unwrap();
945 assert!(
946 word.matches.iter().all(|hit| hit.path != "plural.rs"),
947 "word search should not match plural.rs: {:?}",
948 word.matches
949 );
950
951 let line = search_code(
952 dir.path(),
953 &SearchCodeRequest {
954 query: "needle".to_string(),
955 line: true,
956 case_mode: SearchCase::Sensitive,
957 ..SearchCodeRequest::default()
958 },
959 )
960 .unwrap();
961 assert_eq!(
962 line.matches
963 .iter()
964 .map(|hit| (hit.path.clone(), hit.hit.clone()))
965 .collect::<Vec<_>>(),
966 vec![("line.rs".to_string(), format_line_with_hash(2, "needle"))]
967 );
968 }
969
970 #[test]
971 fn search_code_honors_path_glob_type_hidden_and_ignore_filters() {
972 let dir = tempfile::tempdir().unwrap();
973 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
974 std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
975 std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
976 std::fs::write(dir.path().join("ignored.rs"), "let needle = true;\n").unwrap();
977 std::fs::write(dir.path().join(".hidden.rs"), "let needle = true;\n").unwrap();
978 std::fs::write(dir.path().join("src/lib.rs"), "let needle = true;\n").unwrap();
979 std::fs::write(
980 dir.path().join("src/generated/mod.rs"),
981 "let needle = true;\n",
982 )
983 .unwrap();
984 std::fs::write(dir.path().join("src/main.ts"), "let needle = true;\n").unwrap();
985
986 let filtered = search_code(
987 dir.path(),
988 &SearchCodeRequest {
989 query: "needle".to_string(),
990 path: Some("src".to_string()),
991 include: vec!["*.rs".to_string()],
992 exclude: vec!["src/generated/**".to_string()],
993 types: vec!["rust".to_string()],
994 ..SearchCodeRequest::default()
995 },
996 )
997 .unwrap();
998 assert_eq!(
999 filtered
1000 .matches
1001 .iter()
1002 .map(|hit| hit.path.as_str())
1003 .collect::<Vec<_>>(),
1004 vec!["src/lib.rs"]
1005 );
1006
1007 let default_visibility = search_code(
1008 dir.path(),
1009 &SearchCodeRequest {
1010 query: "needle".to_string(),
1011 path: None,
1012 include: vec!["*.rs".to_string()],
1013 exclude: vec!["src/**".to_string()],
1014 ..SearchCodeRequest::default()
1015 },
1016 )
1017 .unwrap();
1018 assert!(default_visibility.matches.is_empty());
1019
1020 let unrestricted_visibility = search_code(
1021 dir.path(),
1022 &SearchCodeRequest {
1023 query: "needle".to_string(),
1024 path: None,
1025 include: vec!["*.rs".to_string()],
1026 exclude: vec!["src/**".to_string()],
1027 hidden: true,
1028 respect_ignore: false,
1029 ..SearchCodeRequest::default()
1030 },
1031 )
1032 .unwrap();
1033 assert_eq!(
1034 unrestricted_visibility
1035 .matches
1036 .iter()
1037 .map(|hit| hit.path.as_str())
1038 .collect::<Vec<_>>(),
1039 vec![".hidden.rs", "ignored.rs"]
1040 );
1041 }
1042
1043 #[test]
1044 fn search_code_request_accepts_legacy_single_include_glob() {
1045 let value = serde_json::json!({
1046 "query": "needle",
1047 "include": "*.rs"
1048 });
1049
1050 let request: SearchCodeRequest = serde_json::from_value(value).unwrap();
1051 assert_eq!(request.include, vec!["*.rs".to_string()]);
1052 assert_eq!(request.mode, SearchMode::Literal);
1053 assert_eq!(request.case_mode, SearchCase::Smart);
1054 assert!(request.respect_ignore);
1055 }
1056
1057 #[test]
1058 fn search_code_honors_explicit_limit() {
1059 let dir = tempfile::tempdir().unwrap();
1060 std::fs::write(
1061 dir.path().join("lib.rs"),
1062 "pub fn one() { let needle = 1; }\npub fn two() { let needle = 2; }\npub fn three() { let needle = 3; }\n",
1063 )
1064 .unwrap();
1065
1066 let response = search_code(
1067 dir.path(),
1068 &SearchCodeRequest {
1069 query: "needle".to_string(),
1070 limit: Some(2),
1071 ..SearchCodeRequest::default()
1072 },
1073 )
1074 .unwrap();
1075 assert_eq!(response.matches.len(), 2);
1076 }
1077
1078 #[test]
1079 fn search_code_clamps_zero_limit_to_one() {
1080 let dir = tempfile::tempdir().unwrap();
1081 std::fs::write(
1082 dir.path().join("lib.rs"),
1083 "pub fn one() { let needle = 1; }\npub fn two() { let needle = 2; }\n",
1084 )
1085 .unwrap();
1086
1087 let response = search_code(
1088 dir.path(),
1089 &SearchCodeRequest {
1090 query: "needle".to_string(),
1091 limit: Some(0),
1092 ..SearchCodeRequest::default()
1093 },
1094 )
1095 .unwrap();
1096 assert_eq!(response.matches.len(), 1);
1097 }
1098
1099 #[test]
1100 fn search_code_returns_top_level_matched_line() {
1101 let dir = tempfile::tempdir().unwrap();
1102 let source = "use std::fmt;\n\npub fn second() {\n println!(\"second\");\n}\n";
1103 std::fs::write(dir.path().join("lib.rs"), source).unwrap();
1104
1105 let response = search_code(
1106 dir.path(),
1107 &SearchCodeRequest {
1108 query: "std::fmt".to_string(),
1109 ..SearchCodeRequest::default()
1110 },
1111 )
1112 .unwrap();
1113
1114 assert_eq!(response.matches.len(), 1);
1115 assert_eq!(response.matches[0].path, "lib.rs");
1116 assert_eq!(
1117 response.matches[0].hit,
1118 format_line_with_hash(1, "use std::fmt;")
1119 );
1120 }
1121
1122 #[test]
1123 fn read_code_full_reads_enclosing_symbol_from_line_anchor() {
1124 let dir = tempfile::tempdir().unwrap();
1125 let source = "pub fn first() {\n println!(\"first\");\n}\n\npub fn second() {\n println!(\"second\");\n}\n";
1126 std::fs::write(dir.path().join("lib.rs"), source).unwrap();
1127
1128 let search = search_code(
1129 dir.path(),
1130 &SearchCodeRequest {
1131 query: "second".to_string(),
1132 ..SearchCodeRequest::default()
1133 },
1134 )
1135 .unwrap();
1136 let anchor = search.matches[0]
1137 .hit
1138 .split_once('|')
1139 .expect("line anchor")
1140 .0
1141 .to_string();
1142 let result = read_code(
1143 dir.path(),
1144 &ReadCodeRequest {
1145 path: "lib.rs".to_string(),
1146 anchor,
1147 mode: ReadCodeMode::Full,
1148 },
1149 )
1150 .unwrap();
1151 let content = result.content.as_str();
1152 assert!(
1153 content.contains("pub fn second()"),
1154 "should contain fn second, got: {content}"
1155 );
1156 assert!(
1157 !content.contains("pub fn first()"),
1158 "should not contain fn first"
1159 );
1160 assert!(
1161 content.lines().all(|line| {
1162 let parts: Vec<&str> = line.splitn(2, '|').collect();
1163 parts.len() == 2 && parts[0].contains('#')
1164 }),
1165 "each line should have line#hash| prefix, got: {content}"
1166 );
1167 }
1168
1169 #[test]
1170 fn read_code_around_reads_fixed_context_without_enclosing_symbol() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let mut source = String::new();
1173 for line in 1..=40 {
1174 source.push_str(&format!("let value_{line} = {line};\n"));
1175 }
1176 std::fs::write(dir.path().join("lib.rs"), source).unwrap();
1177 let anchor = format!("20#{}", patch::line_hash("let value_20 = 20;"));
1178
1179 let result = read_code(
1180 dir.path(),
1181 &ReadCodeRequest {
1182 path: "lib.rs".to_string(),
1183 anchor,
1184 mode: ReadCodeMode::Around,
1185 },
1186 )
1187 .unwrap();
1188
1189 assert!(result.content.starts_with("8#"));
1190 assert!(result.content.contains("|let value_20 = 20;"));
1191 assert!(result.content.contains("|let value_32 = 32;"));
1192 assert!(!result.content.contains("|let value_7 = 7;"));
1193 assert!(!result.content.contains("|let value_33 = 33;"));
1194 }
1195
1196 #[test]
1197 fn read_code_rejects_stale_line_anchor() {
1198 let dir = tempfile::tempdir().unwrap();
1199 std::fs::write(dir.path().join("lib.rs"), "let current = true;\n").unwrap();
1200
1201 let err = read_code(
1202 dir.path(),
1203 &ReadCodeRequest {
1204 path: "lib.rs".to_string(),
1205 anchor: "1#00".to_string(),
1206 mode: ReadCodeMode::Around,
1207 },
1208 )
1209 .unwrap_err();
1210
1211 assert!(err.contains("hash mismatch"), "{err}");
1212 assert!(err.contains("search or read again"), "{err}");
1213 }
1214
1215 #[test]
1216 fn ack_next_event_can_return_a_limited_batch() {
1217 let propagation_state = Mutex::new(PropagationState::new());
1218 propagation_state.lock().unwrap().accumulate(vec![
1219 PropagationResult {
1220 selector: "src/a.rs::fn foo".to_string(),
1221 reason: "first".to_string(),
1222 source: PropagationSource::Lsp,
1223 lsp_references: Some(vec![]),
1224 diff_summary: None,
1225 file_snippet: None,
1226 project_files: None,
1227 },
1228 PropagationResult {
1229 selector: "src/b.rs::fn bar".to_string(),
1230 reason: "second".to_string(),
1231 source: PropagationSource::Lsp,
1232 lsp_references: Some(vec![]),
1233 diff_summary: None,
1234 file_snippet: None,
1235 project_files: None,
1236 },
1237 PropagationResult {
1238 selector: "src/c.rs::fn baz".to_string(),
1239 reason: "third".to_string(),
1240 source: PropagationSource::Lsp,
1241 lsp_references: Some(vec![]),
1242 diff_summary: None,
1243 file_snippet: None,
1244 project_files: None,
1245 },
1246 ]);
1247
1248 let result = ack_next_events(&propagation_state, Some(2)).unwrap();
1249
1250 assert_eq!(result.returned, 2);
1251 assert_eq!(result.remaining, 1);
1252 assert_eq!(result.reviews.len(), 2);
1253 match result.review.unwrap() {
1254 ReviewEvent::KnownReferences {
1255 modified_symbol, ..
1256 } => assert_eq!(modified_symbol, "src/c.rs::fn baz"),
1257 _ => panic!("expected KnownReferences review"),
1258 }
1259 }
1260
1261 #[test]
1262 fn scope_usage_is_available() {
1263 let result = serde_json::to_value(crate::usage::usage_response()).unwrap();
1264 assert!(
1265 result["usage_markdown"]
1266 .as_str()
1267 .unwrap()
1268 .contains("matched source lines with line-hash anchors")
1269 );
1270 assert!(
1271 result["protocol_items"]
1272 .as_array()
1273 .unwrap()
1274 .iter()
1275 .any(|item| item["item"] == "read_code")
1276 );
1277 }
1278
1279 #[test]
1280 fn is_responsible_source_reports_scope_owned_source() {
1281 let dir = tempfile::tempdir().unwrap();
1282
1283 let result = is_responsible_source(
1284 dir.path(),
1285 &IsResponsibleSourceRequest {
1286 path: "src/lib.rs".to_string(),
1287 },
1288 )
1289 .unwrap();
1290 assert!(result.is_responsible);
1291 assert_eq!(result.path, "src/lib.rs");
1292 assert_eq!(result.extension.as_deref(), Some("rs"));
1293 assert_eq!(result.language.as_deref(), Some("rust"));
1294 }
1295
1296 #[test]
1297 fn is_responsible_source_reports_non_source_file() {
1298 let dir = tempfile::tempdir().unwrap();
1299
1300 let result = is_responsible_source(
1301 dir.path(),
1302 &IsResponsibleSourceRequest {
1303 path: "README.md".to_string(),
1304 },
1305 )
1306 .unwrap();
1307 assert!(!result.is_responsible);
1308 assert_eq!(result.path, "README.md");
1309 assert_eq!(result.extension.as_deref(), Some("md"));
1310 assert_eq!(result.language, None);
1311 }
1312}