1use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LspConfig {
14 pub server_binary: String,
16 pub args: Vec<String>,
18 pub language_id: String,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub enum VerifierStage {
29 SyntaxCheck,
31 Build,
33 Test,
35 Lint,
37}
38
39impl std::fmt::Display for VerifierStage {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 VerifierStage::SyntaxCheck => write!(f, "syntax_check"),
43 VerifierStage::Build => write!(f, "build"),
44 VerifierStage::Test => write!(f, "test"),
45 VerifierStage::Lint => write!(f, "lint"),
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
56pub struct VerifierCapability {
57 pub stage: VerifierStage,
59 pub command: Option<String>,
61 pub available: bool,
63 pub fallback_command: Option<String>,
65 pub fallback_available: bool,
67}
68
69impl VerifierCapability {
70 pub fn any_available(&self) -> bool {
72 self.available || self.fallback_available
73 }
74
75 pub fn effective_command(&self) -> Option<&str> {
77 if self.available {
78 self.command.as_deref()
79 } else if self.fallback_available {
80 self.fallback_command.as_deref()
81 } else {
82 None
83 }
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct LspCapability {
90 pub primary: LspConfig,
92 pub primary_available: bool,
94 pub fallback: Option<LspConfig>,
96 pub fallback_available: bool,
98}
99
100impl LspCapability {
101 pub fn effective_config(&self) -> Option<&LspConfig> {
103 if self.primary_available {
104 Some(&self.primary)
105 } else if self.fallback_available {
106 self.fallback.as_ref()
107 } else {
108 None
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
118pub struct VerifierProfile {
119 pub plugin_name: String,
121 pub capabilities: Vec<VerifierCapability>,
123 pub lsp: LspCapability,
125}
126
127impl VerifierProfile {
128 pub fn get(&self, stage: VerifierStage) -> Option<&VerifierCapability> {
130 self.capabilities.iter().find(|c| c.stage == stage)
131 }
132
133 pub fn available_stages(&self) -> Vec<VerifierStage> {
135 self.capabilities
136 .iter()
137 .filter(|c| c.any_available())
138 .map(|c| c.stage)
139 .collect()
140 }
141
142 pub fn fully_degraded(&self) -> bool {
144 self.capabilities.iter().all(|c| !c.any_available())
145 }
146}
147
148pub fn host_binary_available(binary: &str) -> bool {
157 std::process::Command::new(binary)
158 .arg("--version")
159 .stdout(std::process::Stdio::null())
160 .stderr(std::process::Stdio::null())
161 .status()
162 .map(|s| s.success())
163 .unwrap_or(false)
164}
165
166#[derive(Debug, Clone, Default)]
168pub struct InitOptions {
169 pub name: String,
171 pub package_manager: Option<String>,
173 pub flags: Vec<String>,
175 pub is_empty_dir: bool,
177}
178
179#[derive(Debug, Clone)]
181pub enum ProjectAction {
182 ExecCommand {
184 command: String,
186 description: String,
188 },
189 NoAction,
191}
192
193pub trait LanguagePlugin: Send + Sync {
198 fn name(&self) -> &str;
200
201 fn extensions(&self) -> &[&str];
203
204 fn key_files(&self) -> &[&str];
206
207 fn detect(&self, path: &Path) -> bool {
209 for key_file in self.key_files() {
211 if path.join(key_file).exists() {
212 return true;
213 }
214 }
215
216 if let Ok(entries) = std::fs::read_dir(path) {
218 for entry in entries.flatten() {
219 if let Some(ext) = entry.path().extension() {
220 let ext_str = ext.to_string_lossy();
221 if self.extensions().iter().any(|e| *e == ext_str) {
222 return true;
223 }
224 }
225 }
226 }
227
228 false
229 }
230
231 fn get_lsp_config(&self) -> LspConfig;
233
234 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction;
236
237 fn check_tooling_action(&self, path: &Path) -> ProjectAction;
239
240 fn init_command(&self, opts: &InitOptions) -> String;
243
244 fn test_command(&self) -> String;
246
247 fn run_command(&self) -> String;
249
250 fn run_command_for_dir(&self, _path: &Path) -> String {
255 self.run_command()
256 }
257
258 fn syntax_check_command(&self) -> Option<String> {
266 None
267 }
268
269 fn build_command(&self) -> Option<String> {
273 None
274 }
275
276 fn lint_command(&self) -> Option<String> {
280 None
281 }
282
283 fn file_ownership_patterns(&self) -> &[&str] {
287 self.extensions()
288 }
289
290 fn owns_file(&self, path: &str) -> bool {
294 let path_lower = path.to_lowercase();
295 self.file_ownership_patterns().iter().any(|pattern| {
296 let pattern = pattern.trim_start_matches('*');
297 path_lower.ends_with(pattern)
298 })
299 }
300
301 fn host_tool_available(&self) -> bool {
306 true
307 }
308
309 fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
315 Vec::new()
316 }
317
318 fn lsp_fallback(&self) -> Option<LspConfig> {
320 None
321 }
322
323 fn verifier_profile(&self) -> VerifierProfile {
336 let tool_available = self.host_tool_available();
337
338 let mut capabilities = Vec::new();
339
340 if let Some(cmd) = self.syntax_check_command() {
341 capabilities.push(VerifierCapability {
342 stage: VerifierStage::SyntaxCheck,
343 command: Some(cmd),
344 available: tool_available,
345 fallback_command: None,
346 fallback_available: false,
347 });
348 }
349
350 if let Some(cmd) = self.build_command() {
351 capabilities.push(VerifierCapability {
352 stage: VerifierStage::Build,
353 command: Some(cmd),
354 available: tool_available,
355 fallback_command: None,
356 fallback_available: false,
357 });
358 }
359
360 capabilities.push(VerifierCapability {
362 stage: VerifierStage::Test,
363 command: Some(self.test_command()),
364 available: tool_available,
365 fallback_command: None,
366 fallback_available: false,
367 });
368
369 if let Some(cmd) = self.lint_command() {
370 capabilities.push(VerifierCapability {
371 stage: VerifierStage::Lint,
372 command: Some(cmd),
373 available: tool_available,
374 fallback_command: None,
375 fallback_available: false,
376 });
377 }
378
379 let primary_config = self.get_lsp_config();
380 let primary_available = host_binary_available(&primary_config.server_binary);
381 let fallback = self.lsp_fallback();
382 let fallback_available = fallback
383 .as_ref()
384 .map(|f| host_binary_available(&f.server_binary))
385 .unwrap_or(false);
386
387 VerifierProfile {
388 plugin_name: self.name().to_string(),
389 capabilities,
390 lsp: LspCapability {
391 primary: primary_config,
392 primary_available,
393 fallback,
394 fallback_available,
395 },
396 }
397 }
398}
399
400pub struct RustPlugin;
402
403impl LanguagePlugin for RustPlugin {
404 fn name(&self) -> &str {
405 "rust"
406 }
407
408 fn extensions(&self) -> &[&str] {
409 &["rs"]
410 }
411
412 fn key_files(&self) -> &[&str] {
413 &["Cargo.toml", "Cargo.lock"]
414 }
415
416 fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
417 vec![
418 ("cargo", "build/init", "Install Rust via https://rustup.rs"),
419 ("rustc", "compiler", "Install Rust via https://rustup.rs"),
420 (
421 "rust-analyzer",
422 "language server",
423 "rustup component add rust-analyzer",
424 ),
425 ]
426 }
427
428 fn get_lsp_config(&self) -> LspConfig {
429 LspConfig {
430 server_binary: "rust-analyzer".to_string(),
431 args: vec![],
432 language_id: "rust".to_string(),
433 }
434 }
435
436 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
437 let command = if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
438 "cargo init .".to_string()
439 } else {
440 format!("cargo new {}", opts.name)
441 };
442 ProjectAction::ExecCommand {
443 command,
444 description: "Initialize Rust project with Cargo".to_string(),
445 }
446 }
447
448 fn check_tooling_action(&self, path: &Path) -> ProjectAction {
449 if !path.join("Cargo.lock").exists() && path.join("Cargo.toml").exists() {
451 ProjectAction::ExecCommand {
452 command: "cargo fetch".to_string(),
453 description: "Fetch Rust dependencies".to_string(),
454 }
455 } else {
456 ProjectAction::NoAction
457 }
458 }
459
460 fn init_command(&self, opts: &InitOptions) -> String {
461 if opts.name == "." || opts.name == "./" {
462 "cargo init .".to_string()
463 } else {
464 format!("cargo new {}", opts.name)
465 }
466 }
467
468 fn test_command(&self) -> String {
469 "cargo test".to_string()
470 }
471
472 fn run_command(&self) -> String {
473 "cargo run".to_string()
474 }
475
476 fn syntax_check_command(&self) -> Option<String> {
479 Some("cargo check".to_string())
480 }
481
482 fn build_command(&self) -> Option<String> {
483 Some("cargo build".to_string())
484 }
485
486 fn lint_command(&self) -> Option<String> {
487 Some("cargo clippy -- -D warnings".to_string())
488 }
489
490 fn file_ownership_patterns(&self) -> &[&str] {
491 &["rs"]
492 }
493
494 fn host_tool_available(&self) -> bool {
495 host_binary_available("cargo")
496 }
497
498 fn verifier_profile(&self) -> VerifierProfile {
499 let cargo = host_binary_available("cargo");
500 let clippy = cargo; let capabilities = vec![
503 VerifierCapability {
504 stage: VerifierStage::SyntaxCheck,
505 command: Some("cargo check".to_string()),
506 available: cargo,
507 fallback_command: None,
508 fallback_available: false,
509 },
510 VerifierCapability {
511 stage: VerifierStage::Build,
512 command: Some("cargo build".to_string()),
513 available: cargo,
514 fallback_command: None,
515 fallback_available: false,
516 },
517 VerifierCapability {
518 stage: VerifierStage::Test,
519 command: Some("cargo test".to_string()),
520 available: cargo,
521 fallback_command: None,
522 fallback_available: false,
523 },
524 VerifierCapability {
525 stage: VerifierStage::Lint,
526 command: Some("cargo clippy -- -D warnings".to_string()),
527 available: clippy,
528 fallback_command: None,
529 fallback_available: false,
530 },
531 ];
532
533 let primary = self.get_lsp_config();
534 let primary_available = host_binary_available(&primary.server_binary);
535
536 VerifierProfile {
537 plugin_name: self.name().to_string(),
538 capabilities,
539 lsp: LspCapability {
540 primary,
541 primary_available,
542 fallback: None,
543 fallback_available: false,
544 },
545 }
546 }
547}
548
549pub struct PythonPlugin;
551
552impl LanguagePlugin for PythonPlugin {
553 fn name(&self) -> &str {
554 "python"
555 }
556
557 fn extensions(&self) -> &[&str] {
558 &["py"]
559 }
560
561 fn key_files(&self) -> &[&str] {
562 &["pyproject.toml", "setup.py", "requirements.txt", "uv.lock"]
563 }
564
565 fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
566 vec![
567 (
568 "uv",
569 "package manager",
570 "curl -LsSf https://astral.sh/uv/install.sh | sh",
571 ),
572 (
573 "python3",
574 "interpreter",
575 "uv python install (or install from https://python.org)",
576 ),
577 (
578 "uvx",
579 "tool runner/LSP",
580 "Installed with uv — curl -LsSf https://astral.sh/uv/install.sh | sh",
581 ),
582 ]
583 }
584
585 fn get_lsp_config(&self) -> LspConfig {
586 LspConfig {
589 server_binary: "uvx".to_string(),
590 args: vec!["ty".to_string(), "server".to_string()],
591 language_id: "python".to_string(),
592 }
593 }
594
595 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
596 let command = match opts.package_manager.as_deref() {
597 Some("poetry") => {
598 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
599 "poetry init --no-interaction".to_string()
600 } else {
601 format!("poetry new {}", opts.name)
602 }
603 }
604 Some("pdm") => {
605 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
606 "pdm init --non-interactive".to_string()
607 } else {
608 format!(
609 "mkdir -p {} && cd {} && pdm init --non-interactive",
610 opts.name, opts.name
611 )
612 }
613 }
614 _ => {
615 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
617 "uv init --lib".to_string()
618 } else {
619 format!("uv init --lib {}", opts.name)
620 }
621 }
622 };
623 let description = match opts.package_manager.as_deref() {
624 Some("poetry") => "Initialize Python project with Poetry",
625 Some("pdm") => "Initialize Python project with PDM",
626 _ => "Initialize Python project with uv",
627 };
628 ProjectAction::ExecCommand {
629 command,
630 description: description.to_string(),
631 }
632 }
633
634 fn check_tooling_action(&self, path: &Path) -> ProjectAction {
635 let has_pyproject = path.join("pyproject.toml").exists();
637 let has_venv = path.join(".venv").exists();
638 let has_uv_lock = path.join("uv.lock").exists();
639
640 if has_pyproject && (!has_venv || !has_uv_lock) {
641 ProjectAction::ExecCommand {
642 command: "uv sync".to_string(),
643 description: "Sync Python dependencies with uv".to_string(),
644 }
645 } else {
646 ProjectAction::NoAction
647 }
648 }
649
650 fn init_command(&self, opts: &InitOptions) -> String {
651 if opts.package_manager.as_deref() == Some("poetry") {
652 if opts.name == "." || opts.name == "./" {
653 "poetry init".to_string()
654 } else {
655 format!("poetry new {}", opts.name)
656 }
657 } else {
658 format!("uv init --lib {}", opts.name)
660 }
661 }
662
663 fn test_command(&self) -> String {
664 "uv run pytest".to_string()
665 }
666
667 fn run_command(&self) -> String {
668 "uv run python -m main".to_string()
669 }
670
671 fn run_command_for_dir(&self, path: &Path) -> String {
674 if let Ok(entries) = std::fs::read_dir(path.join("src")) {
676 for entry in entries.flatten() {
677 if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
678 let name = entry.file_name().to_string_lossy().to_string();
679 if !name.starts_with('.') && !name.starts_with('_') {
680 return format!("uv run python -m {}", name);
681 }
682 }
683 }
684 }
685
686 if let Ok(content) = std::fs::read_to_string(path.join("pyproject.toml")) {
688 if content.contains("[project.scripts]") {
689 let mut in_scripts = false;
691 for raw_line in content.lines() {
692 let line = raw_line.trim();
693 if line == "[project.scripts]" {
694 in_scripts = true;
695 continue;
696 }
697 if in_scripts {
698 if line.starts_with('[') {
699 break;
700 }
701 if let Some((name, _)) = line.split_once('=') {
702 let script = name.trim().trim_matches('"');
703 if !script.is_empty() {
704 return format!("uv run {}", script);
705 }
706 }
707 }
708 }
709 }
710 }
711
712 "uv run python -m main".to_string()
714 }
715
716 fn syntax_check_command(&self) -> Option<String> {
719 Some("uv run ty check .".to_string())
720 }
721
722 fn lint_command(&self) -> Option<String> {
723 Some("uv run ruff check .".to_string())
724 }
725
726 fn file_ownership_patterns(&self) -> &[&str] {
727 &["py"]
728 }
729
730 fn host_tool_available(&self) -> bool {
731 host_binary_available("uv")
732 }
733
734 fn lsp_fallback(&self) -> Option<LspConfig> {
735 Some(LspConfig {
736 server_binary: "pyright-langserver".to_string(),
737 args: vec!["--stdio".to_string()],
738 language_id: "python".to_string(),
739 })
740 }
741
742 fn verifier_profile(&self) -> VerifierProfile {
743 let uv = host_binary_available("uv");
744 let pyright = host_binary_available("pyright");
745
746 let capabilities = vec![
747 VerifierCapability {
748 stage: VerifierStage::SyntaxCheck,
749 command: Some("uv run ty check .".to_string()),
750 available: uv,
751 fallback_command: Some("pyright .".to_string()),
753 fallback_available: pyright,
754 },
755 VerifierCapability {
756 stage: VerifierStage::Test,
757 command: Some("uv run pytest".to_string()),
758 available: uv,
759 fallback_command: Some("python -m pytest".to_string()),
761 fallback_available: host_binary_available("python3")
762 || host_binary_available("python"),
763 },
764 VerifierCapability {
765 stage: VerifierStage::Lint,
766 command: Some("uv run ruff check .".to_string()),
767 available: uv,
768 fallback_command: Some("ruff check .".to_string()),
769 fallback_available: host_binary_available("ruff"),
770 },
771 ];
772
773 let primary = self.get_lsp_config();
774 let primary_available = host_binary_available("uvx");
775 let fallback = self.lsp_fallback();
776 let fallback_available = fallback
777 .as_ref()
778 .map(|f| host_binary_available(&f.server_binary))
779 .unwrap_or(false);
780
781 VerifierProfile {
782 plugin_name: self.name().to_string(),
783 capabilities,
784 lsp: LspCapability {
785 primary,
786 primary_available,
787 fallback,
788 fallback_available,
789 },
790 }
791 }
792}
793
794pub struct JsPlugin;
796
797impl LanguagePlugin for JsPlugin {
798 fn name(&self) -> &str {
799 "javascript"
800 }
801
802 fn extensions(&self) -> &[&str] {
803 &["js", "ts", "jsx", "tsx"]
804 }
805
806 fn key_files(&self) -> &[&str] {
807 &["package.json", "tsconfig.json"]
808 }
809
810 fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
811 vec![
812 (
813 "node",
814 "runtime",
815 "Install Node.js from https://nodejs.org or via nvm",
816 ),
817 (
818 "npm",
819 "package manager",
820 "Included with Node.js — install from https://nodejs.org",
821 ),
822 (
823 "typescript-language-server",
824 "language server",
825 "npm install -g typescript-language-server typescript",
826 ),
827 ]
828 }
829
830 fn get_lsp_config(&self) -> LspConfig {
831 LspConfig {
832 server_binary: "typescript-language-server".to_string(),
833 args: vec!["--stdio".to_string()],
834 language_id: "typescript".to_string(),
835 }
836 }
837
838 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
839 let command = match opts.package_manager.as_deref() {
840 Some("pnpm") => {
841 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
842 "pnpm init".to_string()
843 } else {
844 format!("mkdir -p {} && cd {} && pnpm init", opts.name, opts.name)
845 }
846 }
847 Some("yarn") => {
848 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
849 "yarn init -y".to_string()
850 } else {
851 format!("mkdir -p {} && cd {} && yarn init -y", opts.name, opts.name)
852 }
853 }
854 _ => {
855 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
857 "npm init -y".to_string()
858 } else {
859 format!("mkdir -p {} && cd {} && npm init -y", opts.name, opts.name)
860 }
861 }
862 };
863 let description = match opts.package_manager.as_deref() {
864 Some("pnpm") => "Initialize JavaScript project with pnpm",
865 Some("yarn") => "Initialize JavaScript project with Yarn",
866 _ => "Initialize JavaScript project with npm",
867 };
868 ProjectAction::ExecCommand {
869 command,
870 description: description.to_string(),
871 }
872 }
873
874 fn check_tooling_action(&self, path: &Path) -> ProjectAction {
875 let has_package_json = path.join("package.json").exists();
877 let has_node_modules = path.join("node_modules").exists();
878
879 if has_package_json && !has_node_modules {
880 ProjectAction::ExecCommand {
881 command: "npm install".to_string(),
882 description: "Install Node.js dependencies".to_string(),
883 }
884 } else {
885 ProjectAction::NoAction
886 }
887 }
888
889 fn init_command(&self, opts: &InitOptions) -> String {
890 format!("npm init -y && mv package.json {}/", opts.name)
891 }
892
893 fn test_command(&self) -> String {
894 "npm test".to_string()
895 }
896
897 fn run_command(&self) -> String {
898 "npm start".to_string()
899 }
900
901 fn syntax_check_command(&self) -> Option<String> {
904 Some("npx tsc --noEmit".to_string())
905 }
906
907 fn build_command(&self) -> Option<String> {
908 Some("npm run build".to_string())
909 }
910
911 fn lint_command(&self) -> Option<String> {
912 Some("npx eslint .".to_string())
913 }
914
915 fn file_ownership_patterns(&self) -> &[&str] {
916 &["js", "ts", "jsx", "tsx"]
917 }
918
919 fn host_tool_available(&self) -> bool {
920 host_binary_available("node")
921 }
922
923 fn verifier_profile(&self) -> VerifierProfile {
924 let node = host_binary_available("node");
925 let npx = host_binary_available("npx");
926
927 let capabilities = vec![
928 VerifierCapability {
929 stage: VerifierStage::SyntaxCheck,
930 command: Some("npx tsc --noEmit".to_string()),
931 available: npx,
932 fallback_command: None,
933 fallback_available: false,
934 },
935 VerifierCapability {
936 stage: VerifierStage::Build,
937 command: Some("npm run build".to_string()),
938 available: node,
939 fallback_command: None,
940 fallback_available: false,
941 },
942 VerifierCapability {
943 stage: VerifierStage::Test,
944 command: Some("npm test".to_string()),
945 available: node,
946 fallback_command: None,
947 fallback_available: false,
948 },
949 VerifierCapability {
950 stage: VerifierStage::Lint,
951 command: Some("npx eslint .".to_string()),
952 available: npx,
953 fallback_command: None,
954 fallback_available: false,
955 },
956 ];
957
958 let primary = self.get_lsp_config();
959 let primary_available = host_binary_available(&primary.server_binary);
960
961 VerifierProfile {
962 plugin_name: self.name().to_string(),
963 capabilities,
964 lsp: LspCapability {
965 primary,
966 primary_available,
967 fallback: None,
968 fallback_available: false,
969 },
970 }
971 }
972}
973
974pub struct PluginRegistry {
976 plugins: Vec<Box<dyn LanguagePlugin>>,
977}
978
979impl PluginRegistry {
980 pub fn new() -> Self {
982 Self {
983 plugins: vec![
984 Box::new(RustPlugin),
985 Box::new(PythonPlugin),
986 Box::new(JsPlugin),
987 ],
988 }
989 }
990
991 pub fn detect(&self, path: &Path) -> Option<&dyn LanguagePlugin> {
993 self.plugins
994 .iter()
995 .find(|p| p.detect(path))
996 .map(|p| p.as_ref())
997 }
998
999 pub fn detect_all(&self, path: &Path) -> Vec<&dyn LanguagePlugin> {
1004 self.plugins
1005 .iter()
1006 .filter(|p| p.detect(path))
1007 .map(|p| p.as_ref())
1008 .collect()
1009 }
1010
1011 pub fn get(&self, name: &str) -> Option<&dyn LanguagePlugin> {
1013 self.plugins
1014 .iter()
1015 .find(|p| p.name() == name)
1016 .map(|p| p.as_ref())
1017 }
1018
1019 pub fn all(&self) -> &[Box<dyn LanguagePlugin>] {
1021 &self.plugins
1022 }
1023}
1024
1025impl Default for PluginRegistry {
1026 fn default() -> Self {
1027 Self::new()
1028 }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 #[test]
1036 fn test_plugin_owns_file() {
1037 let rust = RustPlugin;
1038 assert!(rust.owns_file("src/main.rs"));
1039 assert!(rust.owns_file("crates/core/src/lib.rs"));
1040 assert!(!rust.owns_file("main.py"));
1041 assert!(!rust.owns_file("index.js"));
1042
1043 let python = PythonPlugin;
1044 assert!(python.owns_file("main.py"));
1045 assert!(python.owns_file("tests/test_main.py"));
1046 assert!(!python.owns_file("src/main.rs"));
1047
1048 let js = JsPlugin;
1049 assert!(js.owns_file("index.js"));
1050 assert!(js.owns_file("src/app.ts"));
1051 assert!(!js.owns_file("main.py"));
1052 assert!(!js.owns_file("src/main.rs"));
1053 }
1054
1055 #[test]
1060 fn test_verifier_capability_effective_command() {
1061 let cap = VerifierCapability {
1063 stage: VerifierStage::SyntaxCheck,
1064 command: Some("cargo check".to_string()),
1065 available: true,
1066 fallback_command: Some("rustc --edition 2021".to_string()),
1067 fallback_available: true,
1068 };
1069 assert_eq!(cap.effective_command(), Some("cargo check"));
1070 assert!(cap.any_available());
1071
1072 let cap2 = VerifierCapability {
1074 stage: VerifierStage::Lint,
1075 command: Some("uv run ruff check .".to_string()),
1076 available: false,
1077 fallback_command: Some("ruff check .".to_string()),
1078 fallback_available: true,
1079 };
1080 assert_eq!(cap2.effective_command(), Some("ruff check ."));
1081 assert!(cap2.any_available());
1082
1083 let cap3 = VerifierCapability {
1085 stage: VerifierStage::Build,
1086 command: Some("cargo build".to_string()),
1087 available: false,
1088 fallback_command: None,
1089 fallback_available: false,
1090 };
1091 assert_eq!(cap3.effective_command(), None);
1092 assert!(!cap3.any_available());
1093 }
1094
1095 #[test]
1096 fn test_verifier_profile_get_and_available_stages() {
1097 let profile = VerifierProfile {
1098 plugin_name: "test".to_string(),
1099 capabilities: vec![
1100 VerifierCapability {
1101 stage: VerifierStage::SyntaxCheck,
1102 command: Some("check".to_string()),
1103 available: true,
1104 fallback_command: None,
1105 fallback_available: false,
1106 },
1107 VerifierCapability {
1108 stage: VerifierStage::Build,
1109 command: Some("build".to_string()),
1110 available: false,
1111 fallback_command: None,
1112 fallback_available: false,
1113 },
1114 VerifierCapability {
1115 stage: VerifierStage::Test,
1116 command: Some("test".to_string()),
1117 available: true,
1118 fallback_command: None,
1119 fallback_available: false,
1120 },
1121 ],
1122 lsp: LspCapability {
1123 primary: LspConfig {
1124 server_binary: "test-ls".to_string(),
1125 args: vec![],
1126 language_id: "test".to_string(),
1127 },
1128 primary_available: false,
1129 fallback: None,
1130 fallback_available: false,
1131 },
1132 };
1133
1134 assert!(profile.get(VerifierStage::SyntaxCheck).is_some());
1135 assert!(profile.get(VerifierStage::Lint).is_none());
1136
1137 let available = profile.available_stages();
1138 assert_eq!(available.len(), 2);
1139 assert!(available.contains(&VerifierStage::SyntaxCheck));
1140 assert!(available.contains(&VerifierStage::Test));
1141 assert!(!available.contains(&VerifierStage::Build));
1142 assert!(!profile.fully_degraded());
1143 }
1144
1145 #[test]
1146 fn test_verifier_profile_fully_degraded() {
1147 let profile = VerifierProfile {
1148 plugin_name: "empty".to_string(),
1149 capabilities: vec![VerifierCapability {
1150 stage: VerifierStage::Build,
1151 command: Some("build".to_string()),
1152 available: false,
1153 fallback_command: None,
1154 fallback_available: false,
1155 }],
1156 lsp: LspCapability {
1157 primary: LspConfig {
1158 server_binary: "none".to_string(),
1159 args: vec![],
1160 language_id: "none".to_string(),
1161 },
1162 primary_available: false,
1163 fallback: None,
1164 fallback_available: false,
1165 },
1166 };
1167 assert!(profile.fully_degraded());
1168 assert!(profile.available_stages().is_empty());
1169 }
1170
1171 #[test]
1172 fn test_lsp_capability_effective_config() {
1173 let lsp = LspCapability {
1174 primary: LspConfig {
1175 server_binary: "rust-analyzer".to_string(),
1176 args: vec![],
1177 language_id: "rust".to_string(),
1178 },
1179 primary_available: true,
1180 fallback: None,
1181 fallback_available: false,
1182 };
1183 assert_eq!(
1184 lsp.effective_config().unwrap().server_binary,
1185 "rust-analyzer"
1186 );
1187
1188 let lsp2 = LspCapability {
1190 primary: LspConfig {
1191 server_binary: "uvx".to_string(),
1192 args: vec![],
1193 language_id: "python".to_string(),
1194 },
1195 primary_available: false,
1196 fallback: Some(LspConfig {
1197 server_binary: "pyright-langserver".to_string(),
1198 args: vec!["--stdio".to_string()],
1199 language_id: "python".to_string(),
1200 }),
1201 fallback_available: true,
1202 };
1203 assert_eq!(
1204 lsp2.effective_config().unwrap().server_binary,
1205 "pyright-langserver"
1206 );
1207
1208 let lsp3 = LspCapability {
1210 primary: LspConfig {
1211 server_binary: "nope".to_string(),
1212 args: vec![],
1213 language_id: "none".to_string(),
1214 },
1215 primary_available: false,
1216 fallback: None,
1217 fallback_available: false,
1218 };
1219 assert!(lsp3.effective_config().is_none());
1220 }
1221
1222 #[test]
1223 fn test_rust_plugin_verifier_profile_shape() {
1224 let rust = RustPlugin;
1225 let profile = rust.verifier_profile();
1226 assert_eq!(profile.plugin_name, "rust");
1227 assert_eq!(profile.capabilities.len(), 4);
1229 let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1230 assert!(stages.contains(&VerifierStage::SyntaxCheck));
1231 assert!(stages.contains(&VerifierStage::Build));
1232 assert!(stages.contains(&VerifierStage::Test));
1233 assert!(stages.contains(&VerifierStage::Lint));
1234 }
1235
1236 #[test]
1237 fn test_python_plugin_verifier_profile_shape() {
1238 let py = PythonPlugin;
1239 let profile = py.verifier_profile();
1240 assert_eq!(profile.plugin_name, "python");
1241 assert_eq!(profile.capabilities.len(), 3);
1243 let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1244 assert!(stages.contains(&VerifierStage::SyntaxCheck));
1245 assert!(stages.contains(&VerifierStage::Test));
1246 assert!(stages.contains(&VerifierStage::Lint));
1247 assert!(!stages.contains(&VerifierStage::Build));
1248 assert!(profile.lsp.fallback.is_some());
1250 }
1251
1252 #[test]
1253 fn test_js_plugin_verifier_profile_shape() {
1254 let js = JsPlugin;
1255 let profile = js.verifier_profile();
1256 assert_eq!(profile.plugin_name, "javascript");
1257 assert_eq!(profile.capabilities.len(), 4);
1259 }
1260
1261 #[test]
1262 fn test_verifier_stage_display() {
1263 assert_eq!(format!("{}", VerifierStage::SyntaxCheck), "syntax_check");
1264 assert_eq!(format!("{}", VerifierStage::Build), "build");
1265 assert_eq!(format!("{}", VerifierStage::Test), "test");
1266 assert_eq!(format!("{}", VerifierStage::Lint), "lint");
1267 }
1268
1269 #[test]
1270 fn test_python_run_command_for_dir_src_layout() {
1271 let dir =
1272 std::env::temp_dir().join(format!("perspt_test_pyrun_src_{}", uuid::Uuid::new_v4()));
1273 std::fs::create_dir_all(dir.join("src/myapp")).unwrap();
1274 std::fs::write(dir.join("src/myapp/__init__.py"), "").unwrap();
1275
1276 let plugin = PythonPlugin;
1277 let cmd = plugin.run_command_for_dir(&dir);
1278 assert_eq!(cmd, "uv run python -m myapp");
1279
1280 let _ = std::fs::remove_dir_all(&dir);
1281 }
1282
1283 #[test]
1284 fn test_python_run_command_for_dir_scripts() {
1285 let dir = std::env::temp_dir().join(format!(
1286 "perspt_test_pyrun_scripts_{}",
1287 uuid::Uuid::new_v4()
1288 ));
1289 std::fs::create_dir_all(&dir).unwrap();
1290 std::fs::write(
1291 dir.join("pyproject.toml"),
1292 "[project]\nname = \"myapp\"\n\n[project.scripts]\nmyapp = \"myapp:main\"\n",
1293 )
1294 .unwrap();
1295
1296 let plugin = PythonPlugin;
1297 let cmd = plugin.run_command_for_dir(&dir);
1298 assert_eq!(cmd, "uv run myapp");
1299
1300 let _ = std::fs::remove_dir_all(&dir);
1301 }
1302
1303 #[test]
1304 fn test_python_run_command_for_dir_default() {
1305 let dir = std::env::temp_dir().join(format!(
1306 "perspt_test_pyrun_default_{}",
1307 uuid::Uuid::new_v4()
1308 ));
1309 std::fs::create_dir_all(&dir).unwrap();
1310 std::fs::write(dir.join("pyproject.toml"), "[project]\nname = \"myapp\"\n").unwrap();
1311
1312 let plugin = PythonPlugin;
1313 let cmd = plugin.run_command_for_dir(&dir);
1314 assert_eq!(cmd, "uv run python -m main");
1315
1316 let _ = std::fs::remove_dir_all(&dir);
1317 }
1318}