Skip to main content

morph_cli/core/config/
schema.rs

1use std::fs;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
7pub struct HooksConfig {
8    #[serde(rename = "before-run")]
9    pub before_run: Option<String>,
10    #[serde(rename = "after-run")]
11    pub after_run: Option<String>,
12    #[serde(rename = "after-write")]
13    pub after_write: Option<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ConfigProfile {
18    pub dry_run_default: Option<bool>,
19    pub review: Option<bool>,
20    pub allow_risky_transforms: Option<bool>,
21    pub max_files: Option<usize>,
22    pub max_duration_seconds: Option<u64>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct MorphCliSchema {
27    #[serde(default)]
28    pub enabled_recipes: Vec<String>,
29
30    #[serde(default)]
31    pub excluded_paths: Vec<String>,
32
33    #[serde(default = "default_max_file_size_kb")]
34    pub max_file_size_kb: usize,
35
36    #[serde(default = "default_true")]
37    pub dry_run_default: bool,
38
39    #[serde(default)]
40    pub backup_enabled: bool,
41
42    #[serde(default = "default_preview_lines")]
43    pub preview_lines: usize,
44
45    #[serde(default)]
46    pub allow_risky_transforms: bool,
47
48    #[serde(default)]
49    pub disabled_recipes: Vec<String>,
50
51    #[serde(default)]
52    pub allow_risky_recipes: Vec<String>,
53
54    #[serde(default)]
55    pub custom_globs: Vec<String>,
56
57    #[serde(default = "default_max_files")]
58    pub max_files: usize,
59
60    pub max_duration_seconds: u64,
61
62    #[serde(default)]
63    pub profiles: std::collections::HashMap<String, ConfigProfile>,
64
65    #[serde(default)]
66    pub hooks: HooksConfig,
67}
68
69impl Default for MorphCliSchema {
70    fn default() -> Self {
71        Self {
72            enabled_recipes: Vec::new(),
73            excluded_paths: Vec::new(),
74            max_file_size_kb: default_max_file_size_kb(),
75            dry_run_default: default_true(),
76            backup_enabled: false,
77            preview_lines: default_preview_lines(),
78            allow_risky_transforms: false,
79            disabled_recipes: Vec::new(),
80            allow_risky_recipes: Vec::new(),
81            custom_globs: Vec::new(),
82            max_files: default_max_files(),
83            max_duration_seconds: default_max_duration(),
84            profiles: std::collections::HashMap::new(),
85            hooks: HooksConfig::default(),
86        }
87    }
88}
89
90fn default_max_file_size_kb() -> usize {
91    500
92}
93
94fn default_true() -> bool {
95    true
96}
97
98fn default_preview_lines() -> usize {
99    100
100}
101
102fn default_max_files() -> usize {
103    10000
104}
105
106fn default_max_duration() -> u64 {
107    300
108}
109
110impl MorphCliSchema {
111    #[allow(dead_code)]
112    pub fn validate(&self) -> Vec<String> {
113        let mut errors = Vec::new();
114
115        if self.max_file_size_kb == 0 {
116            errors.push("max_file_size_kb must be greater than 0".to_string());
117        }
118
119        if self.preview_lines == 0 {
120            errors.push(
121                "preview_lines must be greater than 0 (or use a very large number for unlimited)"
122                    .to_string(),
123            );
124        }
125
126        for path in &self.excluded_paths {
127            if path.is_empty() {
128                errors.push("excluded_paths contains empty string".to_string());
129            }
130            if path.contains('\\') {
131                errors.push(format!(
132                    "excluded_paths contains invalid backslash in: {}",
133                    path
134                ));
135            }
136        }
137
138        errors
139    }
140
141    #[allow(dead_code)]
142    pub fn is_excluded(&self, path: &Path) -> bool {
143        let path_str = path.to_string_lossy();
144
145        for excluded in &self.excluded_paths {
146            if path_str.contains(excluded) {
147                return true;
148            }
149        }
150
151        self.is_default_excluded(path)
152    }
153
154    #[allow(dead_code)]
155    fn is_default_excluded(&self, path: &Path) -> bool {
156        let path_str = path.to_string_lossy();
157
158        let default_excludes = [
159            "node_modules",
160            ".git",
161            "dist",
162            "build",
163            "target",
164            ".next",
165            ".nuxt",
166            "__pycache__",
167            ".venv",
168            "venv",
169        ];
170
171        for exclude in &default_excludes {
172            if path_str.contains(exclude) {
173                return true;
174            }
175        }
176
177        false
178    }
179
180    #[allow(dead_code)]
181    pub fn should_skip_file(&self, path: &Path, content: &str) -> SkipDecision {
182        let metadata = match fs::metadata(path) {
183            Ok(m) => m,
184            Err(_) => return SkipDecision::Error("cannot read file metadata".to_string()),
185        };
186
187        if metadata.len() == 0 {
188            return SkipDecision::Skip("empty file".to_string());
189        }
190
191        let size_kb = metadata.len() / 1024;
192        if size_kb > self.max_file_size_kb as u64 {
193            return SkipDecision::Skip(format!(
194                "file size ({} KB) exceeds limit ({} KB)",
195                size_kb, self.max_file_size_kb
196            ));
197        }
198
199        if self.looks_minified(content) {
200            return SkipDecision::Skip("minified file detected".to_string());
201        }
202
203        if self.looks_generated(content) {
204            return SkipDecision::Skip("generated file detected".to_string());
205        }
206
207        SkipDecision::Process
208    }
209
210    #[allow(dead_code)]
211    fn looks_minified(&self, content: &str) -> bool {
212        if content.len() < 1000 {
213            return false;
214        }
215
216        let mut long_lines = 0;
217        let mut total_lines = 0;
218
219        for line in content.lines() {
220            total_lines += 1;
221            if line.len() > 500 {
222                long_lines += 1;
223            }
224        }
225
226        if total_lines == 0 {
227            return false;
228        }
229
230        let ratio = long_lines as f64 / total_lines as f64;
231        ratio > 0.3
232    }
233
234    #[allow(dead_code)]
235    fn looks_generated(&self, content: &str) -> bool {
236        let markers = [
237            "// DO NOT EDIT",
238            "// This file was generated",
239            "@generated",
240            "/* Generated by ",
241            "Generated by ",
242            "Auto-generated by ",
243        ];
244
245        for marker in &markers {
246            if content.contains(marker) {
247                return true;
248            }
249        }
250
251        false
252    }
253}
254
255#[derive(Debug, Clone)]
256#[allow(dead_code)]
257pub enum SkipDecision {
258    Process,
259    Skip(String),
260    Error(String),
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_default_schema() {
269        let schema = MorphCliSchema::default();
270        assert_eq!(schema.max_file_size_kb, 500);
271        assert_eq!(schema.preview_lines, 100);
272        assert!(schema.dry_run_default);
273    }
274
275    #[test]
276    fn test_validate_empty_errors() {
277        let schema = MorphCliSchema::default();
278        let errors = schema.validate();
279        assert!(errors.is_empty());
280    }
281
282    #[test]
283    fn test_is_excluded() {
284        let schema = MorphCliSchema::default();
285        assert!(schema.is_excluded(Path::new("node_modules/foo.js")));
286        assert!(schema.is_excluded(Path::new("dist/index.js")));
287        assert!(!schema.is_excluded(Path::new("src/index.js")));
288    }
289
290    #[test]
291    fn test_custom_exclusions() {
292        let mut schema = MorphCliSchema::default();
293        schema.excluded_paths = vec!["custom_dir".to_string()];
294
295        assert!(schema.is_excluded(Path::new("custom_dir/file.js")));
296    }
297}