morph_cli/core/config/
schema.rs1use 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}