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    Unknown,
80}
81
82impl Language {
83    pub fn from_extension(ext: &str) -> Self {
84        match ext {
85            "rs" => Language::Rust,
86            "py" => Language::Python,
87            "js" | "mjs" | "cjs" | "jsx" => Language::JavaScript,
88            "ts" | "mts" | "cts" | "tsx" => Language::TypeScript,
89            "vue" => Language::Vue,
90            "svelte" => Language::Svelte,
91            "go" => Language::Go,
92            "java" => Language::Java,
93            "php" => Language::PHP,
94            "c" | "h" => Language::C,
95            "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "C" | "H" => Language::Cpp,
96            "cs" => Language::CSharp,
97            "rb" | "rake" | "gemspec" => Language::Ruby,
98            "kt" | "kts" => Language::Kotlin,
99            "swift" => Language::Swift,
100            "zig" => Language::Zig,
101            _ => Language::Unknown,
102        }
103    }
104
105    /// Parse a language from a human-friendly name (CLI/API input)
106    ///
107    /// Accepts lowercase names and common aliases.
108    /// Returns None for unrecognized names.
109    pub fn from_name(name: &str) -> Option<Self> {
110        match name.to_lowercase().as_str() {
111            "rust" | "rs" => Some(Language::Rust),
112            "python" | "py" => Some(Language::Python),
113            "javascript" | "js" => Some(Language::JavaScript),
114            "typescript" | "ts" => Some(Language::TypeScript),
115            "vue" => Some(Language::Vue),
116            "svelte" => Some(Language::Svelte),
117            "go" => Some(Language::Go),
118            "java" => Some(Language::Java),
119            "php" => Some(Language::PHP),
120            "c" => Some(Language::C),
121            "cpp" | "c++" => Some(Language::Cpp),
122            "csharp" | "cs" | "c#" => Some(Language::CSharp),
123            "ruby" | "rb" => Some(Language::Ruby),
124            "kotlin" | "kt" => Some(Language::Kotlin),
125            "zig" => Some(Language::Zig),
126            _ => None,
127        }
128    }
129
130    /// Human-readable list of all supported language names (for error messages)
131    pub fn supported_names_help() -> &'static str {
132        "rust (rs), python (py), javascript (js), typescript (ts), vue, svelte, \
133         go, java, php, c, cpp (c++), csharp (cs, c#), ruby (rb), kotlin (kt), zig"
134    }
135
136    /// Check if this language has a parser implementation
137    ///
138    /// Returns true only for languages with working Tree-sitter parsers.
139    /// This determines which files will be indexed by Reflex.
140    pub fn is_supported(&self) -> bool {
141        match self {
142            Language::Rust => true,
143            Language::TypeScript => true,
144            Language::JavaScript => true,
145            Language::Vue => true,
146            Language::Svelte => true,
147            Language::Python => true,
148            Language::Go => true,
149            Language::Java => true,
150            Language::PHP => true,
151            Language::C => true,
152            Language::Cpp => true,
153            Language::CSharp => true,
154            Language::Ruby => true,
155            Language::Kotlin => true,
156            Language::Swift => false, // Temporarily disabled - parser queries out of date with tree-sitter-swift 0.7.x grammar
157            Language::Zig => true,
158            Language::Unknown => false,
159        }
160    }
161}
162
163/// Type of import/dependency
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165#[serde(rename_all = "lowercase")]
166pub enum ImportType {
167    /// Internal project file
168    Internal,
169    /// External library/package
170    External,
171    /// Standard library
172    Stdlib,
173    /// Rust `mod foo;` declaration (parent→child ownership, not a usage edge)
174    #[serde(rename = "mod_decl")]
175    ModDecl,
176}
177
178/// Dependency information for API output (simplified, path-based)
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct DependencyInfo {
181    /// Import path as written in source (or resolved path for internal deps)
182    pub path: String,
183    /// Line number where import appears (optional)
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub line: Option<usize>,
186    /// Imported symbols (for selective imports like `from x import a, b`)
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub symbols: Option<Vec<String>>,
189}
190
191/// Full dependency record (internal representation with file IDs)
192#[derive(Debug, Clone)]
193pub struct Dependency {
194    /// Source file ID
195    pub file_id: i64,
196    /// Import path as written in source code
197    pub imported_path: String,
198    /// Resolved file ID (None if external or stdlib)
199    pub resolved_file_id: Option<i64>,
200    /// Import type classification
201    pub import_type: ImportType,
202    /// Line number where import appears
203    pub line_number: usize,
204    /// Imported symbols (for selective imports)
205    pub imported_symbols: Option<Vec<String>>,
206}
207
208/// A lightweight, stable reference to a code symbol for API responses
209///
210/// Prefer this over `(String, SymbolKind, Span)` tuples — tuples serialize as
211/// positional JSON arrays, making any field addition a breaking change.
212/// Named fields here are additive-safe: new optional fields can be added without
213/// shifting positions or bumping the version.
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215pub struct SymbolRef {
216    /// Symbol name (e.g., function name, class name)
217    pub name: String,
218    /// Symbol kind (function, class, struct, etc.)
219    pub kind: SymbolKind,
220    /// Location span in source file
221    pub span: Span,
222}
223
224/// Helper function to skip serializing "Unknown" symbol kinds
225fn is_unknown_kind(kind: &SymbolKind) -> bool {
226    matches!(kind, SymbolKind::Unknown(_))
227}
228
229/// A search result representing a symbol or code location
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct SearchResult {
232    /// Absolute or relative path to the file
233    pub path: String,
234    /// Detected programming language (internal use only, not serialized to save tokens)
235    #[serde(skip)]
236    pub lang: Language,
237    /// Type of symbol found (only included for symbol searches, not text matches)
238    #[serde(skip_serializing_if = "is_unknown_kind")]
239    pub kind: SymbolKind,
240    /// Symbol name (e.g., function name, class name)
241    /// None for text/regex matches where symbol name cannot be accurately determined
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub symbol: Option<String>,
244    /// Location span in the source file
245    pub span: Span,
246    /// Code preview (few lines around the match)
247    pub preview: String,
248    /// File dependencies (only populated when --dependencies flag is used)
249    /// DEPRECATED: Use FileGroupedResult.dependencies instead for file-level grouping
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub dependencies: Option<Vec<DependencyInfo>>,
252}
253
254/// An individual match within a file (no path or dependencies)
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct MatchResult {
257    /// Type of symbol found (only included for symbol searches, not text matches)
258    #[serde(skip_serializing_if = "is_unknown_kind")]
259    pub kind: SymbolKind,
260    /// Symbol name (e.g., function name, class name)
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub symbol: Option<String>,
263    /// Location span in the source file
264    pub span: Span,
265    /// Code preview (few lines around the match)
266    pub preview: String,
267    /// Lines of code before the match (for context)
268    #[serde(skip_serializing_if = "Vec::is_empty")]
269    pub context_before: Vec<String>,
270    /// Lines of code after the match (for context)
271    #[serde(skip_serializing_if = "Vec::is_empty")]
272    pub context_after: Vec<String>,
273}
274
275/// File-level grouped results with dependencies at file level
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct FileGroupedResult {
278    /// Absolute or relative path to the file
279    pub path: String,
280    /// Detected programming language of this file (e.g. "rust", "python", "unknown")
281    pub language: Language,
282    /// File dependencies (only populated when --dependencies flag is used)
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub dependencies: Option<Vec<DependencyInfo>>,
285    /// Individual matches within this file
286    pub matches: Vec<MatchResult>,
287}
288
289impl SearchResult {
290    pub fn new(
291        path: String,
292        lang: Language,
293        kind: SymbolKind,
294        symbol: Option<String>,
295        span: Span,
296        scope: Option<String>,
297        preview: String,
298    ) -> Self {
299        // Ignore scope parameter for backwards compatibility
300        let _ = scope;
301        Self {
302            path,
303            lang,
304            kind,
305            symbol,
306            span,
307            preview,
308            dependencies: None,
309        }
310    }
311}
312
313/// Configuration for indexing behavior
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct IndexConfig {
316    /// Languages to include (empty = all supported)
317    pub languages: Vec<Language>,
318    /// Glob patterns to include
319    pub include_patterns: Vec<String>,
320    /// Glob patterns to exclude
321    pub exclude_patterns: Vec<String>,
322    /// Follow symbolic links
323    pub follow_symlinks: bool,
324    /// Maximum file size to index (bytes)
325    pub max_file_size: usize,
326    /// Number of threads for parallel indexing (0 = auto, 80% of available cores)
327    pub parallel_threads: usize,
328    /// Query timeout in seconds (0 = no timeout)
329    pub query_timeout_secs: u64,
330    /// Maximum entries per trigram posting list (0 = unlimited).
331    /// High-frequency trigrams are truncated at this threshold to bound query latency.
332    pub max_posting_list_entries: usize,
333    /// How long `Indexer::index` waits for `.reflex/index.lock` when another
334    /// indexer holds it (seconds). 0 = fail immediately with `IndexLocked`.
335    /// The `rfx index` CLI waits; MCP, watcher and HTTP callers fail fast.
336    #[serde(default, skip_serializing_if = "is_zero_u64")]
337    pub lock_wait_secs: u64,
338}
339
340impl Default for IndexConfig {
341    fn default() -> Self {
342        Self {
343            languages: vec![],
344            include_patterns: vec![],
345            exclude_patterns: vec![],
346            follow_symlinks: false,
347            max_file_size: 10 * 1024 * 1024,   // 10 MB
348            parallel_threads: 0,               // 0 = auto (80% of available cores)
349            query_timeout_secs: 30,            // 30 seconds default timeout
350            max_posting_list_entries: 500_000, // cap at 500k to bound query latency
351            lock_wait_secs: 0,                 // fail fast when another indexer runs
352        }
353    }
354}
355
356fn is_zero(v: &usize) -> bool {
357    *v == 0
358}
359fn is_zero_u64(v: &u64) -> bool {
360    *v == 0
361}
362
363/// Statistics about the index
364#[derive(Debug, Clone, Serialize, Deserialize, Default)]
365pub struct IndexStats {
366    /// Total files indexed
367    pub total_files: usize,
368    /// Index size on disk (bytes)
369    pub index_size_bytes: u64,
370    /// Last update timestamp
371    pub last_updated: String,
372    /// File count breakdown by language
373    pub files_by_language: std::collections::HashMap<String, usize>,
374    /// Line count breakdown by language
375    pub lines_by_language: std::collections::HashMap<String, usize>,
376    /// New files added since last index run (0 if not an incremental run)
377    #[serde(default, skip_serializing_if = "is_zero")]
378    pub new_files: usize,
379    /// Modified files re-indexed since last run (0 if not an incremental run)
380    #[serde(default, skip_serializing_if = "is_zero")]
381    pub modified_files: usize,
382    /// Unchanged files (same hash as last run, still re-indexed due to other changes)
383    #[serde(default, skip_serializing_if = "is_zero")]
384    pub unchanged_files: usize,
385    /// Files skipped because they exceeded max_file_size
386    #[serde(default, skip_serializing_if = "is_zero")]
387    pub skipped_too_large: usize,
388    /// Total bytes of files skipped due to max_file_size
389    #[serde(default, skip_serializing_if = "is_zero_u64")]
390    pub skipped_bytes_too_large: u64,
391}
392
393/// Information about an indexed file
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct IndexedFile {
396    /// File path
397    pub path: String,
398    /// Detected language
399    pub language: String,
400    /// Last indexed timestamp
401    pub last_indexed: String,
402}
403
404/// Index status for query responses
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406#[serde(rename_all = "snake_case")]
407pub enum IndexStatus {
408    /// Index is fresh and up-to-date
409    Fresh,
410    /// Index is stale (any issue: branch not indexed, commit changed, files modified)
411    Stale,
412}
413
414/// Warning details when index is stale
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct IndexWarning {
417    /// Human-readable reason why index is stale
418    pub reason: String,
419    /// Command to run to fix the issue
420    pub action_required: String,
421    /// Number of files detected as modified (only set for mtime-based staleness)
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub files_modified: Option<u32>,
424    /// Additional context (git branch info, etc.)
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub details: Option<IndexWarningDetails>,
427}
428
429/// Detailed information about index staleness
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct IndexWarningDetails {
432    /// Current branch (if in git repo)
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub current_branch: Option<String>,
435    /// Indexed branch (if in git repo)
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub indexed_branch: Option<String>,
438    /// Current commit SHA (if in git repo)
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub current_commit: Option<String>,
441    /// Indexed commit SHA (if in git repo)
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub indexed_commit: Option<String>,
444}
445
446/// Pagination information for query results
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct PaginationInfo {
449    /// Total number of results (before offset/limit applied)
450    pub total: usize,
451    /// Number of results in this response (after offset/limit)
452    pub count: usize,
453    /// Offset used (starting position)
454    pub offset: usize,
455    /// Limit used (max results per page)
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub limit: Option<usize>,
458    /// Whether there are more results after this page
459    pub has_more: bool,
460}
461
462/// Query response with results and index status
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct QueryResponse {
465    /// AI-optimized instruction for how to handle these results
466    /// Only present when --ai flag is used or in MCP mode
467    /// Provides guidance to AI agents on response format and next actions
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub ai_instruction: Option<String>,
470    /// Status of the index (fresh or stale)
471    pub status: IndexStatus,
472    /// Whether the results can be trusted
473    pub can_trust_results: bool,
474    /// Warning information (only present if stale)
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub warning: Option<IndexWarning>,
477    /// Pagination information
478    pub pagination: PaginationInfo,
479    /// File-grouped search results
480    /// Results are always grouped by file path, with dependencies populated when --dependencies flag is used
481    pub results: Vec<FileGroupedResult>,
482}
483
484/// Report from cache compaction operation
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct CompactionReport {
487    /// Number of files removed
488    pub files_removed: usize,
489    /// Space saved in bytes
490    pub space_saved_bytes: u64,
491    /// Duration in milliseconds
492    pub duration_ms: u64,
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_symbol_ref_json_shape() {
501        let sym = SymbolRef {
502            name: "my_function".to_string(),
503            kind: SymbolKind::Function,
504            span: Span {
505                start_line: 10,
506                end_line: 20,
507            },
508        };
509        let json = serde_json::to_value(&sym).unwrap();
510        assert_eq!(json["name"], "my_function");
511        assert_eq!(json["kind"], "Function");
512        assert_eq!(json["span"]["start_line"], 10);
513        assert_eq!(json["span"]["end_line"], 20);
514        assert!(json.as_array().is_none());
515    }
516
517    #[test]
518    fn test_symbol_ref_roundtrip() {
519        let original = SymbolRef {
520            name: "MyStruct".to_string(),
521            kind: SymbolKind::Struct,
522            span: Span {
523                start_line: 1,
524                end_line: 5,
525            },
526        };
527        let json = serde_json::to_string(&original).unwrap();
528        let decoded: SymbolRef = serde_json::from_str(&json).unwrap();
529        assert_eq!(original, decoded);
530    }
531
532    #[test]
533    fn test_symbol_ref_exact_json() {
534        let sym = SymbolRef {
535            name: "Foo".to_string(),
536            kind: SymbolKind::Class,
537            span: Span {
538                start_line: 3,
539                end_line: 7,
540            },
541        };
542        let json = serde_json::to_string(&sym).unwrap();
543        assert_eq!(
544            json,
545            r#"{"name":"Foo","kind":"Class","span":{"start_line":3,"end_line":7}}"#
546        );
547    }
548}