1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct ComplyConfig {
12 #[serde(default)]
14 pub workspace: Option<PathBuf>,
15
16 #[serde(default)]
18 pub enabled_rules: Vec<String>,
19
20 #[serde(default)]
22 pub disabled_rules: Vec<String>,
23
24 #[serde(default)]
26 pub include_external: bool,
27
28 #[serde(default)]
30 pub project_overrides: HashMap<String, ProjectOverride>,
31
32 #[serde(default)]
34 pub makefile: MakefileConfig,
35
36 #[serde(default)]
38 pub cargo_toml: CargoTomlConfig,
39
40 #[serde(default)]
42 pub ci_workflows: CiWorkflowConfig,
43
44 #[serde(default)]
46 pub duplication: DuplicationConfig,
47}
48
49impl ComplyConfig {
50 pub fn default_for_workspace(workspace: &Path) -> Self {
52 Self { workspace: Some(workspace.to_path_buf()), ..Default::default() }
53 }
54
55 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 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82pub struct ProjectOverride {
83 #[serde(default)]
85 pub exempt_rules: Vec<String>,
86
87 #[serde(default)]
89 pub custom_targets: Vec<String>,
90
91 pub justification: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(default)]
98pub struct MakefileConfig {
99 pub required_targets: HashMap<String, TargetConfig>,
101
102 pub allowed_variations: HashMap<String, Vec<VariationConfig>>,
104
105 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#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TargetConfig {
168 pub pattern: Option<String>,
170 pub description: String,
172 #[serde(default = "default_true")]
174 pub required: bool,
175}
176
177fn default_true() -> bool {
178 true
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct VariationConfig {
184 pub pattern: String,
186 pub reason: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct CargoTomlConfig {
193 #[serde(default)]
195 pub required_dependencies: HashMap<String, String>,
196
197 #[serde(default)]
199 pub prohibited_dependencies: Vec<String>,
200
201 #[serde(default)]
203 pub required_metadata: RequiredMetadata,
204
205 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct RequiredMetadata {
227 pub license: Option<String>,
229 pub edition: Option<String>,
231 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#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct FeatureRequirement {
248 pub required_if: String,
250 #[serde(default)]
252 pub must_include: Vec<String>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct CiWorkflowConfig {
258 #[serde(default)]
260 pub required_workflows: Vec<String>,
261
262 #[serde(default)]
264 pub required_jobs: Vec<String>,
265
266 #[serde(default)]
268 pub required_matrix: MatrixConfig,
269
270 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct MatrixConfig {
289 #[serde(default)]
291 pub os: Vec<String>,
292 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct DuplicationConfig {
306 #[serde(default = "default_similarity_threshold")]
308 pub similarity_threshold: f64,
309
310 #[serde(default = "default_min_fragment_size")]
312 pub min_fragment_size: usize,
313
314 #[serde(default = "default_num_perm")]
316 pub num_permutations: usize,
317
318 #[serde(default)]
320 pub include_patterns: Vec<String>,
321
322 #[serde(default)]
324 pub exclude_patterns: Vec<String>,
325
326 #[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 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 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}