Skip to main content

kardo_core/
fix.rs

1//! Auto-fix engine — scaffolds missing files from templates.
2//!
3//! Only performs safe operations: creating files that don't exist.
4//! Never overwrites existing files.
5
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::scoring::QualityIssue;
11
12/// Template for CLAUDE.md scaffold.
13const CLAUDE_MD_TEMPLATE: &str = include_str!("templates/CLAUDE.md.template");
14
15/// Template for README.md scaffold.
16const README_MD_TEMPLATE: &str = include_str!("templates/README.md.template");
17
18/// Result of a single fix attempt.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub enum FixResult {
21    Created(PathBuf),
22    Skipped(String),
23}
24
25/// Auto-fixer for scaffoldable issues.
26pub struct AutoFixer;
27
28impl AutoFixer {
29    /// Apply fixes for scaffold-type issues.
30    /// If `dry_run` is true, reports what would happen without writing files.
31    pub fn fix(root: &Path, issues: &[QualityIssue], dry_run: bool) -> Vec<FixResult> {
32        let mut results = Vec::new();
33
34        for issue in issues {
35            if issue.fix_type != crate::scoring::FixType::Scaffold {
36                continue;
37            }
38
39            match issue.id.as_str() {
40                "config-missing-claude-md" => {
41                    let path = root.join("CLAUDE.md");
42                    if path.exists() {
43                        results.push(FixResult::Skipped(
44                            "CLAUDE.md already exists".to_string(),
45                        ));
46                    } else if dry_run {
47                        results.push(FixResult::Created(path));
48                    } else {
49                        let project_name = root
50                            .file_name()
51                            .unwrap_or_default()
52                            .to_string_lossy()
53                            .to_string();
54                        let content = CLAUDE_MD_TEMPLATE.replace("{{PROJECT_NAME}}", &project_name);
55                        match std::fs::write(&path, content) {
56                            Ok(()) => results.push(FixResult::Created(path)),
57                            Err(e) => results.push(FixResult::Skipped(format!(
58                                "Failed to create CLAUDE.md: {e}",
59                            ))),
60                        }
61                    }
62                }
63                "config-missing-readme" => {
64                    let path = root.join("README.md");
65                    if path.exists() {
66                        results.push(FixResult::Skipped(
67                            "README.md already exists".to_string(),
68                        ));
69                    } else if dry_run {
70                        results.push(FixResult::Created(path));
71                    } else {
72                        let project_name = root
73                            .file_name()
74                            .unwrap_or_default()
75                            .to_string_lossy()
76                            .to_string();
77                        let content = README_MD_TEMPLATE.replace("{{PROJECT_NAME}}", &project_name);
78                        match std::fs::write(&path, content) {
79                            Ok(()) => results.push(FixResult::Created(path)),
80                            Err(e) => results.push(FixResult::Skipped(format!(
81                                "Failed to create README.md: {e}",
82                            ))),
83                        }
84                    }
85                }
86                _ => {}
87            }
88        }
89
90        results
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::scoring::{FixType, IssueCategory, IssueSeverity, QualityIssue};
98    use tempfile::TempDir;
99
100    fn scaffold_issue(id: &str) -> QualityIssue {
101        let mut issue = QualityIssue::new(
102            id.to_string(),
103            None,
104            IssueCategory::Configuration,
105            IssueSeverity::Blocking,
106            format!("Missing {}", id),
107            "test".to_string(),
108            None,
109        );
110        issue.fix_type = FixType::Scaffold;
111        issue
112    }
113
114    #[test]
115    fn test_scaffold_claude_md() {
116        let dir = TempDir::new().unwrap();
117        let issues = vec![scaffold_issue("config-missing-claude-md")];
118
119        let results = AutoFixer::fix(dir.path(), &issues, false);
120        assert_eq!(results.len(), 1);
121        assert!(matches!(&results[0], FixResult::Created(p) if p.ends_with("CLAUDE.md")));
122        assert!(dir.path().join("CLAUDE.md").exists());
123
124        let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
125        assert!(content.contains("# "));
126    }
127
128    #[test]
129    fn test_scaffold_readme() {
130        let dir = TempDir::new().unwrap();
131        let issues = vec![scaffold_issue("config-missing-readme")];
132
133        let results = AutoFixer::fix(dir.path(), &issues, false);
134        assert_eq!(results.len(), 1);
135        assert!(matches!(&results[0], FixResult::Created(p) if p.ends_with("README.md")));
136        assert!(dir.path().join("README.md").exists());
137    }
138
139    #[test]
140    fn test_dry_run() {
141        let dir = TempDir::new().unwrap();
142        let issues = vec![scaffold_issue("config-missing-claude-md")];
143
144        let results = AutoFixer::fix(dir.path(), &issues, true);
145        assert_eq!(results.len(), 1);
146        assert!(matches!(&results[0], FixResult::Created(_)));
147        // File should NOT exist in dry-run
148        assert!(!dir.path().join("CLAUDE.md").exists());
149    }
150
151    #[test]
152    fn test_skip_existing() {
153        let dir = TempDir::new().unwrap();
154        std::fs::write(dir.path().join("CLAUDE.md"), "existing").unwrap();
155        let issues = vec![scaffold_issue("config-missing-claude-md")];
156
157        let results = AutoFixer::fix(dir.path(), &issues, false);
158        assert_eq!(results.len(), 1);
159        assert!(matches!(&results[0], FixResult::Skipped(_)));
160
161        // Content should be unchanged
162        let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
163        assert_eq!(content, "existing");
164    }
165}