Skip to main content

reflex/
models.rs

1//! Core data models for Reflex
2//!
3//! These structures represent the normalized, deterministic output format
4//! that Reflex provides to AI agents and other programmatic consumers.
5
6use serde::{Deserialize, Serialize};
7use strum::{Display, EnumString};
8
9/// Represents a source code location span (line range only)
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct Span {
12    /// Starting line number (1-indexed)
13    pub start_line: usize,
14    /// Ending line number (1-indexed)
15    pub end_line: usize,
16}
17
18impl Span {
19    pub fn new(start_line: usize, start_col: usize, end_line: usize, end_col: usize) -> Self {
20        // Ignore col parameters for backwards compatibility
21        let _ = (start_col, end_col);
22        Self {
23            start_line,
24            end_line,
25        }
26    }
27}
28
29/// Type of symbol found in code
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, EnumString, Display)]
31#[strum(serialize_all = "PascalCase")]
32pub enum SymbolKind {
33    Function,
34    Class,
35    Struct,
36    Enum,
37    Interface,
38    Trait,
39    Constant,
40    Variable,
41    Method,
42    Module,
43    Namespace,
44    Type,
45    Macro,
46    Property,
47    Event,
48    Import,
49    Export,
50    Attribute,
51    /// Catch-all for symbol kinds not yet explicitly supported.
52    /// This ensures no data loss when encountering new tree-sitter node types.
53    /// The string contains the original kind name from the parser.
54    #[strum(default)]
55    Unknown(String),
56}
57
58/// Programming language identifier
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
60#[serde(rename_all = "lowercase")]
61pub enum Language {
62    #[default]
63    Rust,
64    Python,
65    JavaScript,
66    TypeScript,
67    Vue,
68    Svelte,
69    Go,
70    Java,
71    PHP,
72    C,
73    Cpp,
74    CSharp,
75    Ruby,
76    Kotlin,
77    Swift,
78    Zig,
79    /// Plain-text tier: documentation, config and templates.
80    ///
81    /// Trigram-indexed only — no tree-sitter grammar, no symbol extraction, no
82    /// import extraction. Added because agents do not partition searches by file
83    /// type: a config key lives in the YAML, the Rust struct AND the spec paragraph,
84    /// and Reflex used to return the struct and a confident 0 for the rest.
85    ///
86    /// Serialises as `"text"` (the enum is `rename_all = "lowercase"`).
87    Text,
88    /// Lock files (`Cargo.lock`, `package-lock.json`, `*.lock`, …).
89    ///
90    /// Indexed since 2.0.0 in `[index] mode = "tracked"`, but excluded from every
91    /// search by default: 100k lines of pinned versions are noise for almost every
92    /// query and the one real question ("which lockfile pins serde 1.0.190?") is
93    /// asked with `include_locks: true` or `lang: "lock"`.
94    Lock,
95    /// Generated files judged by name: `*.pb.go`, `*_generated.*`, `*.generated.*`,
96    /// `*.min.js`, `*.min.css`, `*.map`. Indexed, excluded by default,
97    /// `include_generated: true` or `lang: "generated"` to widen.
98    Generated,
99    /// Never produced by [`Language::from_path`] since 2.0.0 (every path classifies
100    /// as code, `Text`, `Lock` or `Generated`); kept as the miss value of
101    /// [`Language::from_extension`] and as the forward-compatible sentinel for
102    /// readers of the `language` field.
103    Unknown,
104}
105
106/// Whether `file_name` is a dependency lock file.
107///
108/// Judged by full name: `package-lock.json` matches a text extension, and the
109/// name is the only thing that tells it apart from `settings.json`.
110pub fn is_lock_file(file_name: &str) -> bool {
111    if TEXT_FILENAME_EXCLUSIONS
112        .iter()
113        .any(|n| n.eq_ignore_ascii_case(file_name))
114    {
115        return true;
116    }
117    // `*-lock.json` and friends, beyond the names listed above.
118    file_name.ends_with("-lock.json") || file_name.ends_with(".lock")
119}
120
121/// Whether `file_name` looks like a generated file, judged by name only.
122///
123/// Content markers (`@generated` in the file head) are not read: the query engine
124/// derives a file's language from its path, so a content-based verdict at index
125/// time could not be honoured at query time.
126pub fn is_generated_name(file_name: &str) -> bool {
127    let lower = file_name.to_ascii_lowercase();
128    lower.ends_with(".pb.go")
129        || lower.ends_with(".min.js")
130        || lower.ends_with(".min.css")
131        || lower.ends_with(".map")
132        || lower.contains("_generated.")
133        || lower.contains(".generated.")
134}
135
136/// Extensions in the plain-text tier.
137///
138/// A fixed allowlist, not "everything unrecognised": an index that swallowed every
139/// binary blob and generated artefact in a repo would be slower and less useful.
140const TEXT_EXTENSIONS: &[&str] = &[
141    "md", "mdx", "txt", "yaml", "yml", "toml", "json", "proto", "html", "htm", "sh", "bash", "ini",
142    "cfg", "sql", "graphql", "bru",
143];
144
145/// Extensionless files in the plain-text tier, matched by exact name.
146///
147/// Agents grep these as readily as any `.md`; a `Makefile` target or a `Dockerfile`
148/// `COPY` line is a legitimate search hit. `Dockerfile.<variant>` is handled as a
149/// prefix in [`is_text_tier_file`].
150const TEXT_FILENAMES: &[&str] = &["Makefile", "makefile", "Dockerfile", "Justfile", "justfile"];
151
152/// Lock files, by exact name. See [`is_lock_file`] for the suffix rules.
153const TEXT_FILENAME_EXCLUSIONS: &[&str] = &[
154    "package-lock.json",
155    "npm-shrinkwrap.json",
156    "composer.lock",
157    "yarn.lock",
158    "pnpm-lock.yaml",
159    "Cargo.lock",
160    "poetry.lock",
161    "Gemfile.lock",
162    "go.sum",
163    "flake.lock",
164    "uv.lock",
165    "deno.lock",
166    "bun.lock",
167];
168
169/// Whether a file belongs in the ALLOWLIST text tier, judged by its full name.
170///
171/// This is the pre-2.0.0 rule, kept for `[index] mode = "allowlist"`. In the
172/// default `tracked` mode every non-binary, non-ignored file is text unless it is
173/// code, a lock file or a generated file.
174pub fn is_text_tier_file(file_name: &str) -> bool {
175    if is_lock_file(file_name) {
176        return false;
177    }
178    if TEXT_FILENAMES.contains(&file_name) || file_name.starts_with("Dockerfile.") {
179        return true;
180    }
181    match file_name.rsplit_once('.') {
182        Some((_, ext)) => TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()),
183        None => false,
184    }
185}
186
187impl Language {
188    /// Classify a file by its path.
189    ///
190    /// This is the one classifier the indexer, watcher and query engine share, so a
191    /// file is either indexed, watched and searchable, or none of the three. Path
192    /// only: whether the file is binary is decided from its bytes by the indexer,
193    /// and a binary file is simply never in the index.
194    ///
195    /// * A lock file name wins (`Cargo.lock`, `package-lock.json`) → `Lock`.
196    /// * A generated name wins next (`x.pb.go`, `app.min.js`) → `Generated`.
197    /// * A recognised code extension → that language (`main.rs`, `app.mjs`).
198    /// * Everything else → `Text`: `README`, `OWNERS`, `foo.po`, `a.css`,
199    ///   `Makefile`, `.githooks/pre-commit`. Whether such a file is INDEXED is the
200    ///   `[index] mode` policy's decision (`PathPolicy::classify`).
201    pub fn from_path(path: &std::path::Path) -> Self {
202        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
203        if is_lock_file(name) {
204            return Language::Lock;
205        }
206        if is_generated_name(name) {
207            return Language::Generated;
208        }
209        let by_ext = path
210            .extension()
211            .and_then(|e| e.to_str())
212            .map(Self::from_extension)
213            .unwrap_or(Language::Unknown);
214        match by_ext {
215            Language::Text | Language::Unknown => Language::Text,
216            code => code,
217        }
218    }
219
220    pub fn from_extension(ext: &str) -> Self {
221        match ext {
222            "rs" => Language::Rust,
223            "py" => Language::Python,
224            "js" | "mjs" | "cjs" | "jsx" => Language::JavaScript,
225            "ts" | "mts" | "cts" | "tsx" => Language::TypeScript,
226            "vue" => Language::Vue,
227            "svelte" => Language::Svelte,
228            "go" => Language::Go,
229            "java" => Language::Java,
230            "php" => Language::PHP,
231            "c" | "h" => Language::C,
232            "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "C" | "H" => Language::Cpp,
233            "cs" => Language::CSharp,
234            "rb" | "rake" | "gemspec" => Language::Ruby,
235            "kt" | "kts" => Language::Kotlin,
236            "swift" => Language::Swift,
237            "zig" => Language::Zig,
238            // The text tier. Note this maps by EXTENSION only; `is_text_tier_file`
239            // additionally excludes lock files by name, and the indexer uses that.
240            ext if TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) => Language::Text,
241            _ => Language::Unknown,
242        }
243    }
244
245    /// Parse a language from a human-friendly name (CLI/API input)
246    ///
247    /// Accepts lowercase names and common aliases.
248    /// Returns None for unrecognized names.
249    pub fn from_name(name: &str) -> Option<Self> {
250        match name.to_lowercase().as_str() {
251            "rust" | "rs" => Some(Language::Rust),
252            "python" | "py" => Some(Language::Python),
253            "javascript" | "js" => Some(Language::JavaScript),
254            "typescript" | "ts" => Some(Language::TypeScript),
255            "vue" => Some(Language::Vue),
256            "svelte" => Some(Language::Svelte),
257            "go" => Some(Language::Go),
258            "java" => Some(Language::Java),
259            "php" => Some(Language::PHP),
260            "c" => Some(Language::C),
261            "cpp" | "c++" => Some(Language::Cpp),
262            "csharp" | "cs" | "c#" => Some(Language::CSharp),
263            "ruby" | "rb" => Some(Language::Ruby),
264            "kotlin" | "kt" => Some(Language::Kotlin),
265            "zig" => Some(Language::Zig),
266            "text" | "txt" | "plaintext" | "plain" => Some(Language::Text),
267            "lock" | "lockfile" | "lockfiles" => Some(Language::Lock),
268            "generated" | "gen" => Some(Language::Generated),
269            _ => None,
270        }
271    }
272
273    /// Human-readable list of all supported language names (for error messages)
274    pub fn supported_names_help() -> &'static str {
275        "rust (rs), python (py), javascript (js), typescript (ts), vue, svelte, \
276         go, java, php, c, cpp (c++), csharp (cs, c#), ruby (rb), kotlin (kt), zig, \
277         text (every other non-binary file: docs, config, templates, extensionless), \
278         lock (lock files, excluded by default), generated (*.pb.go, *.min.js, *.map, \
279         *_generated.*, excluded by default)"
280    }
281
282    /// Check if this language has a parser implementation
283    ///
284    /// Returns true only for languages with working Tree-sitter parsers.
285    /// This determines which files will be indexed by Reflex.
286    pub fn is_supported(&self) -> bool {
287        match self {
288            Language::Rust => true,
289            Language::TypeScript => true,
290            Language::JavaScript => true,
291            Language::Vue => true,
292            Language::Svelte => true,
293            Language::Python => true,
294            Language::Go => true,
295            Language::Java => true,
296            Language::PHP => true,
297            Language::C => true,
298            Language::Cpp => true,
299            Language::CSharp => true,
300            Language::Ruby => true,
301            Language::Kotlin => true,
302            Language::Swift => false, // Temporarily disabled - parser queries out of date with tree-sitter-swift 0.7.x grammar
303            Language::Zig => true,
304            // No tree-sitter grammar, by design.
305            Language::Text => false,
306            Language::Lock => false,
307            Language::Generated => false,
308            Language::Unknown => false,
309        }
310    }
311
312    /// Whether this is the plain-text tier.
313    pub fn is_text(&self) -> bool {
314        matches!(self, Language::Text)
315    }
316
317    /// Whether files of this language are indexed but left out of every search
318    /// unless asked for (`include_locks` / `include_generated`, or `lang`).
319    pub fn is_excluded_by_default(&self) -> bool {
320        matches!(self, Language::Lock | Language::Generated)
321    }
322
323    /// Whether this is a code language, supported or not (Swift is code without a
324    /// working grammar). Code files are assumed to be text; every other file is
325    /// sniffed for a NUL byte before it is indexed.
326    pub fn is_code(&self) -> bool {
327        !matches!(
328            self,
329            Language::Text | Language::Lock | Language::Generated | Language::Unknown
330        )
331    }
332
333    /// Whether files of this language can be in the index at all.
334    ///
335    /// Distinct from [`Self::is_supported`], which means "has a tree-sitter parser".
336    /// The text tier is indexed but never parsed, so symbol search, AST queries and
337    /// dependency analysis skip it while full-text search covers it. Whether a given
338    /// path IS indexed also depends on `[index] mode` (`PathPolicy::classify`).
339    pub fn is_indexable(&self) -> bool {
340        !matches!(self, Language::Unknown)
341    }
342}
343
344/// Which files the indexer takes, beyond the code languages.
345#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
346#[serde(rename_all = "lowercase")]
347pub enum IndexMode {
348    /// Every file not ignored by `.gitignore` / `.ignore` / `[index] exclude`,
349    /// unless a NUL byte anywhere in it says it is binary. Lock and generated
350    /// files are indexed and excluded from searches by default. This is ripgrep's
351    /// rule, and what an agent that greps expects.
352    #[default]
353    Tracked,
354    /// The pre-2.0.0 rule: code by extension plus the fixed docs/config extension
355    /// list (`is_text_tier_file`). Lock and generated files are not indexed. For
356    /// trees where the long tail of data files is not worth the index size.
357    Allowlist,
358}
359
360impl IndexMode {
361    pub fn from_name(name: &str) -> Option<Self> {
362        match name.trim().to_ascii_lowercase().as_str() {
363            "tracked" => Some(Self::Tracked),
364            "allowlist" => Some(Self::Allowlist),
365            _ => None,
366        }
367    }
368}
369
370/// Type of import/dependency
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
372#[serde(rename_all = "lowercase")]
373pub enum ImportType {
374    /// Internal project file
375    Internal,
376    /// External library/package
377    External,
378    /// Standard library
379    Stdlib,
380    /// Rust `mod foo;` declaration (parent→child ownership, not a usage edge)
381    #[serde(rename = "mod_decl")]
382    ModDecl,
383}
384
385/// Dependency information for API output (simplified, path-based)
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct DependencyInfo {
388    /// Import path as written in source (or resolved path for internal deps)
389    pub path: String,
390    /// Line number where import appears (optional)
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub line: Option<usize>,
393    /// Imported symbols (for selective imports like `from x import a, b`)
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub symbols: Option<Vec<String>>,
396}
397
398/// Full dependency record (internal representation with file IDs)
399#[derive(Debug, Clone)]
400pub struct Dependency {
401    /// Source file ID
402    pub file_id: i64,
403    /// Import path as written in source code
404    pub imported_path: String,
405    /// Resolved file ID (None if external or stdlib)
406    pub resolved_file_id: Option<i64>,
407    /// Import type classification
408    pub import_type: ImportType,
409    /// Line number where import appears
410    pub line_number: usize,
411    /// Imported symbols (for selective imports)
412    pub imported_symbols: Option<Vec<String>>,
413}
414
415/// A lightweight, stable reference to a code symbol for API responses
416///
417/// Prefer this over `(String, SymbolKind, Span)` tuples — tuples serialize as
418/// positional JSON arrays, making any field addition a breaking change.
419/// Named fields here are additive-safe: new optional fields can be added without
420/// shifting positions or bumping the version.
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
422pub struct SymbolRef {
423    /// Symbol name (e.g., function name, class name)
424    pub name: String,
425    /// Symbol kind (function, class, struct, etc.)
426    pub kind: SymbolKind,
427    /// Location span in source file
428    pub span: Span,
429}
430
431/// Helper function to skip serializing "Unknown" symbol kinds
432fn is_unknown_kind(kind: &SymbolKind) -> bool {
433    matches!(kind, SymbolKind::Unknown(_))
434}
435
436/// A search result representing a symbol or code location
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct SearchResult {
439    /// Absolute or relative path to the file
440    pub path: String,
441    /// Detected programming language (internal use only, not serialized to save tokens)
442    #[serde(skip)]
443    pub lang: Language,
444    /// Type of symbol found (only included for symbol searches, not text matches)
445    #[serde(skip_serializing_if = "is_unknown_kind")]
446    pub kind: SymbolKind,
447    /// Symbol name (e.g., function name, class name)
448    /// None for text/regex matches where symbol name cannot be accurately determined
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub symbol: Option<String>,
451    /// Location span in the source file
452    pub span: Span,
453    /// Code preview (few lines around the match)
454    pub preview: String,
455    /// File dependencies (only populated when --dependencies flag is used)
456    /// DEPRECATED: Use FileGroupedResult.dependencies instead for file-level grouping
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub dependencies: Option<Vec<DependencyInfo>>,
459}
460
461/// An individual match within a file (no path or dependencies)
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct MatchResult {
464    /// Type of symbol found (only included for symbol searches, not text matches)
465    #[serde(skip_serializing_if = "is_unknown_kind")]
466    pub kind: SymbolKind,
467    /// Symbol name (e.g., function name, class name)
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub symbol: Option<String>,
470    /// Location span in the source file
471    pub span: Span,
472    /// Code preview (few lines around the match)
473    pub preview: String,
474    /// Lines of code before the match (for context)
475    #[serde(skip_serializing_if = "Vec::is_empty")]
476    pub context_before: Vec<String>,
477    /// Lines of code after the match (for context)
478    #[serde(skip_serializing_if = "Vec::is_empty")]
479    pub context_after: Vec<String>,
480}
481
482/// File-level grouped results with dependencies at file level
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct FileGroupedResult {
485    /// Absolute or relative path to the file
486    pub path: String,
487    /// Detected programming language of this file (e.g. "rust", "python", "unknown")
488    pub language: Language,
489    /// File dependencies (only populated when --dependencies flag is used)
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub dependencies: Option<Vec<DependencyInfo>>,
492    /// Individual matches within this file
493    pub matches: Vec<MatchResult>,
494}
495
496impl SearchResult {
497    pub fn new(
498        path: String,
499        lang: Language,
500        kind: SymbolKind,
501        symbol: Option<String>,
502        span: Span,
503        scope: Option<String>,
504        preview: String,
505    ) -> Self {
506        // Ignore scope parameter for backwards compatibility
507        let _ = scope;
508        Self {
509            path,
510            lang,
511            kind,
512            symbol,
513            span,
514            preview,
515            dependencies: None,
516        }
517    }
518}
519
520/// Configuration for indexing behavior
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct IndexConfig {
523    /// Languages to include (empty = all supported)
524    pub languages: Vec<Language>,
525    /// Glob patterns to include
526    pub include_patterns: Vec<String>,
527    /// Glob patterns to exclude
528    pub exclude_patterns: Vec<String>,
529    /// Follow symbolic links
530    pub follow_symlinks: bool,
531    /// Maximum file size to index (bytes)
532    pub max_file_size: usize,
533    /// Number of threads for parallel indexing (0 = auto, 80% of available cores)
534    pub parallel_threads: usize,
535    /// Threads for the background symbol pass (`rfx index-symbols-internal`).
536    /// `0` = auto: half the cores, at most 32. See [`resolve_symbol_thread_count`].
537    #[serde(default)]
538    pub symbol_threads: usize,
539    /// Query timeout in seconds (0 = no timeout)
540    pub query_timeout_secs: u64,
541    /// Maximum entries per trigram posting list (0 = unlimited).
542    /// High-frequency trigrams are truncated at this threshold to bound query latency.
543    pub max_posting_list_entries: usize,
544    /// How long `Indexer::index` waits for `.reflex/index.lock` when another
545    /// indexer holds it (seconds). 0 = fail immediately with `IndexLocked`.
546    /// The `rfx index` CLI waits; MCP, watcher and HTTP callers fail fast.
547    #[serde(default, skip_serializing_if = "is_zero_u64")]
548    pub lock_wait_secs: u64,
549    /// Index documentation, config and template files alongside code.
550    ///
551    /// On by default. Covers md, mdx, txt, yaml, yml, toml, json, proto, html, htm,
552    /// sh, bash, ini, cfg, sql and graphql, trigram-indexed only — no symbols, no
553    /// AST, no dependency analysis. Lock files are always excluded.
554    ///
555    /// Set `[index] text_tier = false` for a repo with large generated JSON or
556    /// vendored documentation where the index growth is not worth it.
557    ///
558    /// `#[serde(default = ...)]` so a config file written before 1.7.2 still parses
559    /// and gets the new default.
560    #[serde(default = "default_true")]
561    pub text_tier: bool,
562    /// Which non-code files the text tier takes: every non-binary, non-hidden, non-ignored file
563    /// (`tracked`, the default since 2.0.0) or the fixed extension list
564    /// (`allowlist`, the pre-2.0.0 rule).
565    #[serde(default)]
566    pub mode: IndexMode,
567    /// Walk dot-directories and dotfiles too (`.githooks/pre-commit`, `.env.example`).
568    /// Off by default, like ripgrep without `--hidden`. `.git/` and `.reflex/` are
569    /// never walked.
570    #[serde(default)]
571    pub hidden: bool,
572}
573
574/// Serde default for boolean options that are on unless explicitly disabled.
575fn default_true() -> bool {
576    true
577}
578
579impl Default for IndexConfig {
580    fn default() -> Self {
581        Self {
582            languages: vec![],
583            include_patterns: vec![],
584            exclude_patterns: vec![],
585            follow_symlinks: false,
586            max_file_size: 10 * 1024 * 1024,   // 10 MB
587            parallel_threads: 0,               // 0 = auto (80% of available cores)
588            symbol_threads: 0,                 // 0 = auto (50% of available cores)
589            query_timeout_secs: 30,            // 30 seconds default timeout
590            max_posting_list_entries: 500_000, // cap at 500k to bound query latency
591            text_tier: true,                   // docs and config are searchable by default
592            mode: IndexMode::Tracked, // ripgrep defaults: not ignored, not hidden, not binary
593            hidden: false,            // dot-directories skipped, like ripgrep
594            lock_wait_secs: 0,        // fail fast when another indexer runs
595        }
596    }
597}
598
599fn is_zero(v: &usize) -> bool {
600    *v == 0
601}
602fn is_zero_u64(v: &u64) -> bool {
603    *v == 0
604}
605
606/// Resolve `[performance] symbol_threads` (the background symbol pass) to a
607/// concrete thread count.
608///
609/// `REFLEX_SYMBOL_THREADS` overrides for benchmarking, then `configured` if
610/// non-zero, else half the cores (1..=32). The pass is detached from `rfx index`
611/// and runs while the user may be querying, so it takes half the machine rather
612/// than the indexer's 80%; before 2.0.0 it took 27.5%.
613pub fn resolve_symbol_thread_count(configured: usize) -> usize {
614    if let Some(n) = std::env::var("REFLEX_SYMBOL_THREADS")
615        .ok()
616        .and_then(|v| v.parse::<usize>().ok())
617        .filter(|&n| n > 0)
618    {
619        return n;
620    }
621    if configured != 0 {
622        return configured.max(1);
623    }
624    let available = std::thread::available_parallelism()
625        .map(|n| n.get())
626        .unwrap_or(4);
627    ((available as f64 * 0.5).ceil() as usize).clamp(1, 32)
628}
629
630/// Resolve `[performance] parallel_threads` to a concrete thread count.
631///
632/// `0` means automatic: 80% of the available cores, at least 1, at most
633/// `auto_cap`. A non-zero value is used as given. The indexer passes a cap of 8
634/// (write-side cache contention); query-time verification passes a higher cap.
635pub fn resolve_thread_count(configured: usize, auto_cap: usize) -> usize {
636    if configured != 0 {
637        return configured.max(1);
638    }
639    let available = std::thread::available_parallelism()
640        .map(|n| n.get())
641        .unwrap_or(4);
642    ((available as f64 * 0.8).ceil() as usize).clamp(1, auto_cap.max(1))
643}
644
645/// How a query found its candidate lines.
646///
647/// `trigram` is the normal case: the pattern's literals were looked up in the
648/// inverted index and only the lines they name were verified. `scan` means every
649/// line of every file was verified, which happens for a pattern shorter than
650/// 3 chars, a regex with no literal of 3+ chars (`\w+_id`), a non-ASCII literal
651/// under `(?i)`, or a keyword symbol query. Before 2.0.0 every `(?i)` regex
652/// scanned; since 2.0.0 its literals are looked up under all case variants.
653#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
654#[serde(rename_all = "lowercase")]
655pub enum IndexPath {
656    /// Candidates came from the trigram index.
657    #[default]
658    Trigram,
659    /// Every line was verified.
660    Scan,
661}
662
663/// Per-phase wall-clock timings for one query, in microseconds.
664///
665/// Present in a [`QueryResponse`] only when the caller asked for it
666/// (`rfx query --timing`, or `REFLEX_MCP_TIMING=1` for the MCP server).
667#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
668pub struct QueryTimings {
669    /// Whether the trigram index or a full scan produced the candidates.
670    #[serde(default)]
671    pub index_path: IndexPath,
672    /// Opening (or reusing) the index handle.
673    pub open_us: u64,
674    /// Trigram lookup and posting-list intersection.
675    pub candidates_us: u64,
676    /// Verifying candidate lines against the pattern (and any enrichment).
677    pub verify_us: u64,
678    /// Time the query waited for the freshness check after the search finished.
679    /// The check runs on its own thread alongside the search, so this is usually
680    /// near zero; `status_compute_us` is what the check itself cost.
681    pub status_us: u64,
682    /// The freshness check's own duration (git spawns, or a tree walk outside git).
683    #[serde(default)]
684    pub status_compute_us: u64,
685    /// Grouping by file, context lines, dependencies.
686    pub group_us: u64,
687    /// Whole query as seen by the engine.
688    pub total_us: u64,
689}
690
691/// Statistics about the index
692#[derive(Debug, Clone, Serialize, Deserialize, Default)]
693pub struct IndexStats {
694    /// Total files indexed
695    pub total_files: usize,
696    /// Index size on disk (bytes)
697    pub index_size_bytes: u64,
698    /// Last update timestamp
699    pub last_updated: String,
700    /// File count breakdown by language
701    pub files_by_language: std::collections::HashMap<String, usize>,
702    /// Line count breakdown by language
703    pub lines_by_language: std::collections::HashMap<String, usize>,
704    /// New files added since last index run (0 if not an incremental run)
705    #[serde(default, skip_serializing_if = "is_zero")]
706    pub new_files: usize,
707    /// Modified files re-indexed since last run (0 if not an incremental run)
708    #[serde(default, skip_serializing_if = "is_zero")]
709    pub modified_files: usize,
710    /// Unchanged files (same hash as last run, still re-indexed due to other changes)
711    #[serde(default, skip_serializing_if = "is_zero")]
712    pub unchanged_files: usize,
713    /// Files dropped from the index because they no longer exist on disk
714    #[serde(default, skip_serializing_if = "is_zero")]
715    pub deleted_files: usize,
716    /// Files skipped because they exceeded max_file_size
717    #[serde(default, skip_serializing_if = "is_zero")]
718    pub skipped_too_large: usize,
719    /// Total bytes of files skipped due to max_file_size
720    #[serde(default, skip_serializing_if = "is_zero_u64")]
721    pub skipped_bytes_too_large: u64,
722    /// Files skipped because a NUL byte says they are binary (ripgrep's rule)
723    #[serde(default, skip_serializing_if = "is_zero")]
724    pub skipped_binary: usize,
725    /// Raw bytes of indexed source held in content.bin (0 if unknown)
726    #[serde(default, skip_serializing_if = "is_zero_u64")]
727    pub corpus_bytes: u64,
728    /// Size of trigrams.bin on disk (0 if absent)
729    #[serde(default, skip_serializing_if = "is_zero_u64")]
730    pub trigram_index_bytes: u64,
731}
732
733/// Information about an indexed file
734#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct IndexedFile {
736    /// File path
737    pub path: String,
738    /// Detected language
739    pub language: String,
740    /// Last indexed timestamp
741    pub last_indexed: String,
742}
743
744/// Index status for query responses
745#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
746#[serde(rename_all = "snake_case")]
747pub enum IndexStatus {
748    /// Index is fresh and up-to-date
749    Fresh,
750    /// Index is stale (any issue: branch not indexed, commit changed, files modified)
751    Stale,
752}
753
754/// Warning details when index is stale
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct IndexWarning {
757    /// Human-readable reason why index is stale
758    pub reason: String,
759    /// Command to run to fix the issue
760    pub action_required: String,
761    /// Tracked files edited since the index was built.
762    ///
763    /// BREAKING in 1.7.2: this was a `u32` count. It is now the paths themselves,
764    /// because a count told an agent something was wrong without telling it what, so
765    /// the only safe reaction was to distrust the whole result. The list is capped;
766    /// `truncated` says when.
767    #[serde(skip_serializing_if = "Option::is_none")]
768    pub files_modified: Option<Vec<String>>,
769    /// Files present on disk but absent from the index (new or untracked).
770    #[serde(skip_serializing_if = "Option::is_none")]
771    pub files_added: Option<Vec<String>>,
772    /// Files in the index but no longer on disk. These produce ghost hits.
773    #[serde(skip_serializing_if = "Option::is_none")]
774    pub files_deleted: Option<Vec<String>>,
775    /// Total changed paths, which may exceed the lengths of the lists above.
776    #[serde(skip_serializing_if = "Option::is_none")]
777    pub changed_count: Option<usize>,
778    /// Whether the lists were cut short. Set on a fresh checkout or a huge rebase.
779    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
780    pub truncated: bool,
781    /// Additional context (git branch info, etc.)
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub details: Option<IndexWarningDetails>,
784}
785
786impl IndexWarning {
787    /// A warning with only a reason and an action, no file lists.
788    pub fn new(reason: impl Into<String>, action_required: impl Into<String>) -> Self {
789        Self {
790            reason: reason.into(),
791            action_required: action_required.into(),
792            files_modified: None,
793            files_added: None,
794            files_deleted: None,
795            changed_count: None,
796            truncated: false,
797            details: None,
798        }
799    }
800
801    /// Attach git branch/commit context.
802    pub fn with_details(mut self, details: IndexWarningDetails) -> Self {
803        self.details = Some(details);
804        self
805    }
806}
807
808/// Detailed information about index staleness
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct IndexWarningDetails {
811    /// Current branch (if in git repo)
812    #[serde(skip_serializing_if = "Option::is_none")]
813    pub current_branch: Option<String>,
814    /// Indexed branch (if in git repo)
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub indexed_branch: Option<String>,
817    /// Current commit SHA (if in git repo)
818    #[serde(skip_serializing_if = "Option::is_none")]
819    pub current_commit: Option<String>,
820    /// Indexed commit SHA (if in git repo)
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub indexed_commit: Option<String>,
823    /// When the index was last written (Unix seconds).
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub indexed_at: Option<i64>,
826    /// How the working tree was compared to the index: `"git"` (candidates from
827    /// `git status`, confirmed by fingerprint) or `"walk"` (every file stat'ed).
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub checked_by: Option<String>,
830}
831
832/// The full answer to `check_index_status`.
833///
834/// `details` is present even when the index is fresh, so a human can see that the
835/// indexed commit differs from HEAD without that difference being called staleness:
836/// since 2.0.0 freshness is judged by file content, not by commit.
837#[derive(Debug, Clone)]
838pub struct IndexStatusReport {
839    pub status: IndexStatus,
840    pub can_trust_results: bool,
841    pub warning: Option<IndexWarning>,
842    pub details: Option<IndexWarningDetails>,
843}
844
845/// Pagination information for query results
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct PaginationInfo {
848    /// Total number of results before offset/limit — **`null` whenever
849    /// `total_is_exact` is false**. A list-mode search with a `limit` stops
850    /// verifying once the page is full, and the number verified by then is not a
851    /// total; reporting it as one made agents stop paginating early (2.0.0 field
852    /// test: `total: 851` for a term with 18,752 matches). Use `approx_total` for an
853    /// estimate, or a count-mode / no-limit search for the exact number.
854    #[serde(default)]
855    pub total: Option<usize>,
856    /// Number of results in this response (after offset/limit)
857    pub count: usize,
858    /// Offset used (starting position)
859    pub offset: usize,
860    /// Limit used (max results per page)
861    #[serde(skip_serializing_if = "Option::is_none")]
862    pub limit: Option<usize>,
863    /// Whether there are more results after this page
864    pub has_more: bool,
865    /// `true` when `total` counts every match. `false` when a list-mode search
866    /// stopped verifying once the page was full: `total` is then `null` and
867    /// `approx_total` carries an estimate. Count mode, no-limit searches,
868    /// symbol/AST searches and `require_exact_total` callers are always exact.
869    #[serde(default = "default_true")]
870    pub total_is_exact: bool,
871    /// Estimated total when `total_is_exact` is false. After the page filled, a
872    /// spread sample of the remaining candidate lines was verified and the
873    /// measured hit rate scaled over the rest (see
874    /// `query::ESTIMATE_SAMPLE_FILES`). Typically within ±30% on the synthetic
875    /// corpus; wider when hits cluster in a few files. Absent when no estimate was
876    /// possible (a regex with no literal, where every line is a candidate).
877    #[serde(default, skip_serializing_if = "Option::is_none")]
878    pub approx_total: Option<usize>,
879}
880
881impl PaginationInfo {
882    /// The total, only when it is a real one.
883    pub fn exact_total(&self) -> Option<usize> {
884        if self.total_is_exact {
885            self.total
886        } else {
887            None
888        }
889    }
890
891    /// The best number available for a threshold or a log line: the exact total,
892    /// else the estimate, else the end of this page. Never show it as a total.
893    pub fn best_total(&self) -> usize {
894        self.exact_total()
895            .or(self.approx_total)
896            .unwrap_or(self.offset + self.count)
897    }
898}
899
900/// Query response with results and index status
901#[derive(Debug, Clone, Serialize, Deserialize)]
902pub struct QueryResponse {
903    /// AI-optimized instruction for how to handle these results
904    /// Only present when --ai flag is used or in MCP mode
905    /// Provides guidance to AI agents on response format and next actions
906    #[serde(skip_serializing_if = "Option::is_none")]
907    pub ai_instruction: Option<String>,
908    /// Status of the index (fresh or stale)
909    pub status: IndexStatus,
910    /// Whether the results can be trusted
911    pub can_trust_results: bool,
912    /// Warning information (only present if stale)
913    #[serde(skip_serializing_if = "Option::is_none")]
914    pub warning: Option<IndexWarning>,
915    /// Pagination information
916    pub pagination: PaginationInfo,
917    /// File-grouped search results
918    /// Results are always grouped by file path, with dependencies populated when --dependencies flag is used
919    pub results: Vec<FileGroupedResult>,
920    /// For a whole-identifier search that found nothing: how many candidate lines
921    /// contain the pattern as a substring. Lets the caller explain a zero without
922    /// running a second search. Absent whenever there were results, or when the
923    /// search was not a whole-identifier one.
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub substring_hint_count: Option<usize>,
926    /// Things the engine did to the query that the caller should know about.
927    ///
928    /// Today this is one message at most: a whole-identifier pattern containing
929    /// brackets was escaped and run as a regex (see
930    /// `query::prepare_literal_pattern`). Every surface — CLI, MCP, HTTP — carries it,
931    /// so a rewrite is never silent on any of them.
932    #[serde(default, skip_serializing_if = "Vec::is_empty")]
933    pub warnings: Vec<String>,
934    /// For a whole-identifier search that found nothing while substring matches
935    /// exist: a ready-to-show sentence naming the count and the switch that shows
936    /// them. Absent whenever there were results, or when the search was not a
937    /// whole-identifier one.
938    #[serde(default, skip_serializing_if = "Option::is_none")]
939    pub hint: Option<String>,
940    /// Machine-readable cause behind `hint`: `hidden` (the filter names a
941    /// dot-directory), `not_indexed` (the `file` filter names a path the index does
942    /// not hold), `lock_or_generated`, `whole_identifier`. Absent when no rule
943    /// applies, so a harness can branch without parsing prose.
944    #[serde(default, skip_serializing_if = "Option::is_none")]
945    pub excluded_reason: Option<crate::query::ExcludedReason>,
946    /// For a search that found nothing: how many candidate files were lock or
947    /// generated files, which every search leaves out unless `include_locks` /
948    /// `include_generated` (or `lang`) asks for them. The `hint` says so too.
949    #[serde(default, skip_serializing_if = "Option::is_none")]
950    pub excluded_by_default: Option<usize>,
951    /// Count-only searches: the number of files with at least one match. `results`
952    /// is empty in that mode, so this is the only place the file count lives.
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub file_count: Option<usize>,
955    /// Per-phase timings, only when requested.
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub timings: Option<QueryTimings>,
958}
959
960/// Report from cache compaction operation
961#[derive(Debug, Clone, Serialize, Deserialize)]
962pub struct CompactionReport {
963    /// Number of files removed
964    pub files_removed: usize,
965    /// Space saved in bytes
966    pub space_saved_bytes: u64,
967    /// Duration in milliseconds
968    pub duration_ms: u64,
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    #[test]
976    fn test_symbol_ref_json_shape() {
977        let sym = SymbolRef {
978            name: "my_function".to_string(),
979            kind: SymbolKind::Function,
980            span: Span {
981                start_line: 10,
982                end_line: 20,
983            },
984        };
985        let json = serde_json::to_value(&sym).unwrap();
986        assert_eq!(json["name"], "my_function");
987        assert_eq!(json["kind"], "Function");
988        assert_eq!(json["span"]["start_line"], 10);
989        assert_eq!(json["span"]["end_line"], 20);
990        assert!(json.as_array().is_none());
991    }
992
993    #[test]
994    fn test_symbol_ref_roundtrip() {
995        let original = SymbolRef {
996            name: "MyStruct".to_string(),
997            kind: SymbolKind::Struct,
998            span: Span {
999                start_line: 1,
1000                end_line: 5,
1001            },
1002        };
1003        let json = serde_json::to_string(&original).unwrap();
1004        let decoded: SymbolRef = serde_json::from_str(&json).unwrap();
1005        assert_eq!(original, decoded);
1006    }
1007
1008    #[test]
1009    fn test_symbol_ref_exact_json() {
1010        let sym = SymbolRef {
1011            name: "Foo".to_string(),
1012            kind: SymbolKind::Class,
1013            span: Span {
1014                start_line: 3,
1015                end_line: 7,
1016            },
1017        };
1018        let json = serde_json::to_string(&sym).unwrap();
1019        assert_eq!(
1020            json,
1021            r#"{"name":"Foo","kind":"Class","span":{"start_line":3,"end_line":7}}"#
1022        );
1023    }
1024}