batuta/comply/rules/
cargo_toml.rs1use crate::comply::rule::{
6 FixResult, RuleCategory, RuleResult, RuleViolation, StackComplianceRule, ViolationLevel,
7};
8use std::collections::HashMap;
9use std::path::Path;
10
11#[derive(Debug)]
13pub struct CargoTomlRule {
14 required_deps: HashMap<String, String>,
16 prohibited_deps: Vec<String>,
18 required_edition: Option<String>,
20 required_license: Option<String>,
22}
23
24impl Default for CargoTomlRule {
25 fn default() -> Self {
26 Self::new()
27 }
28}
29
30impl CargoTomlRule {
31 pub fn new() -> Self {
33 let mut required_deps = HashMap::new();
34 required_deps.insert("trueno".to_string(), ">=0.14".to_string());
36
37 Self {
38 required_deps,
39 prohibited_deps: vec!["cargo-tarpaulin".to_string()],
40 required_edition: Some("2024".to_string()),
41 required_license: Some("MIT OR Apache-2.0".to_string()),
42 }
43 }
44
45 fn parse_cargo_toml(&self, path: &Path) -> anyhow::Result<CargoTomlData> {
47 let content = std::fs::read_to_string(path)?;
48 let toml: toml::Value = toml::from_str(&content)?;
49
50 let package = toml.get("package");
51 let name = package.and_then(|p| p.get("name")).and_then(|n| n.as_str()).map(String::from);
52 let edition =
53 package.and_then(|p| p.get("edition")).and_then(|e| e.as_str()).map(String::from);
54 let license =
55 package.and_then(|p| p.get("license")).and_then(|l| l.as_str()).map(String::from);
56 let rust_version =
57 package.and_then(|p| p.get("rust-version")).and_then(|r| r.as_str()).map(String::from);
58
59 let mut dependencies = HashMap::new();
60 let mut dev_dependencies = HashMap::new();
61
62 if let Some(deps) = toml.get("dependencies") {
64 if let Some(table) = deps.as_table() {
65 for (name, value) in table {
66 let version = extract_version(value);
67 dependencies.insert(name.clone(), version);
68 }
69 }
70 }
71
72 if let Some(deps) = toml.get("dev-dependencies") {
74 if let Some(table) = deps.as_table() {
75 for (name, value) in table {
76 let version = extract_version(value);
77 dev_dependencies.insert(name.clone(), version);
78 }
79 }
80 }
81
82 Ok(CargoTomlData { name, edition, license, rust_version, dependencies, dev_dependencies })
83 }
84}
85
86fn extract_version(value: &toml::Value) -> Option<String> {
87 match value {
88 toml::Value::String(s) => Some(s.clone()),
89 toml::Value::Table(t) => t.get("version").and_then(|v| v.as_str()).map(String::from),
90 _ => None,
91 }
92}
93
94#[derive(Debug)]
95struct CargoTomlData {
96 name: Option<String>,
97 edition: Option<String>,
98 license: Option<String>,
99 rust_version: Option<String>,
100 dependencies: HashMap<String, Option<String>>,
101 dev_dependencies: HashMap<String, Option<String>>,
102}
103
104impl StackComplianceRule for CargoTomlRule {
105 fn id(&self) -> &'static str {
106 "cargo-toml-consistency"
107 }
108
109 fn description(&self) -> &'static str {
110 "Ensures consistent Cargo.toml configuration across stack projects"
111 }
112
113 fn help(&self) -> Option<&str> {
114 Some(
115 "Checks: edition, license, required dependencies\n\
116 Prohibited: cargo-tarpaulin",
117 )
118 }
119
120 fn category(&self) -> RuleCategory {
121 RuleCategory::Build
122 }
123
124 fn check(&self, project_path: &Path) -> anyhow::Result<RuleResult> {
125 let cargo_toml_path = project_path.join("Cargo.toml");
126
127 if !cargo_toml_path.exists() {
128 return Ok(RuleResult::fail(vec![RuleViolation::new(
129 "CT-001",
130 "Cargo.toml not found",
131 )
132 .with_severity(ViolationLevel::Critical)
133 .with_location(project_path.display().to_string())]));
134 }
135
136 let data = self.parse_cargo_toml(&cargo_toml_path)?;
137 let mut violations = Vec::new();
138
139 if let Some(required_edition) = &self.required_edition {
141 match &data.edition {
142 None => {
143 violations.push(
144 RuleViolation::new("CT-002", "Edition not specified")
145 .with_severity(ViolationLevel::Warning)
146 .with_location("Cargo.toml".to_string())
147 .with_diff(required_edition.clone(), "(not set)".to_string())
148 .fixable(),
149 );
150 }
151 Some(edition) if edition != required_edition => {
152 violations.push(
154 RuleViolation::new(
155 "CT-003",
156 format!("Edition mismatch: expected {}", required_edition),
157 )
158 .with_severity(ViolationLevel::Warning)
159 .with_location("Cargo.toml".to_string())
160 .with_diff(required_edition.clone(), edition.clone())
161 .fixable(),
162 );
163 }
164 _ => {}
165 }
166 }
167
168 if let Some(required_license) = &self.required_license {
170 match &data.license {
171 None => {
172 violations.push(
173 RuleViolation::new("CT-004", "License not specified")
174 .with_severity(ViolationLevel::Error)
175 .with_location("Cargo.toml".to_string())
176 .with_diff(required_license.clone(), "(not set)".to_string())
177 .fixable(),
178 );
179 }
180 Some(license) if license != required_license => {
181 violations.push(
183 RuleViolation::new(
184 "CT-005",
185 format!("License differs from standard: {}", required_license),
186 )
187 .with_severity(ViolationLevel::Info)
188 .with_location("Cargo.toml".to_string())
189 .with_diff(required_license.clone(), license.clone()),
190 );
191 }
192 _ => {}
193 }
194 }
195
196 for prohibited in &self.prohibited_deps {
198 if data.dependencies.contains_key(prohibited) {
199 violations.push(
200 RuleViolation::new("CT-006", format!("Prohibited dependency: {}", prohibited))
201 .with_severity(ViolationLevel::Critical)
202 .with_location("Cargo.toml".to_string()),
203 );
204 }
205 if data.dev_dependencies.contains_key(prohibited) {
206 violations.push(
207 RuleViolation::new(
208 "CT-007",
209 format!("Prohibited dev-dependency: {}", prohibited),
210 )
211 .with_severity(ViolationLevel::Critical)
212 .with_location("Cargo.toml".to_string()),
213 );
214 }
215 }
216
217 if violations.is_empty() {
222 Ok(RuleResult::pass())
223 } else {
224 Ok(RuleResult::fail(violations))
225 }
226 }
227
228 fn can_fix(&self) -> bool {
229 false }
231
232 fn fix(&self, _project_path: &Path) -> anyhow::Result<FixResult> {
233 Ok(FixResult::failure("Auto-fix not supported for Cargo.toml - manual review required"))
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use tempfile::TempDir;
241
242 #[test]
243 fn test_cargo_toml_rule_creation() {
244 let rule = CargoTomlRule::new();
245 assert_eq!(rule.id(), "cargo-toml-consistency");
246 assert!(rule.required_deps.contains_key("trueno"));
247 }
248
249 #[test]
250 fn test_missing_cargo_toml() {
251 let temp = TempDir::new().expect("tempdir creation failed");
252 let rule = CargoTomlRule::new();
253 let result = rule.check(temp.path()).expect("check failed");
254 assert!(!result.passed);
255 assert_eq!(result.violations[0].code, "CT-001");
256 }
257
258 #[test]
259 fn test_valid_cargo_toml() {
260 let temp = TempDir::new().expect("tempdir creation failed");
261 let cargo_toml = temp.path().join("Cargo.toml");
262
263 let content = r#"
264[package]
265name = "test-crate"
266version = "0.1.0"
267edition = "2024"
268license = "MIT OR Apache-2.0"
269
270[dependencies]
271trueno = "0.14"
272"#;
273 std::fs::write(&cargo_toml, content).expect("fs write failed");
274
275 let rule = CargoTomlRule::new();
276 let result = rule.check(temp.path()).expect("check failed");
277 assert!(result.passed, "Should pass: {:?}", result.violations);
278 }
279
280 #[test]
281 fn test_missing_edition() {
282 let temp = TempDir::new().expect("tempdir creation failed");
283 let cargo_toml = temp.path().join("Cargo.toml");
284
285 let content = r#"
286[package]
287name = "test-crate"
288version = "0.1.0"
289license = "MIT OR Apache-2.0"
290
291[dependencies]
292"#;
293 std::fs::write(&cargo_toml, content).expect("fs write failed");
294
295 let rule = CargoTomlRule::new();
296 let result = rule.check(temp.path()).expect("check failed");
297 assert!(!result.passed);
298 assert!(result.violations.iter().any(|v| v.code == "CT-002"));
299 }
300
301 #[test]
302 fn test_prohibited_dependency() {
303 let temp = TempDir::new().expect("tempdir creation failed");
304 let cargo_toml = temp.path().join("Cargo.toml");
305
306 let content = r#"
307[package]
308name = "test-crate"
309version = "0.1.0"
310edition = "2024"
311license = "MIT OR Apache-2.0"
312
313[dev-dependencies]
314cargo-tarpaulin = "0.1"
315"#;
316 std::fs::write(&cargo_toml, content).expect("fs write failed");
317
318 let rule = CargoTomlRule::new();
319 let result = rule.check(temp.path()).expect("check failed");
320 assert!(!result.passed);
321 assert!(result.violations.iter().any(|v| v.code == "CT-007"));
322 }
323
324 #[test]
325 fn test_wrong_edition() {
326 let temp = TempDir::new().expect("tempdir creation failed");
327 let cargo_toml = temp.path().join("Cargo.toml");
328
329 let content = r#"
330[package]
331name = "test-crate"
332version = "0.1.0"
333edition = "2021"
334license = "MIT OR Apache-2.0"
335
336[dependencies]
337"#;
338 std::fs::write(&cargo_toml, content).expect("fs write failed");
339
340 let rule = CargoTomlRule::new();
341 let result = rule.check(temp.path()).expect("check failed");
342 assert!(!result.passed);
343 assert!(result.violations.iter().any(|v| v.code == "CT-003"));
344 }
345
346 #[test]
347 fn test_missing_license() {
348 let temp = TempDir::new().expect("tempdir creation failed");
349 let cargo_toml = temp.path().join("Cargo.toml");
350
351 let content = r#"
352[package]
353name = "test-crate"
354version = "0.1.0"
355edition = "2024"
356
357[dependencies]
358"#;
359 std::fs::write(&cargo_toml, content).expect("fs write failed");
360
361 let rule = CargoTomlRule::new();
362 let result = rule.check(temp.path()).expect("check failed");
363 assert!(!result.passed);
364 assert!(result.violations.iter().any(|v| v.code == "CT-004"));
365 }
366
367 #[test]
368 fn test_different_license() {
369 let temp = TempDir::new().expect("tempdir creation failed");
370 let cargo_toml = temp.path().join("Cargo.toml");
371
372 let content = r#"
373[package]
374name = "test-crate"
375version = "0.1.0"
376edition = "2024"
377license = "GPL-3.0"
378
379[dependencies]
380"#;
381 std::fs::write(&cargo_toml, content).expect("fs write failed");
382
383 let rule = CargoTomlRule::new();
384 let result = rule.check(temp.path()).expect("check failed");
385 assert!(result.violations.iter().any(|v| v.code == "CT-005"));
387 }
388
389 #[test]
390 fn test_prohibited_dependency_in_deps() {
391 let temp = TempDir::new().expect("tempdir creation failed");
392 let cargo_toml = temp.path().join("Cargo.toml");
393
394 let content = r#"
395[package]
396name = "test-crate"
397version = "0.1.0"
398edition = "2024"
399license = "MIT OR Apache-2.0"
400
401[dependencies]
402cargo-tarpaulin = "0.1"
403"#;
404 std::fs::write(&cargo_toml, content).expect("fs write failed");
405
406 let rule = CargoTomlRule::new();
407 let result = rule.check(temp.path()).expect("check failed");
408 assert!(!result.passed);
409 assert!(result.violations.iter().any(|v| v.code == "CT-006"));
410 }
411
412 #[test]
413 fn test_can_fix_returns_false() {
414 let rule = CargoTomlRule::new();
415 assert!(!rule.can_fix());
416 }
417
418 #[test]
419 fn test_fix_returns_failure() {
420 let temp = TempDir::new().expect("tempdir creation failed");
421 let rule = CargoTomlRule::new();
422 let result = rule.fix(temp.path()).expect("unexpected failure");
423 assert!(!result.success);
424 }
425
426 #[test]
427 fn test_rule_category() {
428 let rule = CargoTomlRule::new();
429 assert_eq!(rule.category(), RuleCategory::Build);
430 }
431
432 #[test]
433 fn test_rule_description() {
434 let rule = CargoTomlRule::new();
435 assert!(!rule.description().is_empty());
436 }
437
438 #[test]
439 fn test_default_trait() {
440 let rule = CargoTomlRule::default();
441 assert_eq!(rule.id(), "cargo-toml-consistency");
442 }
443
444 #[test]
445 fn test_invalid_toml() {
446 let temp = TempDir::new().expect("tempdir creation failed");
447 let cargo_toml = temp.path().join("Cargo.toml");
448 std::fs::write(&cargo_toml, "invalid toml {{{{").expect("fs write failed");
449
450 let rule = CargoTomlRule::new();
451 let result = rule.check(temp.path());
453 assert!(result.is_err() || !result.expect("operation failed").passed);
455 }
456}