use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HooksConfig {
#[serde(rename = "before-run")]
pub before_run: Option<String>,
#[serde(rename = "after-run")]
pub after_run: Option<String>,
#[serde(rename = "after-write")]
pub after_write: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigProfile {
pub dry_run_default: Option<bool>,
pub review: Option<bool>,
pub allow_risky_transforms: Option<bool>,
pub max_files: Option<usize>,
pub max_duration_seconds: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MorphCliSchema {
#[serde(default)]
pub enabled_recipes: Vec<String>,
#[serde(default)]
pub excluded_paths: Vec<String>,
#[serde(default = "default_max_file_size_kb")]
pub max_file_size_kb: usize,
#[serde(default = "default_true")]
pub dry_run_default: bool,
#[serde(default)]
pub backup_enabled: bool,
#[serde(default = "default_preview_lines")]
pub preview_lines: usize,
#[serde(default)]
pub allow_risky_transforms: bool,
#[serde(default)]
pub disabled_recipes: Vec<String>,
#[serde(default)]
pub allow_risky_recipes: Vec<String>,
#[serde(default)]
pub custom_globs: Vec<String>,
#[serde(default = "default_max_files")]
pub max_files: usize,
pub max_duration_seconds: u64,
#[serde(default)]
pub profiles: std::collections::HashMap<String, ConfigProfile>,
#[serde(default)]
pub hooks: HooksConfig,
}
impl Default for MorphCliSchema {
fn default() -> Self {
Self {
enabled_recipes: Vec::new(),
excluded_paths: Vec::new(),
max_file_size_kb: default_max_file_size_kb(),
dry_run_default: default_true(),
backup_enabled: false,
preview_lines: default_preview_lines(),
allow_risky_transforms: false,
disabled_recipes: Vec::new(),
allow_risky_recipes: Vec::new(),
custom_globs: Vec::new(),
max_files: default_max_files(),
max_duration_seconds: default_max_duration(),
profiles: std::collections::HashMap::new(),
hooks: HooksConfig::default(),
}
}
}
fn default_max_file_size_kb() -> usize {
500
}
fn default_true() -> bool {
true
}
fn default_preview_lines() -> usize {
100
}
fn default_max_files() -> usize {
10000
}
fn default_max_duration() -> u64 {
300
}
impl MorphCliSchema {
#[allow(dead_code)]
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
if self.max_file_size_kb == 0 {
errors.push("max_file_size_kb must be greater than 0".to_string());
}
if self.preview_lines == 0 {
errors.push(
"preview_lines must be greater than 0 (or use a very large number for unlimited)"
.to_string(),
);
}
for path in &self.excluded_paths {
if path.is_empty() {
errors.push("excluded_paths contains empty string".to_string());
}
if path.contains('\\') {
errors.push(format!(
"excluded_paths contains invalid backslash in: {}",
path
));
}
}
errors
}
#[allow(dead_code)]
pub fn is_excluded(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
for excluded in &self.excluded_paths {
if path_str.contains(excluded) {
return true;
}
}
self.is_default_excluded(path)
}
#[allow(dead_code)]
fn is_default_excluded(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
let default_excludes = [
"node_modules",
".git",
"dist",
"build",
"target",
".next",
".nuxt",
"__pycache__",
".venv",
"venv",
];
for exclude in &default_excludes {
if path_str.contains(exclude) {
return true;
}
}
false
}
#[allow(dead_code)]
pub fn should_skip_file(&self, path: &Path, content: &str) -> SkipDecision {
let metadata = match fs::metadata(path) {
Ok(m) => m,
Err(_) => return SkipDecision::Error("cannot read file metadata".to_string()),
};
if metadata.len() == 0 {
return SkipDecision::Skip("empty file".to_string());
}
let size_kb = metadata.len() / 1024;
if size_kb > self.max_file_size_kb as u64 {
return SkipDecision::Skip(format!(
"file size ({} KB) exceeds limit ({} KB)",
size_kb, self.max_file_size_kb
));
}
if self.looks_minified(content) {
return SkipDecision::Skip("minified file detected".to_string());
}
if self.looks_generated(content) {
return SkipDecision::Skip("generated file detected".to_string());
}
SkipDecision::Process
}
#[allow(dead_code)]
fn looks_minified(&self, content: &str) -> bool {
if content.len() < 1000 {
return false;
}
let mut long_lines = 0;
let mut total_lines = 0;
for line in content.lines() {
total_lines += 1;
if line.len() > 500 {
long_lines += 1;
}
}
if total_lines == 0 {
return false;
}
let ratio = long_lines as f64 / total_lines as f64;
ratio > 0.3
}
#[allow(dead_code)]
fn looks_generated(&self, content: &str) -> bool {
let markers = [
"// DO NOT EDIT",
"// This file was generated",
"@generated",
"/* Generated by ",
"Generated by ",
"Auto-generated by ",
];
for marker in &markers {
if content.contains(marker) {
return true;
}
}
false
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum SkipDecision {
Process,
Skip(String),
Error(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_schema() {
let schema = MorphCliSchema::default();
assert_eq!(schema.max_file_size_kb, 500);
assert_eq!(schema.preview_lines, 100);
assert!(schema.dry_run_default);
}
#[test]
fn test_validate_empty_errors() {
let schema = MorphCliSchema::default();
let errors = schema.validate();
assert!(errors.is_empty());
}
#[test]
fn test_is_excluded() {
let schema = MorphCliSchema::default();
assert!(schema.is_excluded(Path::new("node_modules/foo.js")));
assert!(schema.is_excluded(Path::new("dist/index.js")));
assert!(!schema.is_excluded(Path::new("src/index.js")));
}
#[test]
fn test_custom_exclusions() {
let mut schema = MorphCliSchema::default();
schema.excluded_paths = vec!["custom_dir".to_string()];
assert!(schema.is_excluded(Path::new("custom_dir/file.js")));
}
}