Skip to main content

perspt_core/plugin/
rust.rs

1use super::*;
2
3/// Rust language plugin
4pub struct RustPlugin;
5
6impl LanguagePlugin for RustPlugin {
7    fn name(&self) -> &str {
8        "rust"
9    }
10
11    fn extensions(&self) -> &[&str] {
12        &["rs"]
13    }
14
15    fn key_files(&self) -> &[&str] {
16        &["Cargo.toml", "Cargo.lock"]
17    }
18
19    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
20        vec![
21            ("cargo", "build/init", "Install Rust via https://rustup.rs"),
22            ("rustc", "compiler", "Install Rust via https://rustup.rs"),
23            (
24                "rust-analyzer",
25                "language server",
26                "rustup component add rust-analyzer",
27            ),
28        ]
29    }
30
31    fn get_lsp_config(&self) -> LspConfig {
32        LspConfig {
33            server_binary: "rust-analyzer".to_string(),
34            args: vec![],
35            language_id: "rust".to_string(),
36        }
37    }
38
39    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
40        let command = if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
41            "cargo init .".to_string()
42        } else {
43            format!("cargo new {}", opts.name)
44        };
45        ProjectAction::ExecCommand {
46            command,
47            description: "Initialize Rust project with Cargo".to_string(),
48        }
49    }
50
51    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
52        // Check if Cargo.lock exists; if not, suggest cargo fetch
53        if !path.join("Cargo.lock").exists() && path.join("Cargo.toml").exists() {
54            ProjectAction::ExecCommand {
55                command: "cargo fetch".to_string(),
56                description: "Fetch Rust dependencies".to_string(),
57            }
58        } else {
59            ProjectAction::NoAction
60        }
61    }
62
63    fn init_command(&self, opts: &InitOptions) -> String {
64        if opts.name == "." || opts.name == "./" {
65            "cargo init .".to_string()
66        } else {
67            format!("cargo new {}", opts.name)
68        }
69    }
70
71    fn test_command(&self) -> String {
72        "cargo test".to_string()
73    }
74
75    fn test_command_with_filter(&self, filter: Option<&str>) -> String {
76        match filter {
77            Some(filter) => format!("cargo test {filter}"),
78            None => self.test_command(),
79        }
80    }
81
82    fn run_command(&self) -> String {
83        "cargo run".to_string()
84    }
85
86    // PSP-5 capability methods
87
88    fn syntax_check_command(&self) -> Option<String> {
89        // JSON messages preserve codes, spans, and suggestions for the
90        // structured verifier plane (PSP-10 system 26); exit-code semantics
91        // are unchanged.
92        Some("cargo check --message-format=json".to_string())
93    }
94
95    fn build_command(&self) -> Option<String> {
96        Some("cargo build --message-format=json".to_string())
97    }
98
99    fn lint_command(&self) -> Option<String> {
100        Some("cargo clippy -- -D warnings".to_string())
101    }
102
103    fn file_ownership_patterns(&self) -> &[&str] {
104        &["rs", "Cargo.toml"]
105    }
106
107    fn is_test_file(&self, path: &str) -> bool {
108        let normalized = path.replace('\\', "/");
109        let file = normalized.rsplit('/').next().unwrap_or(&normalized);
110        normalized.starts_with("tests/")
111            || normalized.contains("/tests/")
112            || file.ends_with("_test.rs")
113    }
114
115    fn host_tool_available(&self) -> bool {
116        host_binary_available("cargo")
117    }
118
119    fn format_command(&self) -> Option<String> {
120        Some("cargo fmt".to_string())
121    }
122
123    fn format_check_command(&self) -> Option<String> {
124        Some("cargo fmt --check".to_string())
125    }
126
127    fn verifier_profile(&self) -> VerifierProfile {
128        let cargo = host_binary_available("cargo");
129        let clippy = cargo; // clippy is a cargo subcommand, same binary
130
131        let capabilities = vec![
132            VerifierCapability {
133                stage: VerifierStage::SyntaxCheck,
134                command: Some("cargo check --message-format=json".to_string()),
135                available: cargo,
136                fallback_command: None,
137                fallback_available: false,
138            },
139            VerifierCapability {
140                stage: VerifierStage::Build,
141                command: Some("cargo build --message-format=json".to_string()),
142                available: cargo,
143                fallback_command: None,
144                fallback_available: false,
145            },
146            VerifierCapability {
147                stage: VerifierStage::Test,
148                command: Some("cargo test".to_string()),
149                available: cargo,
150                fallback_command: None,
151                fallback_available: false,
152            },
153            VerifierCapability {
154                stage: VerifierStage::Lint,
155                command: Some("cargo clippy -- -D warnings".to_string()),
156                available: clippy,
157                fallback_command: None,
158                fallback_available: false,
159            },
160            VerifierCapability {
161                stage: VerifierStage::Format,
162                command: self.format_check_command(),
163                available: cargo,
164                fallback_command: None,
165                fallback_available: false,
166            },
167        ];
168
169        let primary = self.get_lsp_config();
170        let primary_available = host_binary_available(&primary.server_binary);
171
172        VerifierProfile {
173            plugin_name: self.name().to_string(),
174            capabilities,
175            lsp: LspCapability {
176                primary,
177                primary_available,
178                fallback: None,
179                fallback_available: false,
180            },
181        }
182    }
183
184    // PSP-7 correction contract
185
186    fn legal_support_files(&self) -> &[&str] {
187        &["Cargo.toml", "build.rs"]
188    }
189
190    fn manifest_mutation_policy(
191        &self,
192        manifest_path: &str,
193    ) -> crate::types::ManifestMutationPolicy {
194        // Allow leaf Cargo.toml edits, deny workspace root mutations
195        if manifest_path == "Cargo.toml" {
196            // Root workspace Cargo.toml — deny by default
197            crate::types::ManifestMutationPolicy::Deny
198        } else {
199            crate::types::ManifestMutationPolicy::Allow
200        }
201    }
202
203    fn dependency_commands(
204        &self,
205        action: crate::plugin::DependencyAction,
206        packages: &[String],
207        dev: bool,
208    ) -> Vec<String> {
209        use crate::plugin::DependencyAction;
210        let list = packages.join(" ");
211        match action {
212            DependencyAction::Add if dev => vec![format!("cargo add --dev {list}")],
213            DependencyAction::Add => vec![format!("cargo add {list}")],
214            DependencyAction::Remove => vec![format!("cargo remove {list}")],
215            DependencyAction::Update => vec![format!("cargo update {list}")],
216        }
217    }
218
219    fn dependency_files(&self) -> Vec<String> {
220        vec!["Cargo.toml".into(), "Cargo.lock".into()]
221    }
222
223    fn dependency_command_policy(&self, command: &str) -> crate::types::CommandPolicyDecision {
224        let trimmed = command.trim();
225        if trimmed.starts_with("cargo add ")
226            || trimmed.starts_with("cargo install ")
227            || trimmed.starts_with("cargo fetch")
228        {
229            crate::types::CommandPolicyDecision::Allow
230        } else if trimmed.starts_with("cargo remove ") {
231            crate::types::CommandPolicyDecision::RequireApproval
232        } else if trimmed.starts_with("cargo ") {
233            // Other cargo subcommands: build, test, check, etc. are fine
234            crate::types::CommandPolicyDecision::Allow
235        } else {
236            crate::types::CommandPolicyDecision::Deny
237        }
238    }
239
240    fn test_file_patterns(&self) -> &[&str] {
241        &["tests/*.rs", "tests/**/*.rs", "**/tests.rs"]
242    }
243}