1use std::collections::HashMap;
19use std::path::Path;
20
21fn 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 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 Some(rest.to_string())
38}
39
40pub struct SourceContext<'a> {
42 pub file_path: &'a Path,
44 pub rel_path: &'a str,
46 pub project_root: &'a Path,
48}
49
50pub trait RuleSource: Send + Sync {
55 fn namespace(&self) -> &str;
57
58 fn evaluate(&self, ctx: &SourceContext) -> Option<HashMap<String, String>>;
66}
67
68#[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 pub fn register(&mut self, source: Box<dyn RuleSource>) {
81 self.sources.push(source);
82 }
83
84 pub fn get(&self, ctx: &SourceContext, key: &str) -> Option<String> {
86 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
100pub 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 Some(std::env::vars().collect())
117 }
118}
119
120pub 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
148pub 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 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 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 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
202pub struct RustSource;
208
209impl RustSource {
210 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 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 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 let package = match parsed.get("package").and_then(|v| v.as_table()) {
256 Some(p) => p,
257 None => return result,
258 };
259
260 let keys = ["edition", "resolver", "name", "version"];
262
263 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 if let Some(table) = value.as_table() {
271 if table.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
272 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 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 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 fn is_test_file(ctx: &SourceContext) -> bool {
317 let path = ctx.rel_path;
318
319 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 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 if let Ok(content) = std::fs::read_to_string(ctx.file_path) {
341 for line in content.lines().take(50) {
343 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 let ext = ctx.file_path.extension()?;
363 if ext != "rs" {
364 return None;
365 }
366
367 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 result.insert(
376 "is_test_file".to_string(),
377 Self::is_test_file(ctx).to_string(),
378 );
379
380 Some(result)
381 }
382}
383
384pub struct TypeScriptSource;
388
389impl TypeScriptSource {
390 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 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 fn parse_tsconfig(content: &str) -> HashMap<String, String> {
416 let mut result = HashMap::new();
417
418 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 fn parse_package_json(content: &str) -> HashMap<String, String> {
443 let mut result = HashMap::new();
444
445 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 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 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 let colon_pos = line.find(':')?;
484 let after_colon = line[colon_pos + 1..].trim();
485
486 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 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 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 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
532pub struct PythonSource;
536
537impl PythonSource {
538 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 fn parse_pyproject(content: &str) -> HashMap<String, String> {
552 let mut result = HashMap::new();
553
554 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 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 let ext = ctx.file_path.extension()?;
593 if ext != "py" {
594 return None;
595 }
596
597 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
610pub struct GoSource;
614
615impl GoSource {
616 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 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 if let Some(rest) = line.strip_prefix("module ") {
637 result.insert("module".to_string(), rest.trim().to_string());
638 }
639 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 let ext = ctx.file_path.extension()?;
657 if ext != "go" {
658 return None;
659 }
660
661 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
674pub 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 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 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 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 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 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 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 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 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}