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}
334
335impl Default for IndexConfig {
336    fn default() -> Self {
337        Self {
338            languages: vec![],
339            include_patterns: vec![],
340            exclude_patterns: vec![],
341            follow_symlinks: false,
342            max_file_size: 10 * 1024 * 1024,   // 10 MB
343            parallel_threads: 0,               // 0 = auto (80% of available cores)
344            query_timeout_secs: 30,            // 30 seconds default timeout
345            max_posting_list_entries: 500_000, // cap at 500k to bound query latency
346        }
347    }
348}
349
350fn is_zero(v: &usize) -> bool {
351    *v == 0
352}
353fn is_zero_u64(v: &u64) -> bool {
354    *v == 0
355}
356
357/// Statistics about the index
358#[derive(Debug, Clone, Serialize, Deserialize, Default)]
359pub struct IndexStats {
360    /// Total files indexed
361    pub total_files: usize,
362    /// Index size on disk (bytes)
363    pub index_size_bytes: u64,
364    /// Last update timestamp
365    pub last_updated: String,
366    /// File count breakdown by language
367    pub files_by_language: std::collections::HashMap<String, usize>,
368    /// Line count breakdown by language
369    pub lines_by_language: std::collections::HashMap<String, usize>,
370    /// New files added since last index run (0 if not an incremental run)
371    #[serde(default, skip_serializing_if = "is_zero")]
372    pub new_files: usize,
373    /// Modified files re-indexed since last run (0 if not an incremental run)
374    #[serde(default, skip_serializing_if = "is_zero")]
375    pub modified_files: usize,
376    /// Unchanged files (same hash as last run, still re-indexed due to other changes)
377    #[serde(default, skip_serializing_if = "is_zero")]
378    pub unchanged_files: usize,
379    /// Files skipped because they exceeded max_file_size
380    #[serde(default, skip_serializing_if = "is_zero")]
381    pub skipped_too_large: usize,
382    /// Total bytes of files skipped due to max_file_size
383    #[serde(default, skip_serializing_if = "is_zero_u64")]
384    pub skipped_bytes_too_large: u64,
385}
386
387/// Information about an indexed file
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct IndexedFile {
390    /// File path
391    pub path: String,
392    /// Detected language
393    pub language: String,
394    /// Last indexed timestamp
395    pub last_indexed: String,
396}
397
398/// Index status for query responses
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
400#[serde(rename_all = "snake_case")]
401pub enum IndexStatus {
402    /// Index is fresh and up-to-date
403    Fresh,
404    /// Index is stale (any issue: branch not indexed, commit changed, files modified)
405    Stale,
406}
407
408/// Warning details when index is stale
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct IndexWarning {
411    /// Human-readable reason why index is stale
412    pub reason: String,
413    /// Command to run to fix the issue
414    pub action_required: String,
415    /// Number of files detected as modified (only set for mtime-based staleness)
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub files_modified: Option<u32>,
418    /// Additional context (git branch info, etc.)
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub details: Option<IndexWarningDetails>,
421}
422
423/// Detailed information about index staleness
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct IndexWarningDetails {
426    /// Current branch (if in git repo)
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub current_branch: Option<String>,
429    /// Indexed branch (if in git repo)
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub indexed_branch: Option<String>,
432    /// Current commit SHA (if in git repo)
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub current_commit: Option<String>,
435    /// Indexed commit SHA (if in git repo)
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub indexed_commit: Option<String>,
438}
439
440/// Pagination information for query results
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct PaginationInfo {
443    /// Total number of results (before offset/limit applied)
444    pub total: usize,
445    /// Number of results in this response (after offset/limit)
446    pub count: usize,
447    /// Offset used (starting position)
448    pub offset: usize,
449    /// Limit used (max results per page)
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub limit: Option<usize>,
452    /// Whether there are more results after this page
453    pub has_more: bool,
454}
455
456/// Query response with results and index status
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct QueryResponse {
459    /// AI-optimized instruction for how to handle these results
460    /// Only present when --ai flag is used or in MCP mode
461    /// Provides guidance to AI agents on response format and next actions
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub ai_instruction: Option<String>,
464    /// Status of the index (fresh or stale)
465    pub status: IndexStatus,
466    /// Whether the results can be trusted
467    pub can_trust_results: bool,
468    /// Warning information (only present if stale)
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub warning: Option<IndexWarning>,
471    /// Pagination information
472    pub pagination: PaginationInfo,
473    /// File-grouped search results
474    /// Results are always grouped by file path, with dependencies populated when --dependencies flag is used
475    pub results: Vec<FileGroupedResult>,
476}
477
478/// Report from cache compaction operation
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct CompactionReport {
481    /// Number of files removed
482    pub files_removed: usize,
483    /// Space saved in bytes
484    pub space_saved_bytes: u64,
485    /// Duration in milliseconds
486    pub duration_ms: u64,
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn test_symbol_ref_json_shape() {
495        let sym = SymbolRef {
496            name: "my_function".to_string(),
497            kind: SymbolKind::Function,
498            span: Span {
499                start_line: 10,
500                end_line: 20,
501            },
502        };
503        let json = serde_json::to_value(&sym).unwrap();
504        assert_eq!(json["name"], "my_function");
505        assert_eq!(json["kind"], "Function");
506        assert_eq!(json["span"]["start_line"], 10);
507        assert_eq!(json["span"]["end_line"], 20);
508        assert!(json.as_array().is_none());
509    }
510
511    #[test]
512    fn test_symbol_ref_roundtrip() {
513        let original = SymbolRef {
514            name: "MyStruct".to_string(),
515            kind: SymbolKind::Struct,
516            span: Span {
517                start_line: 1,
518                end_line: 5,
519            },
520        };
521        let json = serde_json::to_string(&original).unwrap();
522        let decoded: SymbolRef = serde_json::from_str(&json).unwrap();
523        assert_eq!(original, decoded);
524    }
525
526    #[test]
527    fn test_symbol_ref_exact_json() {
528        let sym = SymbolRef {
529            name: "Foo".to_string(),
530            kind: SymbolKind::Class,
531            span: Span {
532                start_line: 3,
533                end_line: 7,
534            },
535        };
536        let json = serde_json::to_string(&sym).unwrap();
537        assert_eq!(
538            json,
539            r#"{"name":"Foo","kind":"Class","span":{"start_line":3,"end_line":7}}"#
540        );
541    }
542}