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 && let Some(values) = source.evaluate(ctx)
92 {
93 return values.get(field).cloned();
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 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 if let Ok(repo) = gix::discover(ctx.project_root) {
177 let is_dirty = repo.is_dirty().unwrap_or(false);
179 result.insert("dirty".to_string(), is_dirty.to_string());
180
181 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 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 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
205pub struct RustSource;
211
212impl RustSource {
213 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 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 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 let package = match parsed.get("package").and_then(|v| v.as_table()) {
257 Some(p) => p,
258 None => return result,
259 };
260
261 let keys = ["edition", "resolver", "name", "version"];
263
264 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 if let Some(table) = value.as_table()
272 && table.get("workspace").and_then(|v| v.as_bool()) == Some(true)
273 {
274 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 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 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 if normalize_languages::is_test_path(ctx.file_path) {
319 return true;
320 }
321
322 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 let ext = ctx.file_path.extension()?;
343 if ext != "rs" {
344 return None;
345 }
346
347 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 result.insert(
356 "is_test_file".to_string(),
357 Self::is_test_file(ctx).to_string(),
358 );
359
360 Some(result)
361 }
362}
363
364pub struct TypeScriptSource;
369
370impl TypeScriptSource {
371 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 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 fn parse_tsconfig(content: &str) -> HashMap<String, String> {
397 let mut result = HashMap::new();
398
399 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 fn parse_package_json(content: &str) -> HashMap<String, String> {
424 let mut result = HashMap::new();
425
426 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 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 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 let colon_pos = line.find(':')?;
465 let after_colon = line[colon_pos + 1..].trim();
466
467 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 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 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 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
514pub struct PythonSource;
518
519impl PythonSource {
520 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 fn parse_pyproject(content: &str) -> HashMap<String, String> {
534 let mut result = HashMap::new();
535
536 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 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 let ext = ctx.file_path.extension()?;
575 if ext != "py" {
576 return None;
577 }
578
579 let mut result = HashMap::new();
580
581 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
597pub struct GoSource;
601
602impl GoSource {
603 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 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 if let Some(rest) = line.strip_prefix("module ") {
624 result.insert("module".to_string(), rest.trim().to_string());
625 }
626 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 let ext = ctx.file_path.extension()?;
644 if ext != "go" {
645 return None;
646 }
647
648 let mut result = HashMap::new();
649
650 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
666pub 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 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 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 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 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 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 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 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 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 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 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}