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}