Skip to main content

batuta/comply/
config.rs

1//! Stack Compliance Configuration
2//!
3//! Defines the schema for stack-comply.yaml configuration files.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9/// Main compliance configuration
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct ComplyConfig {
12    /// Workspace root directory
13    #[serde(default)]
14    pub workspace: Option<PathBuf>,
15
16    /// Rules to enable (if empty, all rules are enabled)
17    #[serde(default)]
18    pub enabled_rules: Vec<String>,
19
20    /// Rules to disable
21    #[serde(default)]
22    pub disabled_rules: Vec<String>,
23
24    /// Include non-PAIML crates in checks
25    #[serde(default)]
26    pub include_external: bool,
27
28    /// Project-specific overrides
29    #[serde(default)]
30    pub project_overrides: HashMap<String, ProjectOverride>,
31
32    /// Makefile configuration
33    #[serde(default)]
34    pub makefile: MakefileConfig,
35
36    /// Cargo.toml configuration
37    #[serde(default)]
38    pub cargo_toml: CargoTomlConfig,
39
40    /// CI workflow configuration
41    #[serde(default)]
42    pub ci_workflows: CiWorkflowConfig,
43
44    /// Duplication detection configuration
45    #[serde(default)]
46    pub duplication: DuplicationConfig,
47}
48
49impl ComplyConfig {
50    /// Create default configuration for a workspace
51    pub fn default_for_workspace(workspace: &Path) -> Self {
52        Self { workspace: Some(workspace.to_path_buf()), ..Default::default() }
53    }
54
55    /// Load configuration from a YAML file
56    pub fn load(path: &Path) -> anyhow::Result<Self> {
57        let content = std::fs::read_to_string(path)?;
58        let config: Self = serde_yaml_ng::from_str(&content)?;
59        Ok(config)
60    }
61
62    /// Load configuration from workspace or use defaults
63    pub fn load_or_default(workspace: &Path) -> Self {
64        let config_path = workspace.join("stack-comply.yaml");
65        if config_path.exists() {
66            Self::load(&config_path).unwrap_or_else(|_| Self::default_for_workspace(workspace))
67        } else {
68            Self::default_for_workspace(workspace)
69        }
70    }
71
72    /// Save configuration to a YAML file
73    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
74        let content = serde_yaml_ng::to_string(self)?;
75        std::fs::write(path, content)?;
76        Ok(())
77    }
78}
79
80/// Project-specific override configuration
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82pub struct ProjectOverride {
83    /// Rules exempt from checking for this project
84    #[serde(default)]
85    pub exempt_rules: Vec<String>,
86
87    /// Custom Makefile targets for this project
88    #[serde(default)]
89    pub custom_targets: Vec<String>,
90
91    /// Justification for overrides
92    pub justification: Option<String>,
93}
94
95/// Makefile target configuration
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(default)]
98pub struct MakefileConfig {
99    /// Required targets with expected command patterns
100    pub required_targets: HashMap<String, TargetConfig>,
101
102    /// Allowed variations for specific targets
103    pub allowed_variations: HashMap<String, Vec<VariationConfig>>,
104
105    /// Prohibited commands (e.g., cargo tarpaulin)
106    pub prohibited_commands: Vec<String>,
107}
108
109impl Default for MakefileConfig {
110    fn default() -> Self {
111        let mut required_targets = HashMap::new();
112
113        required_targets.insert(
114            "test-fast".to_string(),
115            TargetConfig {
116                pattern: Some("cargo nextest run --lib".to_string()),
117                description: "Fast unit tests".to_string(),
118                required: true,
119            },
120        );
121
122        required_targets.insert(
123            "test".to_string(),
124            TargetConfig {
125                pattern: Some("cargo nextest run".to_string()),
126                description: "Standard tests".to_string(),
127                required: true,
128            },
129        );
130
131        required_targets.insert(
132            "lint".to_string(),
133            TargetConfig {
134                pattern: Some("cargo clippy".to_string()),
135                description: "Clippy linting".to_string(),
136                required: true,
137            },
138        );
139
140        required_targets.insert(
141            "fmt".to_string(),
142            TargetConfig {
143                pattern: Some("cargo fmt".to_string()),
144                description: "Format code".to_string(),
145                required: true,
146            },
147        );
148
149        required_targets.insert(
150            "coverage".to_string(),
151            TargetConfig {
152                pattern: Some("cargo llvm-cov".to_string()),
153                description: "Coverage report".to_string(),
154                required: true,
155            },
156        );
157
158        let prohibited_commands =
159            vec!["cargo tarpaulin".to_string(), "cargo-tarpaulin".to_string()];
160
161        Self { required_targets, allowed_variations: HashMap::new(), prohibited_commands }
162    }
163}
164
165/// Configuration for a single Makefile target
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TargetConfig {
168    /// Expected command pattern (regex or contains)
169    pub pattern: Option<String>,
170    /// Description of what this target should do
171    pub description: String,
172    /// Whether this target is required
173    #[serde(default = "default_true")]
174    pub required: bool,
175}
176
177fn default_true() -> bool {
178    true
179}
180
181/// Allowed variation for a target
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct VariationConfig {
184    /// Pattern that's allowed
185    pub pattern: String,
186    /// Reason this variation is acceptable
187    pub reason: String,
188}
189
190/// Cargo.toml consistency configuration
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct CargoTomlConfig {
193    /// Required dependencies with version constraints
194    #[serde(default)]
195    pub required_dependencies: HashMap<String, String>,
196
197    /// Prohibited dependencies
198    #[serde(default)]
199    pub prohibited_dependencies: Vec<String>,
200
201    /// Required metadata fields
202    #[serde(default)]
203    pub required_metadata: RequiredMetadata,
204
205    /// Required features when applicable
206    #[serde(default)]
207    pub required_features: HashMap<String, FeatureRequirement>,
208}
209
210impl Default for CargoTomlConfig {
211    fn default() -> Self {
212        let mut required_dependencies = HashMap::new();
213        required_dependencies.insert("trueno".to_string(), ">=0.14.0".to_string());
214
215        Self {
216            required_dependencies,
217            prohibited_dependencies: vec!["cargo-tarpaulin".to_string()],
218            required_metadata: RequiredMetadata::default(),
219            required_features: HashMap::new(),
220        }
221    }
222}
223
224/// Required Cargo.toml metadata
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct RequiredMetadata {
227    /// Required license
228    pub license: Option<String>,
229    /// Required edition
230    pub edition: Option<String>,
231    /// Minimum rust-version
232    pub rust_version: Option<String>,
233}
234
235impl Default for RequiredMetadata {
236    fn default() -> Self {
237        Self {
238            license: Some("MIT OR Apache-2.0".to_string()),
239            edition: Some("2024".to_string()),
240            rust_version: Some("1.85".to_string()),
241        }
242    }
243}
244
245/// Feature requirement configuration
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct FeatureRequirement {
248    /// Condition when this feature is required
249    pub required_if: String,
250    /// Dependencies this feature must include
251    #[serde(default)]
252    pub must_include: Vec<String>,
253}
254
255/// CI workflow parity configuration
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct CiWorkflowConfig {
258    /// Required workflow files
259    #[serde(default)]
260    pub required_workflows: Vec<String>,
261
262    /// Required jobs in CI workflow
263    #[serde(default)]
264    pub required_jobs: Vec<String>,
265
266    /// Required matrix dimensions
267    #[serde(default)]
268    pub required_matrix: MatrixConfig,
269
270    /// Required artifacts
271    #[serde(default)]
272    pub required_artifacts: Vec<String>,
273}
274
275impl Default for CiWorkflowConfig {
276    fn default() -> Self {
277        Self {
278            required_workflows: vec!["ci.yml".to_string(), "ci.yaml".to_string()],
279            required_jobs: vec!["fmt-check".to_string(), "clippy".to_string(), "test".to_string()],
280            required_matrix: MatrixConfig::default(),
281            required_artifacts: vec!["coverage-report".to_string()],
282        }
283    }
284}
285
286/// CI matrix configuration
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct MatrixConfig {
289    /// Required OS values
290    #[serde(default)]
291    pub os: Vec<String>,
292    /// Required Rust toolchain values
293    #[serde(default)]
294    pub rust: Vec<String>,
295}
296
297impl Default for MatrixConfig {
298    fn default() -> Self {
299        Self { os: vec!["ubuntu-latest".to_string()], rust: vec!["stable".to_string()] }
300    }
301}
302
303/// Code duplication detection configuration
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct DuplicationConfig {
306    /// Similarity threshold for duplicates (0.0-1.0)
307    #[serde(default = "default_similarity_threshold")]
308    pub similarity_threshold: f64,
309
310    /// Minimum fragment size in lines
311    #[serde(default = "default_min_fragment_size")]
312    pub min_fragment_size: usize,
313
314    /// Number of MinHash permutations
315    #[serde(default = "default_num_perm")]
316    pub num_permutations: usize,
317
318    /// File patterns to include
319    #[serde(default)]
320    pub include_patterns: Vec<String>,
321
322    /// File patterns to exclude
323    #[serde(default)]
324    pub exclude_patterns: Vec<String>,
325
326    /// Whether to only report cross-project duplicates
327    #[serde(default = "default_true")]
328    pub cross_project_only: bool,
329}
330
331fn default_similarity_threshold() -> f64 {
332    0.85
333}
334
335fn default_min_fragment_size() -> usize {
336    50
337}
338
339fn default_num_perm() -> usize {
340    128
341}
342
343impl Default for DuplicationConfig {
344    fn default() -> Self {
345        Self {
346            similarity_threshold: 0.85,
347            min_fragment_size: 50,
348            num_permutations: 128,
349            include_patterns: vec!["**/*.rs".to_string()],
350            exclude_patterns: vec![
351                "**/target/**".to_string(),
352                "**/tests/**".to_string(),
353                "**/benches/**".to_string(),
354            ],
355            cross_project_only: true,
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::io::Write;
364    use tempfile::NamedTempFile;
365
366    #[test]
367    fn test_default_config() {
368        let config = ComplyConfig::default();
369        assert!(!config.makefile.required_targets.is_empty());
370        assert!(config.makefile.required_targets.contains_key("test-fast"));
371        assert!(config.makefile.required_targets.contains_key("lint"));
372    }
373
374    #[test]
375    fn test_config_serialization() {
376        let config = ComplyConfig::default();
377        let yaml = serde_yaml_ng::to_string(&config).expect("yaml serialize failed");
378        assert!(yaml.contains("makefile"));
379        assert!(yaml.contains("cargo_toml"));
380    }
381
382    #[test]
383    fn test_config_load() {
384        let yaml = r#"
385workspace: /tmp/test
386enabled_rules:
387  - makefile-targets
388makefile:
389  prohibited_commands:
390    - cargo tarpaulin
391"#;
392        let mut file = NamedTempFile::new().expect("tempfile creation failed");
393        file.write_all(yaml.as_bytes()).expect("fs write failed");
394
395        let config = ComplyConfig::load(file.path()).expect("unexpected failure");
396        assert_eq!(config.enabled_rules, vec!["makefile-targets"]);
397    }
398
399    #[test]
400    fn test_default_makefile_config() {
401        let config = MakefileConfig::default();
402        assert!(config.required_targets.contains_key("test-fast"));
403        assert!(config.required_targets.contains_key("coverage"));
404        assert!(config.prohibited_commands.contains(&"cargo tarpaulin".to_string()));
405    }
406
407    #[test]
408    fn test_duplication_config_defaults() {
409        let config = DuplicationConfig::default();
410        assert!((config.similarity_threshold - 0.85).abs() < f64::EPSILON);
411        assert_eq!(config.min_fragment_size, 50);
412        assert!(config.cross_project_only);
413    }
414
415    #[test]
416    fn test_cargo_toml_config_defaults() {
417        let config = CargoTomlConfig::default();
418        assert!(config.required_dependencies.contains_key("trueno"));
419        assert!(config.prohibited_dependencies.contains(&"cargo-tarpaulin".to_string()));
420    }
421
422    #[test]
423    fn test_ci_workflow_config_defaults() {
424        let config = CiWorkflowConfig::default();
425        assert!(config.required_workflows.contains(&"ci.yml".to_string()));
426        assert!(config.required_jobs.contains(&"test".to_string()));
427        assert!(config.required_jobs.contains(&"clippy".to_string()));
428    }
429
430    #[test]
431    fn test_required_metadata_defaults() {
432        let metadata = RequiredMetadata::default();
433        assert_eq!(metadata.license, Some("MIT OR Apache-2.0".to_string()));
434        assert_eq!(metadata.edition, Some("2024".to_string()));
435    }
436
437    #[test]
438    fn test_matrix_config_defaults() {
439        let matrix = MatrixConfig::default();
440        assert!(matrix.os.contains(&"ubuntu-latest".to_string()));
441        assert!(matrix.rust.contains(&"stable".to_string()));
442    }
443
444    #[test]
445    fn test_config_workspace_path() {
446        let config = ComplyConfig::default_for_workspace(std::path::Path::new("/test/path"));
447        assert_eq!(config.workspace, Some(std::path::PathBuf::from("/test/path")));
448    }
449
450    #[test]
451    fn test_project_override() {
452        let override_cfg = ProjectOverride {
453            exempt_rules: vec!["code-duplication".to_string()],
454            custom_targets: vec!["custom-build".to_string()],
455            justification: Some("Legacy project".to_string()),
456        };
457        assert!(override_cfg.exempt_rules.contains(&"code-duplication".to_string()));
458        assert!(override_cfg.custom_targets.contains(&"custom-build".to_string()));
459        assert_eq!(override_cfg.justification, Some("Legacy project".to_string()));
460    }
461
462    #[test]
463    fn test_project_override_default() {
464        let override_cfg = ProjectOverride::default();
465        assert!(override_cfg.exempt_rules.is_empty());
466        assert!(override_cfg.custom_targets.is_empty());
467        assert!(override_cfg.justification.is_none());
468    }
469
470    #[test]
471    fn test_load_or_default_file_not_exists() {
472        let tempdir = tempfile::tempdir().expect("tempdir creation failed");
473        let config = ComplyConfig::load_or_default(tempdir.path());
474        assert_eq!(config.workspace, Some(tempdir.path().to_path_buf()));
475    }
476
477    #[test]
478    fn test_load_or_default_file_exists() {
479        let tempdir = tempfile::tempdir().expect("tempdir creation failed");
480        let config_path = tempdir.path().join("stack-comply.yaml");
481        let yaml = r#"
482enabled_rules:
483  - test-rule
484"#;
485        std::fs::write(&config_path, yaml).expect("fs write failed");
486
487        let config = ComplyConfig::load_or_default(tempdir.path());
488        assert_eq!(config.enabled_rules, vec!["test-rule"]);
489    }
490
491    #[test]
492    fn test_load_or_default_invalid_yaml() {
493        let tempdir = tempfile::tempdir().expect("tempdir creation failed");
494        let config_path = tempdir.path().join("stack-comply.yaml");
495        std::fs::write(&config_path, "{{{{invalid yaml").expect("fs write failed");
496
497        let config = ComplyConfig::load_or_default(tempdir.path());
498        // Should fall back to default
499        assert_eq!(config.workspace, Some(tempdir.path().to_path_buf()));
500    }
501
502    #[test]
503    fn test_config_save() {
504        let tempdir = tempfile::tempdir().expect("tempdir creation failed");
505        let save_path = tempdir.path().join("saved-config.yaml");
506
507        let config =
508            ComplyConfig { enabled_rules: vec!["test-rule".to_string()], ..Default::default() };
509        config.save(&save_path).expect("save failed");
510
511        let loaded = ComplyConfig::load(&save_path).expect("unexpected failure");
512        assert_eq!(loaded.enabled_rules, vec!["test-rule"]);
513    }
514
515    #[test]
516    fn test_target_config_fields() {
517        let target = TargetConfig {
518            pattern: Some("cargo test".to_string()),
519            description: "Run tests".to_string(),
520            required: true,
521        };
522        assert_eq!(target.pattern, Some("cargo test".to_string()));
523        assert_eq!(target.description, "Run tests");
524        assert!(target.required);
525    }
526
527    #[test]
528    fn test_target_config_optional_pattern() {
529        let target = TargetConfig {
530            pattern: None,
531            description: "Optional target".to_string(),
532            required: false,
533        };
534        assert!(target.pattern.is_none());
535        assert!(!target.required);
536    }
537
538    #[test]
539    fn test_variation_config_fields() {
540        let variation = VariationConfig {
541            pattern: "cargo test --release".to_string(),
542            reason: "Performance testing".to_string(),
543        };
544        assert_eq!(variation.pattern, "cargo test --release");
545        assert_eq!(variation.reason, "Performance testing");
546    }
547
548    #[test]
549    fn test_feature_requirement_fields() {
550        let req = FeatureRequirement {
551            required_if: "feature_gpu".to_string(),
552            must_include: vec!["wgpu".to_string(), "trueno".to_string()],
553        };
554        assert_eq!(req.required_if, "feature_gpu");
555        assert_eq!(req.must_include.len(), 2);
556        assert!(req.must_include.contains(&"wgpu".to_string()));
557    }
558
559    #[test]
560    fn test_feature_requirement_empty_includes() {
561        let req = FeatureRequirement { required_if: "always".to_string(), must_include: vec![] };
562        assert!(req.must_include.is_empty());
563    }
564
565    #[test]
566    fn test_required_metadata_fields() {
567        let metadata = RequiredMetadata {
568            license: Some("MIT".to_string()),
569            edition: Some("2021".to_string()),
570            rust_version: Some("1.80".to_string()),
571        };
572        assert_eq!(metadata.license, Some("MIT".to_string()));
573        assert_eq!(metadata.edition, Some("2021".to_string()));
574        assert_eq!(metadata.rust_version, Some("1.80".to_string()));
575    }
576
577    #[test]
578    fn test_required_metadata_none_fields() {
579        let metadata = RequiredMetadata { license: None, edition: None, rust_version: None };
580        assert!(metadata.license.is_none());
581        assert!(metadata.edition.is_none());
582        assert!(metadata.rust_version.is_none());
583    }
584
585    #[test]
586    fn test_duplication_config_serialization_roundtrip() {
587        let config = DuplicationConfig::default();
588        let yaml = serde_yaml_ng::to_string(&config).expect("yaml serialize failed");
589        let parsed: DuplicationConfig =
590            serde_yaml_ng::from_str(&yaml).expect("yaml deserialize failed");
591
592        assert!((parsed.similarity_threshold - config.similarity_threshold).abs() < f64::EPSILON);
593        assert_eq!(parsed.min_fragment_size, config.min_fragment_size);
594        assert_eq!(parsed.num_permutations, config.num_permutations);
595        assert_eq!(parsed.cross_project_only, config.cross_project_only);
596    }
597
598    #[test]
599    fn test_comply_config_disabled_rules() {
600        let config = ComplyConfig {
601            disabled_rules: vec!["rule1".to_string(), "rule2".to_string()],
602            ..Default::default()
603        };
604        assert_eq!(config.disabled_rules.len(), 2);
605    }
606
607    #[test]
608    fn test_comply_config_include_external() {
609        let config = ComplyConfig { include_external: true, ..Default::default() };
610        assert!(config.include_external);
611    }
612
613    #[test]
614    fn test_comply_config_project_overrides() {
615        let mut config = ComplyConfig::default();
616        config.project_overrides.insert(
617            "test-project".to_string(),
618            ProjectOverride {
619                exempt_rules: vec!["rule1".to_string()],
620                custom_targets: vec![],
621                justification: None,
622            },
623        );
624        assert!(config.project_overrides.contains_key("test-project"));
625    }
626
627    #[test]
628    fn test_makefile_config_allowed_variations() {
629        let mut config = MakefileConfig::default();
630        config.allowed_variations.insert(
631            "test".to_string(),
632            vec![VariationConfig {
633                pattern: "cargo test --release".to_string(),
634                reason: "Performance".to_string(),
635            }],
636        );
637        assert!(config.allowed_variations.contains_key("test"));
638    }
639
640    #[test]
641    fn test_ci_workflow_config_required_artifacts() {
642        let config = CiWorkflowConfig::default();
643        assert!(config.required_artifacts.contains(&"coverage-report".to_string()));
644    }
645
646    #[test]
647    fn test_matrix_config_empty() {
648        let matrix = MatrixConfig { os: vec![], rust: vec![] };
649        assert!(matrix.os.is_empty());
650        assert!(matrix.rust.is_empty());
651    }
652
653    #[test]
654    fn test_default_true_in_target_config_deserialization() {
655        let yaml = r#"
656pattern: "cargo test"
657description: "Test"
658"#;
659        let target: TargetConfig = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
660        // required should default to true
661        assert!(target.required);
662    }
663
664    #[test]
665    fn test_duplication_config_custom_values() {
666        let config = DuplicationConfig {
667            similarity_threshold: 0.90,
668            min_fragment_size: 100,
669            num_permutations: 256,
670            include_patterns: vec!["**/*.py".to_string()],
671            exclude_patterns: vec![],
672            cross_project_only: false,
673        };
674        assert!((config.similarity_threshold - 0.90).abs() < f64::EPSILON);
675        assert_eq!(config.min_fragment_size, 100);
676        assert_eq!(config.num_permutations, 256);
677        assert!(!config.cross_project_only);
678    }
679}