Skip to main content

normalize_syntax_rules/
sources.rs

1//! Rule source system for conditional rule evaluation.
2//!
3//! Sources provide data that rules can use in `requires` predicates:
4//! ```toml
5//! requires = { rust.edition = ">=2024" }
6//! requires = { rust.is_test_file = "!" }  # exclude test files
7//! requires = { env.CI = "true" }
8//! requires = { path.matches = "**/tests/**" }
9//! ```
10//!
11//! Built-in sources:
12//! - `path` - file path matching (glob patterns)
13//! - `env` - environment variables
14//! - `git` - repository state (branch, staged, dirty)
15//! - `config` - .normalize/config.toml values
16//! - Language sources: `rust`, `typescript`, `python`, `go`, etc.
17
18use std::collections::HashMap;
19use std::path::Path;
20
21/// Parse a simple TOML value from ` = "value"` or ` = 'value'`.
22/// Used for quick line-based parsing of config files.
23fn parse_toml_value(rest: &str) -> Option<String> {
24    let rest = rest.trim();
25    let rest = rest.strip_prefix('=')?;
26    let rest = rest.trim();
27
28    // Handle quoted strings
29    if let Some(rest) = rest.strip_prefix('"') {
30        return rest.strip_suffix('"').map(|s| s.to_string());
31    }
32    if let Some(rest) = rest.strip_prefix('\'') {
33        return rest.strip_suffix('\'').map(|s| s.to_string());
34    }
35
36    // Handle unquoted values (numbers, etc.)
37    Some(rest.to_string())
38}
39
40/// Context passed to sources for evaluation.
41pub struct SourceContext<'a> {
42    /// Absolute path to the file being analyzed.
43    pub file_path: &'a Path,
44    /// Path relative to project root.
45    pub rel_path: &'a str,
46    /// Project root directory.
47    pub project_root: &'a Path,
48}
49
50/// A source of data for rule conditionals.
51///
52/// Each source owns a namespace (e.g., "rust", "env", "path") and provides
53/// key-value data that rules can query in `requires` predicates.
54pub trait RuleSource: Send + Sync {
55    /// The namespace this source provides (e.g., "rust", "env", "path").
56    fn namespace(&self) -> &str;
57
58    /// Evaluate the source for a given file context.
59    ///
60    /// Returns a map of key-value pairs available under this namespace.
61    /// For example, RustSource might return `{"edition": "2024", "resolver": "2"}`.
62    ///
63    /// Returns `None` if this source doesn't apply to the given file
64    /// (e.g., RustSource returns None for Python files).
65    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>>;
66}
67
68/// Registry of all available rule sources.
69#[derive(Default)]
70pub struct SourceRegistry {
71    sources: Vec<Box<dyn RuleSource>>,
72}
73
74impl SourceRegistry {
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Register a source. Sources are evaluated in registration order.
80    pub fn register(&mut self, source: Box<dyn RuleSource>) {
81        self.sources.push(source);
82    }
83
84    /// Get a specific value by full key (e.g., "rust.edition").
85    pub fn get(&self, ctx: &SourceContext, key: &str) -> Option<String> {
86        // Parse namespace.key
87        let (ns, field) = key.split_once('.')?;
88
89        for source in &self.sources {
90            if source.namespace() == ns
91                && let Some(values) = source.evaluate(ctx)
92            {
93                return values.get(field).cloned();
94            }
95        }
96        None
97    }
98}
99
100// ============================================================================
101// Built-in sources
102// ============================================================================
103
104/// Environment variable source.
105///
106/// Provides `env.VAR_NAME` for any environment variable.
107pub struct EnvSource;
108
109impl RuleSource for EnvSource {
110    fn namespace(&self) -> &str {
111        "env"
112    }
113
114    fn evaluate(&self, _ctx: &SourceContext) -> Option<HashMap<String, String>> {
115        // Return all env vars - could be optimized to lazy evaluation
116        Some(std::env::vars().collect())
117    }
118}
119
120/// Path-based source for glob matching.
121///
122/// Provides `path.matches` for checking if file matches a pattern.
123/// Note: This is evaluated specially since it needs the pattern from requires.
124pub struct PathSource;
125
126impl RuleSource for PathSource {
127    fn namespace(&self) -> &str {
128        "path"
129    }
130
131    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
132        let mut result = HashMap::new();
133        result.insert("rel".to_string(), ctx.rel_path.to_string());
134        result.insert(
135            "abs".to_string(),
136            ctx.file_path.to_string_lossy().to_string(),
137        );
138        if let Some(ext) = ctx.file_path.extension() {
139            result.insert("ext".to_string(), ext.to_string_lossy().to_string());
140        }
141        if let Some(name) = ctx.file_path.file_name() {
142            result.insert("filename".to_string(), name.to_string_lossy().to_string());
143        }
144        Some(result)
145    }
146}
147
148/// Git repository state source.
149///
150/// Provides `git.branch`, `git.dirty`, `git.staged`.
151pub struct GitSource;
152
153impl RuleSource for GitSource {
154    fn namespace(&self) -> &str {
155        "git"
156    }
157
158    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
159        let mut result = HashMap::new();
160
161        // Get current branch via gix (no PATH dependency)
162        if let Some(branch) = gix::discover(ctx.project_root).ok().and_then(|repo| {
163            repo.head().ok().and_then(|head| {
164                head.referent_name().map(|name| {
165                    let b = name.as_bstr();
166                    b.strip_prefix(b"refs/heads/")
167                        .map(|s| String::from_utf8_lossy(s).into_owned())
168                        .unwrap_or_else(|| String::from_utf8_lossy(b).into_owned())
169                })
170            })
171        }) {
172            result.insert("branch".to_string(), branch);
173        }
174
175        // Check staged and dirty state via gix (no PATH dependency).
176        if let Ok(repo) = gix::discover(ctx.project_root) {
177            // is_dirty: any staged or unstaged change in the repo.
178            let is_dirty = repo.is_dirty().unwrap_or(false);
179            result.insert("dirty".to_string(), is_dirty.to_string());
180
181            // is_staged: the specific file appears in `git diff --cached` (index vs HEAD).
182            // Compare the file's blob in the HEAD tree vs the index — if they differ,
183            // the file is staged.
184            let is_staged = (|| -> Option<bool> {
185                let head_id = repo.head_id().ok()?;
186                let head_commit = head_id.object().ok()?.into_commit();
187                let head_tree = head_commit.tree().ok()?;
188                // Look up the file in HEAD tree and the index; compare blob ids.
189                let entry_in_head = head_tree.lookup_entry_by_path(ctx.rel_path).ok().flatten();
190                let head_blob_id = entry_in_head.map(|e| e.id().detach());
191                let index = repo.index_or_empty().ok()?;
192                let rel_bstr: &gix::bstr::BStr = ctx.rel_path.as_bytes().into();
193                let index_blob_id = index.entry_by_path(rel_bstr).map(|e| e.id);
194                // Staged if the file exists in index but not HEAD, or blobs differ.
195                Some(index_blob_id != head_blob_id)
196            })()
197            .unwrap_or(false);
198            result.insert("staged".to_string(), is_staged.to_string());
199        }
200
201        Some(result)
202    }
203}
204
205/// Rust project source - parses Cargo.toml for edition, resolver, etc.
206///
207/// Provides:
208/// - `rust.edition`, `rust.resolver`, `rust.name`, `rust.version` - from Cargo.toml
209/// - `rust.is_test_file` - true if file is in tests/, named *_test.rs, or has top-level #[cfg(test)]
210pub struct RustSource;
211
212impl RustSource {
213    /// Find the nearest Cargo.toml for a given file path.
214    fn find_cargo_toml(file_path: &Path) -> Option<std::path::PathBuf> {
215        let mut current = file_path.parent()?;
216        loop {
217            let cargo_toml = current.join("Cargo.toml");
218            if cargo_toml.exists() {
219                return Some(cargo_toml);
220            }
221            current = current.parent()?;
222        }
223    }
224
225    /// Find the workspace root Cargo.toml (the one with [workspace] section).
226    fn find_workspace_root(start: &Path) -> Option<std::path::PathBuf> {
227        let mut current = start.parent()?;
228        loop {
229            let cargo_toml = current.join("Cargo.toml");
230            if cargo_toml.exists()
231                && let Ok(content) = std::fs::read_to_string(&cargo_toml)
232                && let Ok(parsed) = content.parse::<toml::Table>()
233                && parsed.contains_key("workspace")
234            {
235                return Some(cargo_toml);
236            }
237            current = current.parent()?;
238        }
239    }
240
241    /// Parse Cargo.toml, resolving workspace inheritance.
242    fn parse_cargo_toml(cargo_toml_path: &Path) -> HashMap<String, String> {
243        let mut result = HashMap::new();
244
245        let content = match std::fs::read_to_string(cargo_toml_path) {
246            Ok(c) => c,
247            Err(_) => return result,
248        };
249
250        let parsed: toml::Table = match content.parse() {
251            Ok(t) => t,
252            Err(_) => return result,
253        };
254
255        // Get package table
256        let package = match parsed.get("package").and_then(|v| v.as_table()) {
257            Some(p) => p,
258            None => return result,
259        };
260
261        // Keys we're interested in
262        let keys = ["edition", "resolver", "name", "version"];
263
264        // Try to get workspace values lazily (only if needed)
265        let mut workspace_package: Option<&toml::Table> = None;
266        let mut workspace_parsed: Option<toml::Table> = None;
267
268        for key in keys {
269            if let Some(value) = package.get(key) {
270                // Check for workspace inheritance: { workspace = true }
271                if let Some(table) = value.as_table()
272                    && table.get("workspace").and_then(|v| v.as_bool()) == Some(true)
273                {
274                    // Lazily load workspace Cargo.toml
275                    if workspace_package.is_none() {
276                        if let Some(ws_path) = Self::find_workspace_root(cargo_toml_path)
277                            && let Ok(ws_content) = std::fs::read_to_string(&ws_path)
278                            && let Ok(ws_parsed) = ws_content.parse::<toml::Table>()
279                        {
280                            workspace_parsed = Some(ws_parsed);
281                        }
282                        workspace_package = workspace_parsed
283                            .as_ref()
284                            .and_then(|ws| ws.get("workspace"))
285                            .and_then(|w| w.as_table())
286                            .and_then(|w| w.get("package"))
287                            .and_then(|p| p.as_table());
288                    }
289
290                    // Get value from workspace
291                    if let Some(ws_pkg) = workspace_package
292                        && let Some(ws_value) = ws_pkg.get(key)
293                    {
294                        if let Some(s) = ws_value.as_str() {
295                            result.insert(key.to_string(), s.to_string());
296                        } else if let Some(i) = ws_value.as_integer() {
297                            result.insert(key.to_string(), i.to_string());
298                        }
299                    }
300                    continue;
301                }
302
303                // Direct value
304                if let Some(s) = value.as_str() {
305                    result.insert(key.to_string(), s.to_string());
306                } else if let Some(i) = value.as_integer() {
307                    result.insert(key.to_string(), i.to_string());
308                }
309            }
310        }
311
312        result
313    }
314
315    /// Detect if a file is a test file based on path patterns and content.
316    fn is_test_file(ctx: &SourceContext) -> bool {
317        // Path-based detection via Language trait
318        if normalize_languages::is_test_path(ctx.file_path) {
319            return true;
320        }
321
322        // Content-based: catch Rust files that are primarily test code via inline #[cfg(test)]
323        if let Ok(content) = std::fs::read_to_string(ctx.file_path) {
324            for line in content.lines().take(50) {
325                if line.trim().starts_with("#[cfg(test)]") {
326                    return true;
327                }
328            }
329        }
330
331        false
332    }
333}
334
335impl RuleSource for RustSource {
336    fn namespace(&self) -> &str {
337        "rust"
338    }
339
340    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
341        // Only apply to Rust files
342        let ext = ctx.file_path.extension()?;
343        if ext != "rs" {
344            return None;
345        }
346
347        // Find nearest Cargo.toml
348        let cargo_toml = Self::find_cargo_toml(ctx.file_path);
349
350        let mut result = cargo_toml
351            .map(|p| Self::parse_cargo_toml(&p))
352            .unwrap_or_default();
353
354        // Detect test files
355        result.insert(
356            "is_test_file".to_string(),
357            Self::is_test_file(ctx).to_string(),
358        );
359
360        Some(result)
361    }
362}
363
364/// TypeScript/JavaScript project source - parses tsconfig.json and package.json.
365///
366/// Provides `typescript.target`, `typescript.module`, `typescript.strict`, `node.version`,
367/// `typescript.is_test_file`.
368pub struct TypeScriptSource;
369
370impl TypeScriptSource {
371    /// Find the nearest tsconfig.json for a given file path.
372    fn find_tsconfig(file_path: &Path) -> Option<std::path::PathBuf> {
373        let mut current = file_path.parent()?;
374        loop {
375            let tsconfig = current.join("tsconfig.json");
376            if tsconfig.exists() {
377                return Some(tsconfig);
378            }
379            current = current.parent()?;
380        }
381    }
382
383    /// Find the nearest package.json for a given file path.
384    fn find_package_json(file_path: &Path) -> Option<std::path::PathBuf> {
385        let mut current = file_path.parent()?;
386        loop {
387            let pkg = current.join("package.json");
388            if pkg.exists() {
389                return Some(pkg);
390            }
391            current = current.parent()?;
392        }
393    }
394
395    /// Parse tsconfig.json for compilerOptions.
396    fn parse_tsconfig(content: &str) -> HashMap<String, String> {
397        let mut result = HashMap::new();
398
399        // Simple JSON parsing for key fields in compilerOptions
400        // Look for "target", "module", "strict", "moduleResolution"
401        for line in content.lines() {
402            let line = line.trim();
403
404            if let Some(value) = Self::extract_json_string(line, "target") {
405                result.insert("target".to_string(), value);
406            } else if let Some(value) = Self::extract_json_string(line, "module") {
407                result.insert("module".to_string(), value);
408            } else if let Some(value) = Self::extract_json_string(line, "moduleResolution") {
409                result.insert("moduleResolution".to_string(), value);
410            } else if line.contains("\"strict\"") {
411                if line.contains("true") {
412                    result.insert("strict".to_string(), "true".to_string());
413                } else if line.contains("false") {
414                    result.insert("strict".to_string(), "false".to_string());
415                }
416            }
417        }
418
419        result
420    }
421
422    /// Parse package.json for engines.node.
423    fn parse_package_json(content: &str) -> HashMap<String, String> {
424        let mut result = HashMap::new();
425
426        // Look for engines.node version
427        let mut in_engines = false;
428        for line in content.lines() {
429            let line = line.trim();
430            if line.contains("\"engines\"") {
431                in_engines = true;
432            } else if in_engines {
433                if line.starts_with('}') {
434                    in_engines = false;
435                } else if let Some(value) = Self::extract_json_string(line, "node") {
436                    result.insert("node_version".to_string(), value);
437                }
438            }
439
440            // Also get name and version
441            if let Some(value) = Self::extract_json_string(line, "name")
442                && !result.contains_key("name")
443            {
444                result.insert("name".to_string(), value);
445            }
446            if let Some(value) = Self::extract_json_string(line, "version")
447                && !result.contains_key("version")
448            {
449                result.insert("version".to_string(), value);
450            }
451        }
452
453        result
454    }
455
456    /// Extract a JSON string value: `"key": "value"` or `"key": "value",`
457    fn extract_json_string(line: &str, key: &str) -> Option<String> {
458        let pattern = format!("\"{}\"", key);
459        if !line.contains(&pattern) {
460            return None;
461        }
462
463        // Find the value after the colon
464        let colon_pos = line.find(':')?;
465        let after_colon = line[colon_pos + 1..].trim();
466
467        // Extract quoted string
468        if let Some(rest) = after_colon.strip_prefix('"') {
469            let end = rest.find('"')?;
470            return Some(rest[..end].to_string());
471        }
472
473        None
474    }
475}
476
477impl RuleSource for TypeScriptSource {
478    fn namespace(&self) -> &str {
479        "typescript"
480    }
481
482    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
483        // Only apply to TypeScript/JavaScript files
484        let ext = ctx.file_path.extension()?.to_string_lossy();
485        if !matches!(ext.as_ref(), "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs") {
486            return None;
487        }
488
489        let mut result = HashMap::new();
490
491        // Parse tsconfig.json if present
492        if let Some(tsconfig) = Self::find_tsconfig(ctx.file_path)
493            && let Ok(content) = std::fs::read_to_string(&tsconfig)
494        {
495            result.extend(Self::parse_tsconfig(&content));
496        }
497
498        // Parse package.json if present
499        if let Some(pkg_json) = Self::find_package_json(ctx.file_path)
500            && let Ok(content) = std::fs::read_to_string(&pkg_json)
501        {
502            result.extend(Self::parse_package_json(&content));
503        }
504
505        result.insert(
506            "is_test_file".to_string(),
507            normalize_languages::is_test_path(ctx.file_path).to_string(),
508        );
509
510        Some(result)
511    }
512}
513
514/// Python project source - parses pyproject.toml for project metadata.
515///
516/// Provides `python.version`, `python.name`, `python.is_test_file`.
517pub struct PythonSource;
518
519impl PythonSource {
520    /// Find the nearest pyproject.toml for a given file path.
521    fn find_pyproject(file_path: &Path) -> Option<std::path::PathBuf> {
522        let mut current = file_path.parent()?;
523        loop {
524            let pyproject = current.join("pyproject.toml");
525            if pyproject.exists() {
526                return Some(pyproject);
527            }
528            current = current.parent()?;
529        }
530    }
531
532    /// Parse pyproject.toml for project metadata.
533    fn parse_pyproject(content: &str) -> HashMap<String, String> {
534        let mut result = HashMap::new();
535
536        // Simple TOML parsing for key fields
537        // Look for requires-python, name, version
538        for line in content.lines() {
539            let line = line.trim();
540
541            if let Some(rest) = line.strip_prefix("requires-python")
542                && let Some(value) = parse_toml_value(rest)
543            {
544                // Strip comparison operators for the version
545                let version = value
546                    .trim_start_matches(">=")
547                    .trim_start_matches("<=")
548                    .trim_start_matches("==")
549                    .trim_start_matches('^')
550                    .trim_start_matches('~');
551                result.insert("requires_python".to_string(), version.to_string());
552            } else if let Some(rest) = line.strip_prefix("name")
553                && let Some(value) = parse_toml_value(rest)
554            {
555                result.insert("name".to_string(), value);
556            } else if let Some(rest) = line.strip_prefix("version")
557                && let Some(value) = parse_toml_value(rest)
558            {
559                result.insert("version".to_string(), value);
560            }
561        }
562
563        result
564    }
565}
566
567impl RuleSource for PythonSource {
568    fn namespace(&self) -> &str {
569        "python"
570    }
571
572    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
573        // Only apply to Python files
574        let ext = ctx.file_path.extension()?;
575        if ext != "py" {
576            return None;
577        }
578
579        let mut result = HashMap::new();
580
581        // Parse pyproject.toml if present
582        if let Some(pyproject) = Self::find_pyproject(ctx.file_path)
583            && let Ok(content) = std::fs::read_to_string(&pyproject)
584        {
585            result.extend(Self::parse_pyproject(&content));
586        }
587
588        result.insert(
589            "is_test_file".to_string(),
590            normalize_languages::is_test_path(ctx.file_path).to_string(),
591        );
592
593        Some(result)
594    }
595}
596
597/// Go project source - parses go.mod for module metadata.
598///
599/// Provides `go.version`, `go.module`, `go.is_test_file`.
600pub struct GoSource;
601
602impl GoSource {
603    /// Find the nearest go.mod for a given file path.
604    fn find_go_mod(file_path: &Path) -> Option<std::path::PathBuf> {
605        let mut current = file_path.parent()?;
606        loop {
607            let go_mod = current.join("go.mod");
608            if go_mod.exists() {
609                return Some(go_mod);
610            }
611            current = current.parent()?;
612        }
613    }
614
615    /// Parse go.mod for module metadata.
616    fn parse_go_mod(content: &str) -> HashMap<String, String> {
617        let mut result = HashMap::new();
618
619        for line in content.lines() {
620            let line = line.trim();
621
622            // module github.com/user/repo
623            if let Some(rest) = line.strip_prefix("module ") {
624                result.insert("module".to_string(), rest.trim().to_string());
625            }
626            // go 1.21
627            else if let Some(rest) = line.strip_prefix("go ") {
628                result.insert("version".to_string(), rest.trim().to_string());
629            }
630        }
631
632        result
633    }
634}
635
636impl RuleSource for GoSource {
637    fn namespace(&self) -> &str {
638        "go"
639    }
640
641    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
642        // Only apply to Go files
643        let ext = ctx.file_path.extension()?;
644        if ext != "go" {
645            return None;
646        }
647
648        let mut result = HashMap::new();
649
650        // Parse go.mod if present
651        if let Some(go_mod) = Self::find_go_mod(ctx.file_path)
652            && let Ok(content) = std::fs::read_to_string(&go_mod)
653        {
654            result.extend(Self::parse_go_mod(&content));
655        }
656
657        result.insert(
658            "is_test_file".to_string(),
659            normalize_languages::is_test_path(ctx.file_path).to_string(),
660        );
661
662        Some(result)
663    }
664}
665
666/// Create a registry with all built-in sources.
667pub fn builtin_registry() -> SourceRegistry {
668    let mut registry = SourceRegistry::new();
669    registry.register(Box::new(EnvSource));
670    registry.register(Box::new(PathSource));
671    registry.register(Box::new(GitSource));
672    registry.register(Box::new(RustSource));
673    registry.register(Box::new(TypeScriptSource));
674    registry.register(Box::new(PythonSource));
675    registry.register(Box::new(GoSource));
676    registry
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn test_env_source() {
685        // SAFETY: Test runs single-threaded, no concurrent env access
686        unsafe {
687            std::env::set_var("MOSS_TEST_VAR", "hello");
688        }
689
690        let ctx = SourceContext {
691            file_path: Path::new("/tmp/test.rs"),
692            rel_path: "test.rs",
693            project_root: Path::new("/tmp"),
694        };
695
696        let registry = builtin_registry();
697        let value = registry.get(&ctx, "env.MOSS_TEST_VAR");
698        assert_eq!(value, Some("hello".to_string()));
699
700        // SAFETY: Test cleanup
701        unsafe {
702            std::env::remove_var("MOSS_TEST_VAR");
703        }
704    }
705
706    #[test]
707    fn test_path_source() {
708        let ctx = SourceContext {
709            file_path: Path::new("/project/src/lib.rs"),
710            rel_path: "src/lib.rs",
711            project_root: Path::new("/project"),
712        };
713
714        let registry = builtin_registry();
715        assert_eq!(
716            registry.get(&ctx, "path.rel"),
717            Some("src/lib.rs".to_string())
718        );
719        assert_eq!(registry.get(&ctx, "path.ext"), Some("rs".to_string()));
720        assert_eq!(
721            registry.get(&ctx, "path.filename"),
722            Some("lib.rs".to_string())
723        );
724    }
725
726    #[test]
727    fn test_rust_source_parse_cargo_toml() {
728        let temp_dir = std::env::temp_dir().join("moss_test_cargo_toml");
729        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
730        std::fs::create_dir_all(&temp_dir).unwrap();
731        let cargo_path = temp_dir.join("Cargo.toml");
732        let content = r#"
733[package]
734name = "my-crate"
735version = "0.1.0"
736edition = "2024"
737resolver = "2"
738"#;
739        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
740        std::fs::write(&cargo_path, content).unwrap();
741        let result = RustSource::parse_cargo_toml(&cargo_path);
742        assert_eq!(result.get("name"), Some(&"my-crate".to_string()));
743        assert_eq!(result.get("version"), Some(&"0.1.0".to_string()));
744        assert_eq!(result.get("edition"), Some(&"2024".to_string()));
745        assert_eq!(result.get("resolver"), Some(&"2".to_string()));
746        std::fs::remove_dir_all(&temp_dir).ok();
747    }
748
749    #[test]
750    fn test_rust_source_real_file() {
751        // Test against this project's actual Cargo.toml
752        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
753        let file_path = manifest_dir.join("src/lib.rs");
754        let ctx = SourceContext {
755            file_path: &file_path,
756            rel_path: "src/lib.rs",
757            project_root: manifest_dir,
758        };
759
760        let registry = builtin_registry();
761        // Should find edition from Cargo.toml
762        let edition = registry.get(&ctx, "rust.edition");
763        assert!(edition.is_some(), "Should find rust.edition");
764    }
765
766    #[test]
767    fn test_typescript_source_parse_tsconfig() {
768        let content = r#"{
769  "compilerOptions": {
770    "target": "ES2020",
771    "module": "ESNext",
772    "strict": true,
773    "moduleResolution": "bundler"
774  }
775}"#;
776        let result = TypeScriptSource::parse_tsconfig(content);
777        assert_eq!(result.get("target"), Some(&"ES2020".to_string()));
778        assert_eq!(result.get("module"), Some(&"ESNext".to_string()));
779        assert_eq!(result.get("strict"), Some(&"true".to_string()));
780        assert_eq!(result.get("moduleResolution"), Some(&"bundler".to_string()));
781    }
782
783    #[test]
784    fn test_typescript_source_parse_package_json() {
785        let content = r#"{
786  "name": "my-app",
787  "version": "1.0.0",
788  "engines": {
789    "node": ">=18.0.0"
790  }
791}"#;
792        let result = TypeScriptSource::parse_package_json(content);
793        assert_eq!(result.get("name"), Some(&"my-app".to_string()));
794        assert_eq!(result.get("version"), Some(&"1.0.0".to_string()));
795        assert_eq!(result.get("node_version"), Some(&">=18.0.0".to_string()));
796    }
797
798    #[test]
799    fn test_python_source_parse_pyproject() {
800        let content = r#"
801[project]
802name = "my-package"
803version = "0.1.0"
804requires-python = ">=3.10"
805"#;
806        let result = PythonSource::parse_pyproject(content);
807        assert_eq!(result.get("name"), Some(&"my-package".to_string()));
808        assert_eq!(result.get("version"), Some(&"0.1.0".to_string()));
809        assert_eq!(result.get("requires_python"), Some(&"3.10".to_string()));
810    }
811
812    #[test]
813    fn test_go_source_parse_go_mod() {
814        let content = r#"module github.com/user/repo
815
816go 1.21
817
818require (
819    golang.org/x/text v0.3.0
820)"#;
821        let result = GoSource::parse_go_mod(content);
822        assert_eq!(
823            result.get("module"),
824            Some(&"github.com/user/repo".to_string())
825        );
826        assert_eq!(result.get("version"), Some(&"1.21".to_string()));
827    }
828
829    #[test]
830    fn test_rust_is_test_file() {
831        // Path-based detection: /tests/ directory
832        let ctx = SourceContext {
833            file_path: Path::new("/project/tests/integration.rs"),
834            rel_path: "tests/integration.rs",
835            project_root: Path::new("/project"),
836        };
837        assert!(RustSource::is_test_file(&ctx));
838
839        // Filename pattern: *_test.rs
840        let ctx = SourceContext {
841            file_path: Path::new("/project/src/foo_test.rs"),
842            rel_path: "src/foo_test.rs",
843            project_root: Path::new("/project"),
844        };
845        assert!(RustSource::is_test_file(&ctx));
846
847        // Filename pattern: test_*.rs
848        let ctx = SourceContext {
849            file_path: Path::new("/project/src/test_bar.rs"),
850            rel_path: "src/test_bar.rs",
851            project_root: Path::new("/project"),
852        };
853        assert!(RustSource::is_test_file(&ctx));
854
855        // Not a test file
856        let ctx = SourceContext {
857            file_path: Path::new("/project/src/lib.rs"),
858            rel_path: "src/lib.rs",
859            project_root: Path::new("/project"),
860        };
861        assert!(!RustSource::is_test_file(&ctx));
862    }
863}