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                if let Some(values) = source.evaluate(ctx) {
92                    return values.get(field).cloned();
93                }
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
162        if let Ok(output) = std::process::Command::new("git")
163            .args(["rev-parse", "--abbrev-ref", "HEAD"])
164            .current_dir(ctx.project_root)
165            .output()
166        {
167            if output.status.success() {
168                let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
169                result.insert("branch".to_string(), branch);
170            }
171        }
172
173        // Check if file is staged
174        if let Ok(output) = std::process::Command::new("git")
175            .args(["diff", "--cached", "--name-only"])
176            .current_dir(ctx.project_root)
177            .output()
178        {
179            if output.status.success() {
180                let staged = String::from_utf8_lossy(&output.stdout);
181                let is_staged = staged.lines().any(|l| l == ctx.rel_path);
182                result.insert("staged".to_string(), is_staged.to_string());
183            }
184        }
185
186        // Check if repo is dirty
187        if let Ok(output) = std::process::Command::new("git")
188            .args(["status", "--porcelain"])
189            .current_dir(ctx.project_root)
190            .output()
191        {
192            if output.status.success() {
193                let dirty = !output.stdout.is_empty();
194                result.insert("dirty".to_string(), dirty.to_string());
195            }
196        }
197
198        Some(result)
199    }
200}
201
202/// Rust project source - parses Cargo.toml for edition, resolver, etc.
203///
204/// Provides:
205/// - `rust.edition`, `rust.resolver`, `rust.name`, `rust.version` - from Cargo.toml
206/// - `rust.is_test_file` - true if file is in tests/, named *_test.rs, or has top-level #[cfg(test)]
207pub struct RustSource;
208
209impl RustSource {
210    /// Find the nearest Cargo.toml for a given file path.
211    fn find_cargo_toml(file_path: &Path) -> Option<std::path::PathBuf> {
212        let mut current = file_path.parent()?;
213        loop {
214            let cargo_toml = current.join("Cargo.toml");
215            if cargo_toml.exists() {
216                return Some(cargo_toml);
217            }
218            current = current.parent()?;
219        }
220    }
221
222    /// Find the workspace root Cargo.toml (the one with [workspace] section).
223    fn find_workspace_root(start: &Path) -> Option<std::path::PathBuf> {
224        let mut current = start.parent()?;
225        loop {
226            let cargo_toml = current.join("Cargo.toml");
227            if cargo_toml.exists() {
228                if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
229                    if let Ok(parsed) = content.parse::<toml::Table>() {
230                        if parsed.contains_key("workspace") {
231                            return Some(cargo_toml);
232                        }
233                    }
234                }
235            }
236            current = current.parent()?;
237        }
238    }
239
240    /// Parse Cargo.toml, resolving workspace inheritance.
241    fn parse_cargo_toml(cargo_toml_path: &Path) -> HashMap<String, String> {
242        let mut result = HashMap::new();
243
244        let content = match std::fs::read_to_string(cargo_toml_path) {
245            Ok(c) => c,
246            Err(_) => return result,
247        };
248
249        let parsed: toml::Table = match content.parse() {
250            Ok(t) => t,
251            Err(_) => return result,
252        };
253
254        // Get package table
255        let package = match parsed.get("package").and_then(|v| v.as_table()) {
256            Some(p) => p,
257            None => return result,
258        };
259
260        // Keys we're interested in
261        let keys = ["edition", "resolver", "name", "version"];
262
263        // Try to get workspace values lazily (only if needed)
264        let mut workspace_package: Option<&toml::Table> = None;
265        let mut workspace_parsed: Option<toml::Table> = None;
266
267        for key in keys {
268            if let Some(value) = package.get(key) {
269                // Check for workspace inheritance: { workspace = true }
270                if let Some(table) = value.as_table() {
271                    if table.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
272                        // Lazily load workspace Cargo.toml
273                        if workspace_package.is_none() {
274                            if let Some(ws_path) = Self::find_workspace_root(cargo_toml_path) {
275                                if let Ok(ws_content) = std::fs::read_to_string(&ws_path) {
276                                    if let Ok(ws_parsed) = ws_content.parse::<toml::Table>() {
277                                        workspace_parsed = Some(ws_parsed);
278                                    }
279                                }
280                            }
281                            workspace_package = workspace_parsed
282                                .as_ref()
283                                .and_then(|ws| ws.get("workspace"))
284                                .and_then(|w| w.as_table())
285                                .and_then(|w| w.get("package"))
286                                .and_then(|p| p.as_table());
287                        }
288
289                        // Get value from workspace
290                        if let Some(ws_pkg) = workspace_package {
291                            if let Some(ws_value) = ws_pkg.get(key) {
292                                if let Some(s) = ws_value.as_str() {
293                                    result.insert(key.to_string(), s.to_string());
294                                } else if let Some(i) = ws_value.as_integer() {
295                                    result.insert(key.to_string(), i.to_string());
296                                }
297                            }
298                        }
299                        continue;
300                    }
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        let path = ctx.rel_path;
318
319        // Path-based detection
320        if path.starts_with("tests/")
321            || path.starts_with("tests\\")
322            || path.contains("/tests/")
323            || path.contains("\\tests\\")
324        {
325            return true;
326        }
327
328        // Filename patterns
329        if let Some(filename) = ctx.file_path.file_name().and_then(|n| n.to_str()) {
330            if filename.ends_with("_test.rs")
331                || filename.ends_with("_tests.rs")
332                || filename.starts_with("test_")
333            {
334                return true;
335            }
336        }
337
338        // Check file content for top-level #[cfg(test)]
339        // This catches files that are primarily test code
340        if let Ok(content) = std::fs::read_to_string(ctx.file_path) {
341            // Look for #[cfg(test)] at start of line (top-level attribute)
342            for line in content.lines().take(50) {
343                // Skip to first non-comment, non-empty line with #[cfg(test)]
344                let trimmed = line.trim();
345                if trimmed.starts_with("#[cfg(test)]") {
346                    return true;
347                }
348            }
349        }
350
351        false
352    }
353}
354
355impl RuleSource for RustSource {
356    fn namespace(&self) -> &str {
357        "rust"
358    }
359
360    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
361        // Only apply to Rust files
362        let ext = ctx.file_path.extension()?;
363        if ext != "rs" {
364            return None;
365        }
366
367        // Find nearest Cargo.toml
368        let cargo_toml = Self::find_cargo_toml(ctx.file_path);
369
370        let mut result = cargo_toml
371            .map(|p| Self::parse_cargo_toml(&p))
372            .unwrap_or_default();
373
374        // Detect test files
375        result.insert(
376            "is_test_file".to_string(),
377            Self::is_test_file(ctx).to_string(),
378        );
379
380        Some(result)
381    }
382}
383
384/// TypeScript/JavaScript project source - parses tsconfig.json and package.json.
385///
386/// Provides `typescript.target`, `typescript.module`, `typescript.strict`, `node.version`.
387pub struct TypeScriptSource;
388
389impl TypeScriptSource {
390    /// Find the nearest tsconfig.json for a given file path.
391    fn find_tsconfig(file_path: &Path) -> Option<std::path::PathBuf> {
392        let mut current = file_path.parent()?;
393        loop {
394            let tsconfig = current.join("tsconfig.json");
395            if tsconfig.exists() {
396                return Some(tsconfig);
397            }
398            current = current.parent()?;
399        }
400    }
401
402    /// Find the nearest package.json for a given file path.
403    fn find_package_json(file_path: &Path) -> Option<std::path::PathBuf> {
404        let mut current = file_path.parent()?;
405        loop {
406            let pkg = current.join("package.json");
407            if pkg.exists() {
408                return Some(pkg);
409            }
410            current = current.parent()?;
411        }
412    }
413
414    /// Parse tsconfig.json for compilerOptions.
415    fn parse_tsconfig(content: &str) -> HashMap<String, String> {
416        let mut result = HashMap::new();
417
418        // Simple JSON parsing for key fields in compilerOptions
419        // Look for "target", "module", "strict", "moduleResolution"
420        for line in content.lines() {
421            let line = line.trim();
422
423            if let Some(value) = Self::extract_json_string(line, "target") {
424                result.insert("target".to_string(), value);
425            } else if let Some(value) = Self::extract_json_string(line, "module") {
426                result.insert("module".to_string(), value);
427            } else if let Some(value) = Self::extract_json_string(line, "moduleResolution") {
428                result.insert("moduleResolution".to_string(), value);
429            } else if line.contains("\"strict\"") {
430                if line.contains("true") {
431                    result.insert("strict".to_string(), "true".to_string());
432                } else if line.contains("false") {
433                    result.insert("strict".to_string(), "false".to_string());
434                }
435            }
436        }
437
438        result
439    }
440
441    /// Parse package.json for engines.node.
442    fn parse_package_json(content: &str) -> HashMap<String, String> {
443        let mut result = HashMap::new();
444
445        // Look for engines.node version
446        let mut in_engines = false;
447        for line in content.lines() {
448            let line = line.trim();
449            if line.contains("\"engines\"") {
450                in_engines = true;
451            } else if in_engines {
452                if line.starts_with('}') {
453                    in_engines = false;
454                } else if let Some(value) = Self::extract_json_string(line, "node") {
455                    result.insert("node_version".to_string(), value);
456                }
457            }
458
459            // Also get name and version
460            if let Some(value) = Self::extract_json_string(line, "name") {
461                if !result.contains_key("name") {
462                    result.insert("name".to_string(), value);
463                }
464            }
465            if let Some(value) = Self::extract_json_string(line, "version") {
466                if !result.contains_key("version") {
467                    result.insert("version".to_string(), value);
468                }
469            }
470        }
471
472        result
473    }
474
475    /// Extract a JSON string value: `"key": "value"` or `"key": "value",`
476    fn extract_json_string(line: &str, key: &str) -> Option<String> {
477        let pattern = format!("\"{}\"", key);
478        if !line.contains(&pattern) {
479            return None;
480        }
481
482        // Find the value after the colon
483        let colon_pos = line.find(':')?;
484        let after_colon = line[colon_pos + 1..].trim();
485
486        // Extract quoted string
487        if let Some(rest) = after_colon.strip_prefix('"') {
488            let end = rest.find('"')?;
489            return Some(rest[..end].to_string());
490        }
491
492        None
493    }
494}
495
496impl RuleSource for TypeScriptSource {
497    fn namespace(&self) -> &str {
498        "typescript"
499    }
500
501    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
502        // Only apply to TypeScript/JavaScript files
503        let ext = ctx.file_path.extension()?.to_string_lossy();
504        if !matches!(ext.as_ref(), "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs") {
505            return None;
506        }
507
508        let mut result = HashMap::new();
509
510        // Parse tsconfig.json if present
511        if let Some(tsconfig) = Self::find_tsconfig(ctx.file_path) {
512            if let Ok(content) = std::fs::read_to_string(&tsconfig) {
513                result.extend(Self::parse_tsconfig(&content));
514            }
515        }
516
517        // Parse package.json if present
518        if let Some(pkg_json) = Self::find_package_json(ctx.file_path) {
519            if let Ok(content) = std::fs::read_to_string(&pkg_json) {
520                result.extend(Self::parse_package_json(&content));
521            }
522        }
523
524        if result.is_empty() {
525            None
526        } else {
527            Some(result)
528        }
529    }
530}
531
532/// Python project source - parses pyproject.toml for project metadata.
533///
534/// Provides `python.version`, `python.name`.
535pub struct PythonSource;
536
537impl PythonSource {
538    /// Find the nearest pyproject.toml for a given file path.
539    fn find_pyproject(file_path: &Path) -> Option<std::path::PathBuf> {
540        let mut current = file_path.parent()?;
541        loop {
542            let pyproject = current.join("pyproject.toml");
543            if pyproject.exists() {
544                return Some(pyproject);
545            }
546            current = current.parent()?;
547        }
548    }
549
550    /// Parse pyproject.toml for project metadata.
551    fn parse_pyproject(content: &str) -> HashMap<String, String> {
552        let mut result = HashMap::new();
553
554        // Simple TOML parsing for key fields
555        // Look for requires-python, name, version
556        for line in content.lines() {
557            let line = line.trim();
558
559            if let Some(rest) = line.strip_prefix("requires-python") {
560                if let Some(value) = parse_toml_value(rest) {
561                    // Strip comparison operators for the version
562                    let version = value
563                        .trim_start_matches(">=")
564                        .trim_start_matches("<=")
565                        .trim_start_matches("==")
566                        .trim_start_matches('^')
567                        .trim_start_matches('~');
568                    result.insert("requires_python".to_string(), version.to_string());
569                }
570            } else if let Some(rest) = line.strip_prefix("name") {
571                if let Some(value) = parse_toml_value(rest) {
572                    result.insert("name".to_string(), value);
573                }
574            } else if let Some(rest) = line.strip_prefix("version") {
575                if let Some(value) = parse_toml_value(rest) {
576                    result.insert("version".to_string(), value);
577                }
578            }
579        }
580
581        result
582    }
583}
584
585impl RuleSource for PythonSource {
586    fn namespace(&self) -> &str {
587        "python"
588    }
589
590    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
591        // Only apply to Python files
592        let ext = ctx.file_path.extension()?;
593        if ext != "py" {
594            return None;
595        }
596
597        // Find nearest pyproject.toml
598        let pyproject = Self::find_pyproject(ctx.file_path)?;
599        let content = std::fs::read_to_string(&pyproject).ok()?;
600
601        let result = Self::parse_pyproject(&content);
602        if result.is_empty() {
603            None
604        } else {
605            Some(result)
606        }
607    }
608}
609
610/// Go project source - parses go.mod for module metadata.
611///
612/// Provides `go.version`, `go.module`.
613pub struct GoSource;
614
615impl GoSource {
616    /// Find the nearest go.mod for a given file path.
617    fn find_go_mod(file_path: &Path) -> Option<std::path::PathBuf> {
618        let mut current = file_path.parent()?;
619        loop {
620            let go_mod = current.join("go.mod");
621            if go_mod.exists() {
622                return Some(go_mod);
623            }
624            current = current.parent()?;
625        }
626    }
627
628    /// Parse go.mod for module metadata.
629    fn parse_go_mod(content: &str) -> HashMap<String, String> {
630        let mut result = HashMap::new();
631
632        for line in content.lines() {
633            let line = line.trim();
634
635            // module github.com/user/repo
636            if let Some(rest) = line.strip_prefix("module ") {
637                result.insert("module".to_string(), rest.trim().to_string());
638            }
639            // go 1.21
640            else if let Some(rest) = line.strip_prefix("go ") {
641                result.insert("version".to_string(), rest.trim().to_string());
642            }
643        }
644
645        result
646    }
647}
648
649impl RuleSource for GoSource {
650    fn namespace(&self) -> &str {
651        "go"
652    }
653
654    fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>> {
655        // Only apply to Go files
656        let ext = ctx.file_path.extension()?;
657        if ext != "go" {
658            return None;
659        }
660
661        // Find nearest go.mod
662        let go_mod = Self::find_go_mod(ctx.file_path)?;
663        let content = std::fs::read_to_string(&go_mod).ok()?;
664
665        let result = Self::parse_go_mod(&content);
666        if result.is_empty() {
667            None
668        } else {
669            Some(result)
670        }
671    }
672}
673
674/// Create a registry with all built-in sources.
675pub fn builtin_registry() -> SourceRegistry {
676    let mut registry = SourceRegistry::new();
677    registry.register(Box::new(EnvSource));
678    registry.register(Box::new(PathSource));
679    registry.register(Box::new(GitSource));
680    registry.register(Box::new(RustSource));
681    registry.register(Box::new(TypeScriptSource));
682    registry.register(Box::new(PythonSource));
683    registry.register(Box::new(GoSource));
684    registry
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn test_env_source() {
693        // SAFETY: Test runs single-threaded, no concurrent env access
694        unsafe {
695            std::env::set_var("MOSS_TEST_VAR", "hello");
696        }
697
698        let ctx = SourceContext {
699            file_path: Path::new("/tmp/test.rs"),
700            rel_path: "test.rs",
701            project_root: Path::new("/tmp"),
702        };
703
704        let registry = builtin_registry();
705        let value = registry.get(&ctx, "env.MOSS_TEST_VAR");
706        assert_eq!(value, Some("hello".to_string()));
707
708        // SAFETY: Test cleanup
709        unsafe {
710            std::env::remove_var("MOSS_TEST_VAR");
711        }
712    }
713
714    #[test]
715    fn test_path_source() {
716        let ctx = SourceContext {
717            file_path: Path::new("/project/src/lib.rs"),
718            rel_path: "src/lib.rs",
719            project_root: Path::new("/project"),
720        };
721
722        let registry = builtin_registry();
723        assert_eq!(
724            registry.get(&ctx, "path.rel"),
725            Some("src/lib.rs".to_string())
726        );
727        assert_eq!(registry.get(&ctx, "path.ext"), Some("rs".to_string()));
728        assert_eq!(
729            registry.get(&ctx, "path.filename"),
730            Some("lib.rs".to_string())
731        );
732    }
733
734    #[test]
735    fn test_rust_source_parse_cargo_toml() {
736        let temp_dir = std::env::temp_dir().join("moss_test_cargo_toml");
737        std::fs::create_dir_all(&temp_dir).unwrap();
738        let cargo_path = temp_dir.join("Cargo.toml");
739        let content = r#"
740[package]
741name = "my-crate"
742version = "0.1.0"
743edition = "2024"
744resolver = "2"
745"#;
746        std::fs::write(&cargo_path, content).unwrap();
747        let result = RustSource::parse_cargo_toml(&cargo_path);
748        assert_eq!(result.get("name"), Some(&"my-crate".to_string()));
749        assert_eq!(result.get("version"), Some(&"0.1.0".to_string()));
750        assert_eq!(result.get("edition"), Some(&"2024".to_string()));
751        assert_eq!(result.get("resolver"), Some(&"2".to_string()));
752        std::fs::remove_dir_all(&temp_dir).ok();
753    }
754
755    #[test]
756    fn test_rust_source_real_file() {
757        // Test against this project's actual Cargo.toml
758        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
759        let file_path = manifest_dir.join("src/lib.rs");
760        let ctx = SourceContext {
761            file_path: &file_path,
762            rel_path: "src/lib.rs",
763            project_root: manifest_dir,
764        };
765
766        let registry = builtin_registry();
767        // Should find edition from Cargo.toml
768        let edition = registry.get(&ctx, "rust.edition");
769        assert!(edition.is_some(), "Should find rust.edition");
770    }
771
772    #[test]
773    fn test_typescript_source_parse_tsconfig() {
774        let content = r#"{
775  "compilerOptions": {
776    "target": "ES2020",
777    "module": "ESNext",
778    "strict": true,
779    "moduleResolution": "bundler"
780  }
781}"#;
782        let result = TypeScriptSource::parse_tsconfig(content);
783        assert_eq!(result.get("target"), Some(&"ES2020".to_string()));
784        assert_eq!(result.get("module"), Some(&"ESNext".to_string()));
785        assert_eq!(result.get("strict"), Some(&"true".to_string()));
786        assert_eq!(result.get("moduleResolution"), Some(&"bundler".to_string()));
787    }
788
789    #[test]
790    fn test_typescript_source_parse_package_json() {
791        let content = r#"{
792  "name": "my-app",
793  "version": "1.0.0",
794  "engines": {
795    "node": ">=18.0.0"
796  }
797}"#;
798        let result = TypeScriptSource::parse_package_json(content);
799        assert_eq!(result.get("name"), Some(&"my-app".to_string()));
800        assert_eq!(result.get("version"), Some(&"1.0.0".to_string()));
801        assert_eq!(result.get("node_version"), Some(&">=18.0.0".to_string()));
802    }
803
804    #[test]
805    fn test_python_source_parse_pyproject() {
806        let content = r#"
807[project]
808name = "my-package"
809version = "0.1.0"
810requires-python = ">=3.10"
811"#;
812        let result = PythonSource::parse_pyproject(content);
813        assert_eq!(result.get("name"), Some(&"my-package".to_string()));
814        assert_eq!(result.get("version"), Some(&"0.1.0".to_string()));
815        assert_eq!(result.get("requires_python"), Some(&"3.10".to_string()));
816    }
817
818    #[test]
819    fn test_go_source_parse_go_mod() {
820        let content = r#"module github.com/user/repo
821
822go 1.21
823
824require (
825    golang.org/x/text v0.3.0
826)"#;
827        let result = GoSource::parse_go_mod(content);
828        assert_eq!(
829            result.get("module"),
830            Some(&"github.com/user/repo".to_string())
831        );
832        assert_eq!(result.get("version"), Some(&"1.21".to_string()));
833    }
834
835    #[test]
836    fn test_rust_is_test_file() {
837        // Path-based detection: /tests/ directory
838        let ctx = SourceContext {
839            file_path: Path::new("/project/tests/integration.rs"),
840            rel_path: "tests/integration.rs",
841            project_root: Path::new("/project"),
842        };
843        assert!(RustSource::is_test_file(&ctx));
844
845        // Filename pattern: *_test.rs
846        let ctx = SourceContext {
847            file_path: Path::new("/project/src/foo_test.rs"),
848            rel_path: "src/foo_test.rs",
849            project_root: Path::new("/project"),
850        };
851        assert!(RustSource::is_test_file(&ctx));
852
853        // Filename pattern: test_*.rs
854        let ctx = SourceContext {
855            file_path: Path::new("/project/src/test_bar.rs"),
856            rel_path: "src/test_bar.rs",
857            project_root: Path::new("/project"),
858        };
859        assert!(RustSource::is_test_file(&ctx));
860
861        // Not a test file
862        let ctx = SourceContext {
863            file_path: Path::new("/project/src/lib.rs"),
864            rel_path: "src/lib.rs",
865            project_root: Path::new("/project"),
866        };
867        assert!(!RustSource::is_test_file(&ctx));
868    }
869}