Skip to main content

batuta/comply/rules/
cargo_toml.rs

1//! Cargo.toml Consistency Rule
2//!
3//! Ensures consistent Cargo.toml configuration across PAIML stack projects.
4
5use crate::comply::rule::{
6    FixResult, RuleCategory, RuleResult, RuleViolation, StackComplianceRule, ViolationLevel,
7};
8use std::collections::HashMap;
9use std::path::Path;
10
11/// Cargo.toml consistency rule
12#[derive(Debug)]
13pub struct CargoTomlRule {
14    /// Required dependencies with version constraints
15    required_deps: HashMap<String, String>,
16    /// Prohibited dependencies
17    prohibited_deps: Vec<String>,
18    /// Required edition
19    required_edition: Option<String>,
20    /// Required license
21    required_license: Option<String>,
22}
23
24impl Default for CargoTomlRule {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl CargoTomlRule {
31    /// Create a new Cargo.toml rule with default configuration
32    pub fn new() -> Self {
33        let mut required_deps = HashMap::new();
34        // trueno is a core dependency for stack crates
35        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    /// Parse Cargo.toml and extract relevant fields
46    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        // Parse dependencies
63        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        // Parse dev-dependencies
73        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        // Check edition
140        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                    // Allow older editions but warn
153                    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        // Check license
169        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                    // Just warn, different licenses may be intentional
182                    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        // Check prohibited dependencies
197        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        // Note: We don't enforce trueno dependency for all crates,
218        // as some crates (like documentation) don't need it
219        // This could be made configurable per-crate category
220
221        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 // Cargo.toml changes are too risky for auto-fix
230    }
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        // Different license is just a warning (Info), so it still passes
386        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        // Should not panic, should return an error result
452        let result = rule.check(temp.path());
453        // The implementation returns an Err for parse failures
454        assert!(result.is_err() || !result.expect("operation failed").passed);
455    }
456}