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", "Cargo.toml"]
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("uvx 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", "pyproject.toml", "setup.py", "requirements.txt"]
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("uvx ty check .".to_string()),
750 available: uv,
751 fallback_command: Some("pyright .".to_string()),
753 fallback_available: pyright,
754 },
755 VerifierCapability {
756 stage: VerifierStage::Build,
757 command: None,
760 available: true,
761 fallback_command: None,
762 fallback_available: false,
763 },
764 VerifierCapability {
765 stage: VerifierStage::Test,
766 command: Some("uv run pytest".to_string()),
767 available: uv,
768 fallback_command: Some("python -m pytest".to_string()),
770 fallback_available: host_binary_available("python3")
771 || host_binary_available("python"),
772 },
773 VerifierCapability {
774 stage: VerifierStage::Lint,
775 command: Some("uv run ruff check .".to_string()),
776 available: uv,
777 fallback_command: Some("ruff check .".to_string()),
778 fallback_available: host_binary_available("ruff"),
779 },
780 ];
781
782 let primary = self.get_lsp_config();
783 let primary_available = host_binary_available("uvx");
784 let fallback = self.lsp_fallback();
785 let fallback_available = fallback
786 .as_ref()
787 .map(|f| host_binary_available(&f.server_binary))
788 .unwrap_or(false);
789
790 VerifierProfile {
791 plugin_name: self.name().to_string(),
792 capabilities,
793 lsp: LspCapability {
794 primary,
795 primary_available,
796 fallback,
797 fallback_available,
798 },
799 }
800 }
801}
802
803pub struct JsPlugin;
805
806impl LanguagePlugin for JsPlugin {
807 fn name(&self) -> &str {
808 "javascript"
809 }
810
811 fn extensions(&self) -> &[&str] {
812 &["js", "ts", "jsx", "tsx"]
813 }
814
815 fn key_files(&self) -> &[&str] {
816 &["package.json", "tsconfig.json"]
817 }
818
819 fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
820 vec![
821 (
822 "node",
823 "runtime",
824 "Install Node.js from https://nodejs.org or via nvm",
825 ),
826 (
827 "npm",
828 "package manager",
829 "Included with Node.js — install from https://nodejs.org",
830 ),
831 (
832 "typescript-language-server",
833 "language server",
834 "npm install -g typescript-language-server typescript",
835 ),
836 ]
837 }
838
839 fn get_lsp_config(&self) -> LspConfig {
840 LspConfig {
841 server_binary: "typescript-language-server".to_string(),
842 args: vec!["--stdio".to_string()],
843 language_id: "typescript".to_string(),
844 }
845 }
846
847 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
848 let command = match opts.package_manager.as_deref() {
849 Some("pnpm") => {
850 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
851 "pnpm init".to_string()
852 } else {
853 format!("mkdir -p {} && cd {} && pnpm init", opts.name, opts.name)
854 }
855 }
856 Some("yarn") => {
857 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
858 "yarn init -y".to_string()
859 } else {
860 format!("mkdir -p {} && cd {} && yarn init -y", opts.name, opts.name)
861 }
862 }
863 _ => {
864 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
866 "npm init -y".to_string()
867 } else {
868 format!("mkdir -p {} && cd {} && npm init -y", opts.name, opts.name)
869 }
870 }
871 };
872 let description = match opts.package_manager.as_deref() {
873 Some("pnpm") => "Initialize JavaScript project with pnpm",
874 Some("yarn") => "Initialize JavaScript project with Yarn",
875 _ => "Initialize JavaScript project with npm",
876 };
877 ProjectAction::ExecCommand {
878 command,
879 description: description.to_string(),
880 }
881 }
882
883 fn check_tooling_action(&self, path: &Path) -> ProjectAction {
884 let has_package_json = path.join("package.json").exists();
886 let has_node_modules = path.join("node_modules").exists();
887
888 if has_package_json && !has_node_modules {
889 ProjectAction::ExecCommand {
890 command: "npm install".to_string(),
891 description: "Install Node.js dependencies".to_string(),
892 }
893 } else {
894 ProjectAction::NoAction
895 }
896 }
897
898 fn init_command(&self, opts: &InitOptions) -> String {
899 format!("npm init -y && mv package.json {}/", opts.name)
900 }
901
902 fn test_command(&self) -> String {
903 "npm test".to_string()
904 }
905
906 fn run_command(&self) -> String {
907 "npm start".to_string()
908 }
909
910 fn syntax_check_command(&self) -> Option<String> {
913 Some("npx tsc --noEmit".to_string())
914 }
915
916 fn build_command(&self) -> Option<String> {
917 Some("npm run build".to_string())
918 }
919
920 fn lint_command(&self) -> Option<String> {
921 Some("npx eslint .".to_string())
922 }
923
924 fn file_ownership_patterns(&self) -> &[&str] {
925 &["js", "ts", "jsx", "tsx", "package.json", "tsconfig.json"]
926 }
927
928 fn host_tool_available(&self) -> bool {
929 host_binary_available("node")
930 }
931
932 fn verifier_profile(&self) -> VerifierProfile {
933 let node = host_binary_available("node");
934 let npx = host_binary_available("npx");
935
936 let capabilities = vec![
937 VerifierCapability {
938 stage: VerifierStage::SyntaxCheck,
939 command: Some("npx tsc --noEmit".to_string()),
940 available: npx,
941 fallback_command: None,
942 fallback_available: false,
943 },
944 VerifierCapability {
945 stage: VerifierStage::Build,
946 command: Some("npm run build".to_string()),
947 available: node,
948 fallback_command: None,
949 fallback_available: false,
950 },
951 VerifierCapability {
952 stage: VerifierStage::Test,
953 command: Some("npm test".to_string()),
954 available: node,
955 fallback_command: None,
956 fallback_available: false,
957 },
958 VerifierCapability {
959 stage: VerifierStage::Lint,
960 command: Some("npx eslint .".to_string()),
961 available: npx,
962 fallback_command: None,
963 fallback_available: false,
964 },
965 ];
966
967 let primary = self.get_lsp_config();
968 let primary_available = host_binary_available(&primary.server_binary);
969
970 VerifierProfile {
971 plugin_name: self.name().to_string(),
972 capabilities,
973 lsp: LspCapability {
974 primary,
975 primary_available,
976 fallback: None,
977 fallback_available: false,
978 },
979 }
980 }
981}
982
983pub struct PluginRegistry {
985 plugins: Vec<Box<dyn LanguagePlugin>>,
986}
987
988impl PluginRegistry {
989 pub fn new() -> Self {
991 Self {
992 plugins: vec![
993 Box::new(RustPlugin),
994 Box::new(PythonPlugin),
995 Box::new(JsPlugin),
996 ],
997 }
998 }
999
1000 pub fn detect(&self, path: &Path) -> Option<&dyn LanguagePlugin> {
1002 self.plugins
1003 .iter()
1004 .find(|p| p.detect(path))
1005 .map(|p| p.as_ref())
1006 }
1007
1008 pub fn detect_all(&self, path: &Path) -> Vec<&dyn LanguagePlugin> {
1013 self.plugins
1014 .iter()
1015 .filter(|p| p.detect(path))
1016 .map(|p| p.as_ref())
1017 .collect()
1018 }
1019
1020 pub fn get(&self, name: &str) -> Option<&dyn LanguagePlugin> {
1022 self.plugins
1023 .iter()
1024 .find(|p| p.name() == name)
1025 .map(|p| p.as_ref())
1026 }
1027
1028 pub fn all(&self) -> &[Box<dyn LanguagePlugin>] {
1030 &self.plugins
1031 }
1032}
1033
1034impl Default for PluginRegistry {
1035 fn default() -> Self {
1036 Self::new()
1037 }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043
1044 #[test]
1045 fn test_plugin_owns_file() {
1046 let rust = RustPlugin;
1047 assert!(rust.owns_file("src/main.rs"));
1048 assert!(rust.owns_file("crates/core/src/lib.rs"));
1049 assert!(!rust.owns_file("main.py"));
1050 assert!(!rust.owns_file("index.js"));
1051
1052 let python = PythonPlugin;
1053 assert!(python.owns_file("main.py"));
1054 assert!(python.owns_file("tests/test_main.py"));
1055 assert!(!python.owns_file("src/main.rs"));
1056
1057 let js = JsPlugin;
1058 assert!(js.owns_file("index.js"));
1059 assert!(js.owns_file("src/app.ts"));
1060 assert!(!js.owns_file("main.py"));
1061 assert!(!js.owns_file("src/main.rs"));
1062 }
1063
1064 #[test]
1069 fn test_verifier_capability_effective_command() {
1070 let cap = VerifierCapability {
1072 stage: VerifierStage::SyntaxCheck,
1073 command: Some("cargo check".to_string()),
1074 available: true,
1075 fallback_command: Some("rustc --edition 2021".to_string()),
1076 fallback_available: true,
1077 };
1078 assert_eq!(cap.effective_command(), Some("cargo check"));
1079 assert!(cap.any_available());
1080
1081 let cap2 = VerifierCapability {
1083 stage: VerifierStage::Lint,
1084 command: Some("uv run ruff check .".to_string()),
1085 available: false,
1086 fallback_command: Some("ruff check .".to_string()),
1087 fallback_available: true,
1088 };
1089 assert_eq!(cap2.effective_command(), Some("ruff check ."));
1090 assert!(cap2.any_available());
1091
1092 let cap3 = VerifierCapability {
1094 stage: VerifierStage::Build,
1095 command: Some("cargo build".to_string()),
1096 available: false,
1097 fallback_command: None,
1098 fallback_available: false,
1099 };
1100 assert_eq!(cap3.effective_command(), None);
1101 assert!(!cap3.any_available());
1102 }
1103
1104 #[test]
1105 fn test_verifier_profile_get_and_available_stages() {
1106 let profile = VerifierProfile {
1107 plugin_name: "test".to_string(),
1108 capabilities: vec![
1109 VerifierCapability {
1110 stage: VerifierStage::SyntaxCheck,
1111 command: Some("check".to_string()),
1112 available: true,
1113 fallback_command: None,
1114 fallback_available: false,
1115 },
1116 VerifierCapability {
1117 stage: VerifierStage::Build,
1118 command: Some("build".to_string()),
1119 available: false,
1120 fallback_command: None,
1121 fallback_available: false,
1122 },
1123 VerifierCapability {
1124 stage: VerifierStage::Test,
1125 command: Some("test".to_string()),
1126 available: true,
1127 fallback_command: None,
1128 fallback_available: false,
1129 },
1130 ],
1131 lsp: LspCapability {
1132 primary: LspConfig {
1133 server_binary: "test-ls".to_string(),
1134 args: vec![],
1135 language_id: "test".to_string(),
1136 },
1137 primary_available: false,
1138 fallback: None,
1139 fallback_available: false,
1140 },
1141 };
1142
1143 assert!(profile.get(VerifierStage::SyntaxCheck).is_some());
1144 assert!(profile.get(VerifierStage::Lint).is_none());
1145
1146 let available = profile.available_stages();
1147 assert_eq!(available.len(), 2);
1148 assert!(available.contains(&VerifierStage::SyntaxCheck));
1149 assert!(available.contains(&VerifierStage::Test));
1150 assert!(!available.contains(&VerifierStage::Build));
1151 assert!(!profile.fully_degraded());
1152 }
1153
1154 #[test]
1155 fn test_verifier_profile_fully_degraded() {
1156 let profile = VerifierProfile {
1157 plugin_name: "empty".to_string(),
1158 capabilities: vec![VerifierCapability {
1159 stage: VerifierStage::Build,
1160 command: Some("build".to_string()),
1161 available: false,
1162 fallback_command: None,
1163 fallback_available: false,
1164 }],
1165 lsp: LspCapability {
1166 primary: LspConfig {
1167 server_binary: "none".to_string(),
1168 args: vec![],
1169 language_id: "none".to_string(),
1170 },
1171 primary_available: false,
1172 fallback: None,
1173 fallback_available: false,
1174 },
1175 };
1176 assert!(profile.fully_degraded());
1177 assert!(profile.available_stages().is_empty());
1178 }
1179
1180 #[test]
1181 fn test_lsp_capability_effective_config() {
1182 let lsp = LspCapability {
1183 primary: LspConfig {
1184 server_binary: "rust-analyzer".to_string(),
1185 args: vec![],
1186 language_id: "rust".to_string(),
1187 },
1188 primary_available: true,
1189 fallback: None,
1190 fallback_available: false,
1191 };
1192 assert_eq!(
1193 lsp.effective_config().unwrap().server_binary,
1194 "rust-analyzer"
1195 );
1196
1197 let lsp2 = LspCapability {
1199 primary: LspConfig {
1200 server_binary: "uvx".to_string(),
1201 args: vec![],
1202 language_id: "python".to_string(),
1203 },
1204 primary_available: false,
1205 fallback: Some(LspConfig {
1206 server_binary: "pyright-langserver".to_string(),
1207 args: vec!["--stdio".to_string()],
1208 language_id: "python".to_string(),
1209 }),
1210 fallback_available: true,
1211 };
1212 assert_eq!(
1213 lsp2.effective_config().unwrap().server_binary,
1214 "pyright-langserver"
1215 );
1216
1217 let lsp3 = LspCapability {
1219 primary: LspConfig {
1220 server_binary: "nope".to_string(),
1221 args: vec![],
1222 language_id: "none".to_string(),
1223 },
1224 primary_available: false,
1225 fallback: None,
1226 fallback_available: false,
1227 };
1228 assert!(lsp3.effective_config().is_none());
1229 }
1230
1231 #[test]
1232 fn test_rust_plugin_verifier_profile_shape() {
1233 let rust = RustPlugin;
1234 let profile = rust.verifier_profile();
1235 assert_eq!(profile.plugin_name, "rust");
1236 assert_eq!(profile.capabilities.len(), 4);
1238 let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1239 assert!(stages.contains(&VerifierStage::SyntaxCheck));
1240 assert!(stages.contains(&VerifierStage::Build));
1241 assert!(stages.contains(&VerifierStage::Test));
1242 assert!(stages.contains(&VerifierStage::Lint));
1243 }
1244
1245 #[test]
1246 fn test_python_plugin_verifier_profile_shape() {
1247 let py = PythonPlugin;
1248 let profile = py.verifier_profile();
1249 assert_eq!(profile.plugin_name, "python");
1250 assert_eq!(profile.capabilities.len(), 4);
1252 let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1253 assert!(stages.contains(&VerifierStage::SyntaxCheck));
1254 assert!(stages.contains(&VerifierStage::Build));
1255 assert!(stages.contains(&VerifierStage::Test));
1256 assert!(stages.contains(&VerifierStage::Lint));
1257 assert!(profile.lsp.fallback.is_some());
1259 }
1260
1261 #[test]
1262 fn test_js_plugin_verifier_profile_shape() {
1263 let js = JsPlugin;
1264 let profile = js.verifier_profile();
1265 assert_eq!(profile.plugin_name, "javascript");
1266 assert_eq!(profile.capabilities.len(), 4);
1268 }
1269
1270 #[test]
1271 fn test_verifier_stage_display() {
1272 assert_eq!(format!("{}", VerifierStage::SyntaxCheck), "syntax_check");
1273 assert_eq!(format!("{}", VerifierStage::Build), "build");
1274 assert_eq!(format!("{}", VerifierStage::Test), "test");
1275 assert_eq!(format!("{}", VerifierStage::Lint), "lint");
1276 }
1277
1278 #[test]
1279 fn test_python_run_command_for_dir_src_layout() {
1280 let dir =
1281 std::env::temp_dir().join(format!("perspt_test_pyrun_src_{}", uuid::Uuid::new_v4()));
1282 std::fs::create_dir_all(dir.join("src/myapp")).unwrap();
1283 std::fs::write(dir.join("src/myapp/__init__.py"), "").unwrap();
1284
1285 let plugin = PythonPlugin;
1286 let cmd = plugin.run_command_for_dir(&dir);
1287 assert_eq!(cmd, "uv run python -m myapp");
1288
1289 let _ = std::fs::remove_dir_all(&dir);
1290 }
1291
1292 #[test]
1293 fn test_python_run_command_for_dir_scripts() {
1294 let dir = std::env::temp_dir().join(format!(
1295 "perspt_test_pyrun_scripts_{}",
1296 uuid::Uuid::new_v4()
1297 ));
1298 std::fs::create_dir_all(&dir).unwrap();
1299 std::fs::write(
1300 dir.join("pyproject.toml"),
1301 "[project]\nname = \"myapp\"\n\n[project.scripts]\nmyapp = \"myapp:main\"\n",
1302 )
1303 .unwrap();
1304
1305 let plugin = PythonPlugin;
1306 let cmd = plugin.run_command_for_dir(&dir);
1307 assert_eq!(cmd, "uv run myapp");
1308
1309 let _ = std::fs::remove_dir_all(&dir);
1310 }
1311
1312 #[test]
1313 fn test_python_run_command_for_dir_default() {
1314 let dir = std::env::temp_dir().join(format!(
1315 "perspt_test_pyrun_default_{}",
1316 uuid::Uuid::new_v4()
1317 ));
1318 std::fs::create_dir_all(&dir).unwrap();
1319 std::fs::write(dir.join("pyproject.toml"), "[project]\nname = \"myapp\"\n").unwrap();
1320
1321 let plugin = PythonPlugin;
1322 let cmd = plugin.run_command_for_dir(&dir);
1323 assert_eq!(cmd, "uv run python -m main");
1324
1325 let _ = std::fs::remove_dir_all(&dir);
1326 }
1327}