Skip to main content

scope_engine/
engine.rs

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(&params.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(&params.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(&params.include)?,
343            exclude: build_optional_glob_set(&params.exclude)?,
344            type_include_exts: build_optional_type_exts(&params.types)?,
345            type_exclude_exts: build_optional_type_exts(&params.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(&params.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(&params.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(&params.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), // covers both TS and JS
681        Box::new(GoplsConfig),
682        Box::new(JdtlsConfig),
683    ];
684
685    let mut languages = Vec::new();
686
687    // Tree-sitter languages from registry
688    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    // LSP configs
700    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}