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