diff --git a/Cargo.toml b/Cargo.toml index 3a5902b..09bd987 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ name = "diecut" version = "0.3.4" edition = "2021" license = "MIT" -rust-version = "1.75" +rust-version = "1.80" description = "A single binary project template generator" [lib] diff --git a/src/cli.rs b/src/cli.rs index ab84986..fde16cb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,4 +49,38 @@ pub enum Commands { /// List cached templates List, + + /// Extract a template from an existing project + Extract { + /// Source project directory + source: String, + + /// Variable values to templatize (can be repeated: --var key=value) + #[arg(long = "var", value_name = "KEY=VALUE")] + vars: Vec, + + /// Output directory for the extracted template + #[arg(short, long)] + output: Option, + + /// Convert the source directory in-place + #[arg(long)] + in_place: bool, + + /// Accept all defaults without prompting + #[arg(short = 'y', long)] + yes: bool, + + /// Minimum confidence threshold for auto-detected variables (0.0-1.0) + #[arg(long, default_value = "0.5")] + min_confidence: f64, + + /// Max path depth for stubbing content files (deeper files are dropped) + #[arg(long, default_value = "2")] + stub_depth: usize, + + /// Show what would be extracted without writing files + #[arg(long)] + dry_run: bool, + }, } diff --git a/src/commands/extract.rs b/src/commands/extract.rs new file mode 100644 index 0000000..6251044 --- /dev/null +++ b/src/commands/extract.rs @@ -0,0 +1,119 @@ +use std::path::PathBuf; + +use console::style; + +use diecut::error::DicecutError; +use diecut::extract::{execute_extraction, plan_extraction, ExtractOptions}; +use miette::Result; + +#[allow(clippy::too_many_arguments)] +pub fn run( + source: String, + vars: Vec, + output: Option, + in_place: bool, + yes: bool, + min_confidence: f64, + stub_depth: usize, + dry_run: bool, +) -> Result<()> { + let variables = parse_vars(&vars)?; + + let options = ExtractOptions { + source_dir: PathBuf::from(&source), + variables, + output_dir: output.map(PathBuf::from), + in_place, + yes, + min_confidence, + stub_depth, + dry_run, + }; + + let plan = plan_extraction(&options)?; + + if dry_run { + print_dry_run(&plan); + return Ok(()); + } + + execute_extraction(&plan, in_place)?; + + Ok(()) +} + +fn parse_vars(vars: &[String]) -> diecut::error::Result> { + let mut parsed = Vec::new(); + + for var in vars { + let (key, value) = var + .split_once('=') + .ok_or_else(|| DicecutError::ExtractInvalidVar { input: var.clone() })?; + parsed.push((key.trim().to_string(), value.trim().to_string())); + } + + Ok(parsed) +} + +fn print_dry_run(plan: &diecut::extract::ExtractionPlan) { + eprintln!( + "\n{} Dry run — no files will be written\n", + style("⚡").yellow().bold() + ); + + eprintln!( + "Output directory: {}", + style(plan.output_dir.display()).cyan() + ); + + let templated: Vec<_> = plan.files.iter().filter(|f| f.has_replacements()).collect(); + let boilerplate: Vec<_> = plan + .files + .iter() + .filter(|f| !f.has_replacements() && !f.stubbed) + .collect(); + let stubbed: Vec<_> = plan.files.iter().filter(|f| f.stubbed).collect(); + + eprintln!("\nTemplated files ({}):", templated.len()); + for file in &templated { + eprintln!( + " {} ({} replacements)", + file.template_path.display(), + file.replacement_count() + ); + } + + eprintln!("\nBoilerplate ({}):", boilerplate.len()); + for file in &boilerplate { + eprintln!(" {}", file.template_path.display()); + } + + if !stubbed.is_empty() { + eprintln!("\nStubbed ({}):", stubbed.len()); + for file in &stubbed { + eprintln!(" {}", file.template_path.display()); + } + } + + if plan.dropped_count > 0 { + eprintln!("\nDropped ({}):", plan.dropped_count); + for path in &plan.dropped_paths { + eprintln!(" {}", path.display()); + } + } + + eprintln!("\nVariables:"); + for var in &plan.variables { + eprintln!(" {} = {:?}", var.name, var.value); + for variant in &var.variants { + if variant.name != "verbatim" { + eprintln!(" {} → {}", variant.name, variant.literal); + } + } + } + + eprintln!("\nGenerated diecut.toml:"); + eprintln!("{}", style("─".repeat(60)).dim()); + eprint!("{}", plan.config_toml); + eprintln!("{}", style("─".repeat(60)).dim()); +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 33661b9..8c884a4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,2 +1,3 @@ +pub mod extract; pub mod list; pub mod new; diff --git a/src/error.rs b/src/error.rs index 834b7d2..3ad5597 100644 --- a/src/error.rs +++ b/src/error.rs @@ -117,6 +117,30 @@ pub enum DicecutError { #[source] source: toml::de::Error, }, + + #[error("Source directory not found: {path}")] + #[diagnostic(help("Provide the path to an existing project directory"))] + ExtractSourceNotFound { path: PathBuf }, + + #[error("No variables provided for extraction")] + #[diagnostic(help( + "Use --var key=value to specify variables, or ensure the project has identifiable names in config files or directory name" + ))] + ExtractNoVariables, + + #[error("Invalid --var argument: {input} (expected key=value)")] + #[diagnostic(help("Use --var key=value format, e.g., --var project_name=my-app"))] + ExtractInvalidVar { input: String }, + + #[error("Output directory already exists: {path}")] + #[diagnostic(help( + "Choose a different output path with -o, or remove the existing directory" + ))] + ExtractOutputExists { path: PathBuf }, + + #[error("Directory already contains a diecut.toml: {path}")] + #[diagnostic(help("This directory is already a diecut template"))] + ExtractAlreadyTemplate { path: PathBuf }, } pub type Result = std::result::Result; diff --git a/src/extract/auto_detect.rs b/src/extract/auto_detect.rs new file mode 100644 index 0000000..193bbac --- /dev/null +++ b/src/extract/auto_detect.rs @@ -0,0 +1,1140 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::process::Command; +use std::sync::LazyLock; + +use regex_lite::Regex; + +static GO_MOD_RE: LazyLock = LazyLock::new(|| Regex::new(r"^module\s+(\S+)").unwrap()); + +static TOKEN_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"[a-zA-Z][a-zA-Z0-9]*(?:[-_.][a-zA-Z0-9]+)+|[A-Z][a-z]+(?:[A-Z][a-z]+)+|[a-z]+(?:[A-Z][a-z]+)+|[A-Z]{2,}(?:_[A-Z]{2,})+", + ) + .unwrap() +}); + +use super::scan::ScanResult; +use super::variants::split_into_words; + +/// Confidence tier indicating how a candidate variable was detected. +#[derive(Debug, Clone, PartialEq)] +pub enum ConfidenceTier { + DirectoryName, + ConfigFile, + GitMetadata, + FrequencyAnalysis, +} + +impl std::fmt::Display for ConfidenceTier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfidenceTier::DirectoryName => write!(f, "directory name"), + ConfidenceTier::ConfigFile => write!(f, "config file"), + ConfidenceTier::GitMetadata => write!(f, "git metadata"), + ConfidenceTier::FrequencyAnalysis => write!(f, "frequency analysis"), + } + } +} + +/// A candidate variable detected by auto-detection. +#[derive(Debug, Clone)] +pub struct DetectedCandidate { + pub suggested_name: String, + pub value: String, + pub tier: ConfidenceTier, + pub confidence: f64, + pub reason: String, + pub file_count: usize, + pub total_occurrences: usize, +} + +/// Result of running auto-detection. +#[derive(Debug)] +pub struct AutoDetectResult { + pub candidates: Vec, +} + +// ── Entry point ────────────────────────────────────────────────────────── + +/// Run all 4 auto-detection tiers against a scanned project. +pub fn auto_detect(project_dir: &Path, scan_result: &ScanResult) -> AutoDetectResult { + let mut candidates = Vec::new(); + + // Tier 1: Directory name + candidates.extend(detect_directory_name(project_dir, scan_result)); + + // Tier 2: Ecosystem config files + candidates.extend(detect_config_files(project_dir, scan_result)); + + // Tier 3: Git metadata + candidates.extend(detect_git_metadata(project_dir, scan_result)); + + // Collect values already covered by tiers 1-3 + let covered_values: HashSet = + candidates.iter().map(|c| c.value.to_lowercase()).collect(); + + // Tier 4: Frequency analysis + candidates.extend(detect_frequency(scan_result, &covered_values)); + + // Deduplicate by normalized word list, keeping highest confidence + deduplicate_candidates(&mut candidates); + + // Sort by confidence descending + candidates.sort_by(|a, b| b.confidence.total_cmp(&a.confidence)); + + AutoDetectResult { candidates } +} + +// ── Tier 1: Directory name ─────────────────────────────────────────────── + +const GENERIC_DIR_NAMES: &[&str] = &[ + "src", + "app", + "project", + "tmp", + "temp", + "build", + "dist", + "out", + "output", + "lib", + "bin", + "test", + "tests", + "example", + "examples", + "docs", + "doc", + "assets", + "public", + "static", + "vendor", + "node_modules", + "target", + "pkg", + "cmd", + "internal", + "api", + "web", + "server", + "client", + "frontend", + "backend", + "service", + "services", + "workspace", + "repo", + "code", +]; + +fn detect_directory_name(project_dir: &Path, scan_result: &ScanResult) -> Vec { + let dir_name = match project_dir.file_name() { + Some(name) => name.to_string_lossy().to_string(), + None => return vec![], + }; + + if GENERIC_DIR_NAMES.contains(&dir_name.to_lowercase().as_str()) { + return vec![]; + } + + // Must have at least 2 chars + if dir_name.len() < 2 { + return vec![]; + } + + let (file_count, total_occurrences) = count_occurrences(&dir_name, scan_result); + + vec![DetectedCandidate { + suggested_name: "project_name".to_string(), + value: dir_name.clone(), + tier: ConfidenceTier::DirectoryName, + confidence: 0.95, + reason: format!("directory name \"{}\"", dir_name), + file_count, + total_occurrences, + }] +} + +// ── Tier 2: Ecosystem config files ─────────────────────────────────────── + +fn detect_config_files(project_dir: &Path, scan_result: &ScanResult) -> Vec { + let mut candidates = Vec::new(); + + if let Some(mut c) = parse_cargo_toml(project_dir, scan_result) { + candidates.append(&mut c); + } + if let Some(mut c) = parse_package_json(project_dir, scan_result) { + candidates.append(&mut c); + } + if let Some(mut c) = parse_pyproject_toml(project_dir, scan_result) { + candidates.append(&mut c); + } + if let Some(mut c) = parse_go_mod(project_dir, scan_result) { + candidates.append(&mut c); + } + + candidates +} + +fn push_config_candidate( + candidates: &mut Vec, + value: &str, + suggested_name: &str, + confidence: f64, + reason: &str, + scan_result: &ScanResult, +) { + let (file_count, total_occurrences) = count_occurrences(value, scan_result); + candidates.push(DetectedCandidate { + suggested_name: suggested_name.to_string(), + value: value.to_string(), + tier: ConfidenceTier::ConfigFile, + confidence, + reason: reason.to_string(), + file_count, + total_occurrences, + }); +} + +fn parse_cargo_toml( + project_dir: &Path, + scan_result: &ScanResult, +) -> Option> { + let path = project_dir.join("Cargo.toml"); + let content = std::fs::read_to_string(&path).ok()?; + let parsed: toml::Value = content.parse().ok()?; + + let mut candidates = Vec::new(); + + if let Some(name) = parsed + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + { + push_config_candidate( + &mut candidates, + name, + "project_name", + 0.90, + "Cargo.toml [package].name", + scan_result, + ); + } + + if let Some(version) = parsed + .get("package") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + { + if !version.is_empty() { + push_config_candidate( + &mut candidates, + version, + "version", + 0.85, + "Cargo.toml [package].version", + scan_result, + ); + } + } + + if let Some(authors) = parsed + .get("package") + .and_then(|p| p.get("authors")) + .and_then(|a| a.as_array()) + { + if let Some(first) = authors.first().and_then(|a| a.as_str()) { + let author = strip_email(first); + if !author.is_empty() { + push_config_candidate( + &mut candidates, + &author, + "author", + 0.85, + "Cargo.toml [package].authors[0]", + scan_result, + ); + } + } + } + + Some(candidates) +} + +fn parse_package_json( + project_dir: &Path, + scan_result: &ScanResult, +) -> Option> { + let path = project_dir.join("package.json"); + let content = std::fs::read_to_string(&path).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&content).ok()?; + + let mut candidates = Vec::new(); + + if let Some(name) = parsed.get("name").and_then(|n| n.as_str()) { + let clean_name = strip_npm_scope(name); + push_config_candidate( + &mut candidates, + clean_name, + "project_name", + 0.90, + "package.json \"name\"", + scan_result, + ); + } + + if let Some(version) = parsed.get("version").and_then(|v| v.as_str()) { + if !version.is_empty() { + push_config_candidate( + &mut candidates, + version, + "version", + 0.85, + "package.json \"version\"", + scan_result, + ); + } + } + + if let Some(author) = parsed.get("author") { + let author_str = match author { + serde_json::Value::String(s) => Some(strip_email(s)), + serde_json::Value::Object(obj) => { + obj.get("name").and_then(|n| n.as_str()).map(String::from) + } + _ => None, + }; + if let Some(author_name) = author_str { + if !author_name.is_empty() { + push_config_candidate( + &mut candidates, + &author_name, + "author", + 0.85, + "package.json \"author\"", + scan_result, + ); + } + } + } + + Some(candidates) +} + +fn parse_pyproject_toml( + project_dir: &Path, + scan_result: &ScanResult, +) -> Option> { + let path = project_dir.join("pyproject.toml"); + let content = std::fs::read_to_string(&path).ok()?; + let parsed: toml::Value = content.parse().ok()?; + + let mut candidates = Vec::new(); + + if let Some(name) = parsed + .get("project") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + { + push_config_candidate( + &mut candidates, + name, + "project_name", + 0.90, + "pyproject.toml [project].name", + scan_result, + ); + } + + if let Some(version) = parsed + .get("project") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + { + if !version.is_empty() { + push_config_candidate( + &mut candidates, + version, + "version", + 0.85, + "pyproject.toml [project].version", + scan_result, + ); + } + } + + if let Some(authors) = parsed + .get("project") + .and_then(|p| p.get("authors")) + .and_then(|a| a.as_array()) + { + if let Some(first) = authors.first() { + let author_name = first + .get("name") + .and_then(|n| n.as_str()) + .or_else(|| first.as_str()) + .map(strip_email); + if let Some(name) = author_name { + if !name.is_empty() { + push_config_candidate( + &mut candidates, + &name, + "author", + 0.85, + "pyproject.toml [project].authors[0].name", + scan_result, + ); + } + } + } + } + + Some(candidates) +} + +fn parse_go_mod(project_dir: &Path, scan_result: &ScanResult) -> Option> { + let path = project_dir.join("go.mod"); + let content = std::fs::read_to_string(&path).ok()?; + + let module_path = GO_MOD_RE.captures(&content)?.get(1)?.as_str(); + + let segments: Vec<&str> = module_path.split('/').collect(); + + // Extract last path segment as project name + let name = segments.last().copied()?; + if name.is_empty() { + return None; + } + + let mut candidates = Vec::new(); + + push_config_candidate( + &mut candidates, + name, + "project_name", + 0.90, + &format!("go.mod module \"{}\"", module_path), + scan_result, + ); + + // Extract org name (second-to-last segment for github.com/org/repo patterns) + if segments.len() >= 3 { + let org = segments[segments.len() - 2]; + if !org.is_empty() && org != name { + let (_, org_total_occurrences) = count_occurrences(org, scan_result); + if org_total_occurrences > 0 { + push_config_candidate( + &mut candidates, + org, + "org_name", + 0.85, + &format!("go.mod module org \"{}\"", org), + scan_result, + ); + } + } + } + + Some(candidates) +} + +// ── Tier 3: Git metadata ───────────────────────────────────────────────── + +fn detect_git_metadata(project_dir: &Path, scan_result: &ScanResult) -> Vec { + let mut candidates = Vec::new(); + + // Try to get remote origin URL + if let Some(url) = git_config_get(project_dir, "remote.origin.url") { + if let Some(org) = parse_org_from_url(&url) { + let (file_count, total_occurrences) = count_occurrences(&org, scan_result); + // Only include if org name actually appears in files + if total_occurrences > 0 { + candidates.push(DetectedCandidate { + suggested_name: "org_name".to_string(), + value: org.clone(), + tier: ConfidenceTier::GitMetadata, + confidence: 0.70, + reason: format!("git remote org \"{}\"", org), + file_count, + total_occurrences, + }); + } + } + } + + // Try to get user name + if let Some(user_name) = git_config_get(project_dir, "user.name") { + if !user_name.is_empty() { + let (file_count, total_occurrences) = count_occurrences(&user_name, scan_result); + candidates.push(DetectedCandidate { + suggested_name: "author".to_string(), + value: user_name.clone(), + tier: ConfidenceTier::GitMetadata, + confidence: 0.65, + reason: format!("git config user.name \"{}\"", user_name), + file_count, + total_occurrences, + }); + } + } + + candidates +} + +fn git_config_get(project_dir: &Path, key: &str) -> Option { + let output = Command::new("git") + .arg("config") + .arg("--get") + .arg(key) + .current_dir(project_dir) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +fn parse_org_from_url(url: &str) -> Option { + // SSH: git@github.com:org/repo.git + if let Some(rest) = url.strip_prefix("git@") { + let after_colon = rest.split(':').nth(1)?; + let org = after_colon.split('/').next()?; + if !org.is_empty() { + return Some(org.to_string()); + } + } + + // HTTPS: https://github.com/org/repo.git + if url.starts_with("https://") || url.starts_with("http://") { + let parts: Vec<&str> = url.split('/').collect(); + // https://host/org/repo → parts[3] is org + if parts.len() >= 4 && !parts[3].is_empty() { + return Some(parts[3].to_string()); + } + } + + None +} + +// ── Tier 4: Frequency analysis ─────────────────────────────────────────── + +fn detect_frequency( + scan_result: &ScanResult, + covered_values: &HashSet, +) -> Vec { + // Tokenize all text file content + let mut token_file_map: HashMap> = HashMap::new(); + let mut token_counts: HashMap = HashMap::new(); + + for (file_idx, file) in scan_result.files.iter().enumerate() { + if let Some(ref content) = file.content { + for mat in TOKEN_RE.find_iter(content) { + let token = mat.as_str().to_string(); + token_file_map + .entry(token.clone()) + .or_default() + .insert(file_idx); + *token_counts.entry(token).or_insert(0) += 1; + } + } + } + + // Group tokens by normalized word list to find multi-variant clusters + struct Cluster { + literals: Vec, + total_occurrences: usize, + files: HashSet, + } + + let mut clusters: HashMap = HashMap::new(); + + for (token, count) in &token_counts { + let words = split_into_words(token); + let normalized_key = words.join(" "); + + // Token must be at least 4 chars + if token.len() < 4 { + continue; + } + + let cluster = clusters.entry(normalized_key).or_insert_with(|| Cluster { + literals: Vec::new(), + total_occurrences: 0, + files: HashSet::new(), + }); + + if !cluster.literals.contains(token) { + cluster.literals.push(token.clone()); + } + cluster.total_occurrences += count; + if let Some(file_set) = token_file_map.get(token) { + cluster.files.extend(file_set); + } + } + + // Filter and convert to candidates + let mut freq_candidates: Vec = Vec::new(); + + for cluster in clusters.values() { + // Must have ≥2 distinct case variants (the key multi-variant heuristic) + if cluster.literals.len() < 2 { + continue; + } + + // Must have ≥3 total occurrences + if cluster.total_occurrences < 3 { + continue; + } + + // Must appear in ≥2 files + if cluster.files.len() < 2 { + continue; + } + + // Skip if already covered by higher tiers + if cluster + .literals + .iter() + .any(|l| covered_values.contains(&l.to_lowercase())) + { + continue; + } + + let best_literal = &cluster.literals[0]; + let words = split_into_words(best_literal); + let suggested_name = if words.len() <= 3 { + words.join("_") + } else { + words[..3].join("_") + }; + + let file_count = cluster.files.len(); + freq_candidates.push(DetectedCandidate { + suggested_name, + value: best_literal.clone(), + tier: ConfidenceTier::FrequencyAnalysis, + confidence: 0.60, + reason: format!( + "{} occurrences across {} files, {} variant(s)", + cluster.total_occurrences, + file_count, + cluster.literals.len() + ), + file_count, + total_occurrences: cluster.total_occurrences, + }); + } + + // Sort by file_count * total_occurrences descending, take top 5 + freq_candidates.sort_by(|a, b| { + let score_a = a.file_count * a.total_occurrences; + let score_b = b.file_count * b.total_occurrences; + score_b.cmp(&score_a) + }); + freq_candidates.truncate(5); + + freq_candidates +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +pub fn count_occurrences(value: &str, scan_result: &ScanResult) -> (usize, usize) { + let mut file_count = 0; + let mut total = 0; + + for file in &scan_result.files { + let mut counted_file = false; + + if let Some(ref content) = file.content { + let hits = content.matches(value).count(); + if hits > 0 { + file_count += 1; + counted_file = true; + total += hits; + } + } + + let path_str = file.relative_path.to_string_lossy(); + let path_hits = path_str.matches(value).count(); + if path_hits > 0 { + total += path_hits; + if !counted_file { + file_count += 1; + } + } + } + + (file_count, total) +} + +pub fn strip_email(s: &str) -> String { + // "Jane Doe " → "Jane Doe" + if let Some(idx) = s.find('<') { + s[..idx].trim().to_string() + } else if s.contains('@') { + // Bare email — use part before @ + s.split('@').next().unwrap_or("").trim().to_string() + } else { + s.trim().to_string() + } +} + +fn strip_npm_scope(name: &str) -> &str { + if let Some(rest) = name.strip_prefix('@') { + rest.split('/').nth(1).unwrap_or(name) + } else { + name + } +} + +fn deduplicate_candidates(candidates: &mut Vec) { + // Only deduplicate by value (same literal from multiple tiers → keep highest confidence). + // Name collisions (e.g., two different "author" candidates) are preserved + // for the interactive/yes layer to resolve. + let mut seen_value: HashMap = HashMap::new(); + let mut to_remove = Vec::new(); + + for (i, candidate) in candidates.iter().enumerate() { + let value_key = candidate.value.to_lowercase(); + if let Some(&prev_idx) = seen_value.get(&value_key) { + if candidate.confidence > candidates[prev_idx].confidence { + to_remove.push(prev_idx); + seen_value.insert(value_key, i); + } else { + to_remove.push(i); + } + } else { + seen_value.insert(value_key, i); + } + } + + to_remove.sort_unstable(); + to_remove.dedup(); + for idx in to_remove.into_iter().rev() { + candidates.remove(idx); + } +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::extract::scan::ScannedFile; + use std::path::PathBuf; + + fn make_scan_result(files: Vec<(&str, &str)>) -> ScanResult { + ScanResult { + files: files + .into_iter() + .map(|(path, content)| ScannedFile { + relative_path: PathBuf::from(path), + absolute_path: PathBuf::from(path), + is_binary: false, + content: Some(content.to_string()), + }) + .collect(), + excluded_count: 0, + } + } + + // ── Tier 1 tests ───────────────────────────────────────────────── + + #[test] + fn test_tier1_basic_dir_name() { + let scan = make_scan_result(vec![ + ("README.md", "# my-widget\nA widget project"), + ("src/lib.rs", "// my-widget core"), + ]); + let dir = PathBuf::from("/projects/my-widget"); + let candidates = detect_directory_name(&dir, &scan); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].value, "my-widget"); + assert_eq!(candidates[0].suggested_name, "project_name"); + assert_eq!(candidates[0].confidence, 0.95); + assert!(candidates[0].total_occurrences >= 2); + } + + #[test] + fn test_tier1_generic_name_skipped() { + let scan = make_scan_result(vec![("main.rs", "fn main() {}")]); + let dir = PathBuf::from("/projects/src"); + let candidates = detect_directory_name(&dir, &scan); + assert!(candidates.is_empty()); + } + + #[test] + fn test_tier1_occurrence_counting() { + let scan = make_scan_result(vec![ + ("a.txt", "hello hello hello"), + ("b.txt", "hello world"), + ]); + let dir = PathBuf::from("/projects/hello"); + let candidates = detect_directory_name(&dir, &scan); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].file_count, 2); + assert!(candidates[0].total_occurrences >= 4); + } + + // ── Tier 2 tests ───────────────────────────────────────────────── + + #[test] + fn test_tier2_cargo_toml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"data-pipeline\"\nversion = \"0.3.1\"\nauthors = [\"Alice \"]\n", + ) + .unwrap(); + + let scan = make_scan_result(vec![("src/main.rs", "data-pipeline runs here")]); + let candidates = parse_cargo_toml(dir.path(), &scan).unwrap(); + + assert!(candidates.iter().any(|c| c.value == "data-pipeline")); + assert!(candidates + .iter() + .any(|c| c.value == "0.3.1" && c.suggested_name == "version" && c.confidence == 0.85)); + assert!(candidates.iter().any(|c| c.value == "Alice")); + } + + #[test] + fn test_tier2_package_json_with_scope() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"name": "@myorg/cool-widget", "version": "2.1.0", "author": "Bob Smith "}"#, + ) + .unwrap(); + + let scan = make_scan_result(vec![("index.js", "cool-widget stuff")]); + let candidates = parse_package_json(dir.path(), &scan).unwrap(); + + let name_candidate = candidates + .iter() + .find(|c| c.suggested_name == "project_name") + .unwrap(); + assert_eq!(name_candidate.value, "cool-widget"); + + let version_candidate = candidates + .iter() + .find(|c| c.suggested_name == "version") + .unwrap(); + assert_eq!(version_candidate.value, "2.1.0"); + assert_eq!(version_candidate.confidence, 0.85); + + let author_candidate = candidates + .iter() + .find(|c| c.suggested_name == "author") + .unwrap(); + assert_eq!(author_candidate.value, "Bob Smith"); + } + + #[test] + fn test_tier2_pyproject_toml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"my-tool\"\nversion = \"1.0.0\"\n\n[[project.authors]]\nname = \"Charlie\"\n", + ) + .unwrap(); + + let scan = make_scan_result(vec![("setup.py", "my-tool setup")]); + let candidates = parse_pyproject_toml(dir.path(), &scan).unwrap(); + + assert!(candidates.iter().any(|c| c.value == "my-tool")); + assert!(candidates + .iter() + .any(|c| c.value == "1.0.0" && c.suggested_name == "version" && c.confidence == 0.85)); + assert!(candidates.iter().any(|c| c.value == "Charlie")); + } + + #[test] + fn test_tier2_go_mod() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("go.mod"), + "module github.com/acme/my-service\n\ngo 1.21\n", + ) + .unwrap(); + + let scan = make_scan_result(vec![("main.go", "package main // my-service by acme")]); + let candidates = parse_go_mod(dir.path(), &scan).unwrap(); + + let project = candidates + .iter() + .find(|c| c.suggested_name == "project_name"); + assert!(project.is_some()); + assert_eq!(project.unwrap().value, "my-service"); + + let org = candidates.iter().find(|c| c.suggested_name == "org_name"); + assert!(org.is_some(), "should extract org from go.mod module path"); + assert_eq!(org.unwrap().value, "acme"); + } + + #[test] + fn test_tier2_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let scan = make_scan_result(vec![]); + + assert!(parse_cargo_toml(dir.path(), &scan).is_none()); + assert!(parse_package_json(dir.path(), &scan).is_none()); + assert!(parse_pyproject_toml(dir.path(), &scan).is_none()); + assert!(parse_go_mod(dir.path(), &scan).is_none()); + } + + #[test] + fn test_tier2_malformed_cargo_toml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "this is not valid toml {{{}}}", + ) + .unwrap(); + let scan = make_scan_result(vec![]); + assert!(parse_cargo_toml(dir.path(), &scan).is_none()); + } + + #[test] + fn test_tier2_version_missing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"no-version-crate\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"name": "no-version-pkg"}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"no-version-py\"\n", + ) + .unwrap(); + + let scan = make_scan_result(vec![]); + + let cargo = parse_cargo_toml(dir.path(), &scan).unwrap(); + assert!(!cargo.iter().any(|c| c.suggested_name == "version")); + + let pkg = parse_package_json(dir.path(), &scan).unwrap(); + assert!(!pkg.iter().any(|c| c.suggested_name == "version")); + + let pyproj = parse_pyproject_toml(dir.path(), &scan).unwrap(); + assert!(!pyproj.iter().any(|c| c.suggested_name == "version")); + } + + // ── Tier 3 tests ───────────────────────────────────────────────── + + #[test] + fn test_parse_org_from_url_ssh() { + assert_eq!( + parse_org_from_url("git@github.com:acme-corp/my-repo.git"), + Some("acme-corp".to_string()) + ); + } + + #[test] + fn test_parse_org_from_url_https() { + assert_eq!( + parse_org_from_url("https://github.com/acme-corp/my-repo.git"), + Some("acme-corp".to_string()) + ); + } + + #[test] + fn test_strip_email_with_angle_brackets() { + assert_eq!(strip_email("Jane Doe "), "Jane Doe"); + } + + #[test] + fn test_strip_email_bare_email() { + assert_eq!(strip_email("jane@example.com"), "jane"); + } + + #[test] + fn test_strip_email_no_email() { + assert_eq!(strip_email("Jane Doe"), "Jane Doe"); + } + + // ── Tier 4 tests ───────────────────────────────────────────────── + + #[test] + fn test_frequency_finds_repeated_identifier() { + let scan = make_scan_result(vec![ + ("a.txt", "data-pipeline is great\ndata-pipeline rocks"), + ("b.txt", "use data_pipeline here\ndata_pipeline again"), + ("c.txt", "DataPipeline class\nDataPipeline impl"), + ("d.txt", "DATA_PIPELINE env var\nDATA_PIPELINE config"), + ]); + + let covered = HashSet::new(); + let candidates = detect_frequency(&scan, &covered); + + assert!(!candidates.is_empty()); + // Should find "data-pipeline" cluster (multi-variant) + let found = candidates.iter().any(|c| { + let words = split_into_words(&c.value); + words == vec!["data", "pipeline"] + }); + assert!( + found, + "should find data-pipeline cluster, got: {:?}", + candidates + ); + } + + #[test] + fn test_frequency_filters_short_tokens() { + let scan = make_scan_result(vec![("a.txt", "ab cd ef gh"), ("b.txt", "ab cd ef gh")]); + + let covered = HashSet::new(); + let candidates = detect_frequency(&scan, &covered); + + assert!(candidates.is_empty(), "short tokens should be filtered"); + } + + #[test] + fn test_frequency_skips_covered_values() { + let scan = make_scan_result(vec![ + ("a.txt", "my-widget rocks"), + ("b.txt", "my-widget is great"), + ("c.txt", "my_widget too"), + ]); + + let mut covered = HashSet::new(); + covered.insert("my-widget".to_string()); + let candidates = detect_frequency(&scan, &covered); + + let has_widget = candidates + .iter() + .any(|c| c.value.to_lowercase().contains("widget")); + assert!(!has_widget, "covered values should be skipped"); + } + + #[test] + fn test_frequency_requires_multi_variant() { + // Single variant only — should NOT be detected even with many occurrences + let scan = make_scan_result(vec![ + ("a.txt", "async_handler async_handler async_handler"), + ("b.txt", "async_handler async_handler"), + ("c.txt", "async_handler"), + ]); + + let covered = HashSet::new(); + let candidates = detect_frequency(&scan, &covered); + + assert!( + candidates.is_empty(), + "single-variant tokens should be filtered, got: {:?}", + candidates + ); + } + + // ── Helper tests ───────────────────────────────────────────────── + + #[test] + fn test_deduplication_keeps_highest_confidence() { + let mut candidates = vec![ + DetectedCandidate { + suggested_name: "project_name".to_string(), + value: "my-app".to_string(), + tier: ConfidenceTier::ConfigFile, + confidence: 0.90, + reason: "Cargo.toml".to_string(), + file_count: 3, + total_occurrences: 10, + }, + DetectedCandidate { + suggested_name: "project_name".to_string(), + value: "my-app".to_string(), + tier: ConfidenceTier::DirectoryName, + confidence: 0.95, + reason: "directory name".to_string(), + file_count: 3, + total_occurrences: 10, + }, + ]; + + deduplicate_candidates(&mut candidates); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].confidence, 0.95); + } + + #[test] + fn test_name_collisions_preserved() { + let mut candidates = vec![ + DetectedCandidate { + suggested_name: "author".to_string(), + value: "Alice Johnson".to_string(), + tier: ConfidenceTier::ConfigFile, + confidence: 0.85, + reason: "package.json".to_string(), + file_count: 3, + total_occurrences: 5, + }, + DetectedCandidate { + suggested_name: "author".to_string(), + value: "Robert Roskam".to_string(), + tier: ConfidenceTier::GitMetadata, + confidence: 0.65, + reason: "git config".to_string(), + file_count: 0, + total_occurrences: 0, + }, + ]; + + deduplicate_candidates(&mut candidates); + assert_eq!( + candidates.len(), + 2, + "name collisions should be preserved for interactive resolution" + ); + } + + #[test] + fn test_strip_npm_scope() { + assert_eq!(strip_npm_scope("@myorg/cool-widget"), "cool-widget"); + assert_eq!(strip_npm_scope("plain-package"), "plain-package"); + } + + #[test] + fn test_auto_detect_integration() { + let dir = tempfile::tempdir().unwrap(); + let project_dir = dir.path().join("my-widget"); + std::fs::create_dir(&project_dir).unwrap(); + std::fs::write( + project_dir.join("README.md"), + "# my-widget\nWelcome to my-widget", + ) + .unwrap(); + std::fs::write( + project_dir.join("lib.rs"), + "pub mod my_widget;\nstruct MyWidget;", + ) + .unwrap(); + + let scan = crate::extract::scan::scan_project(&project_dir, &[]).unwrap(); + let result = auto_detect(&project_dir, &scan); + + assert!(!result.candidates.is_empty()); + let project_name = result + .candidates + .iter() + .find(|c| c.suggested_name == "project_name"); + assert!(project_name.is_some(), "should detect project_name"); + assert_eq!(project_name.unwrap().value, "my-widget"); + } +} diff --git a/src/extract/conditional.rs b/src/extract/conditional.rs new file mode 100644 index 0000000..67e7346 --- /dev/null +++ b/src/extract/conditional.rs @@ -0,0 +1,170 @@ +use std::path::Path; + +/// A known optional file pattern that can be made conditional in the template. +#[derive(Debug, Clone)] +pub struct ConditionalPattern { + /// Glob pattern to match files. + pub pattern: &'static str, + /// Variable name to control inclusion. + pub variable: &'static str, + /// Human-readable description. + pub description: &'static str, +} + +/// Curated list of known optional file patterns. +const KNOWN_PATTERNS: &[ConditionalPattern] = &[ + ConditionalPattern { + pattern: ".github/**", + variable: "use_github_actions", + description: "GitHub Actions CI", + }, + ConditionalPattern { + pattern: ".gitlab-ci.yml", + variable: "use_gitlab_ci", + description: "GitLab CI", + }, + ConditionalPattern { + pattern: "Dockerfile", + variable: "use_docker", + description: "Docker support", + }, + ConditionalPattern { + pattern: "docker-compose.yml", + variable: "use_docker", + description: "Docker support", + }, + ConditionalPattern { + pattern: "docker-compose.yaml", + variable: "use_docker", + description: "Docker support", + }, + ConditionalPattern { + pattern: ".pre-commit-config.yaml", + variable: "use_pre_commit", + description: "Pre-commit hooks", + }, + ConditionalPattern { + pattern: "Makefile", + variable: "use_make", + description: "Make build system", + }, + ConditionalPattern { + pattern: "Justfile", + variable: "use_just", + description: "Just command runner", + }, + ConditionalPattern { + pattern: ".editorconfig", + variable: "use_editorconfig", + description: "EditorConfig", + }, + ConditionalPattern { + pattern: "renovate.json", + variable: "use_renovate", + description: "Renovate dependency updates", + }, + ConditionalPattern { + pattern: ".renovaterc", + variable: "use_renovate", + description: "Renovate dependency updates", + }, + ConditionalPattern { + pattern: ".github/dependabot.yml", + variable: "use_dependabot", + description: "Dependabot", + }, + ConditionalPattern { + pattern: ".husky/**", + variable: "use_husky", + description: "Git hooks (JS)", + }, +]; + +/// A detected conditional file in the project. +#[derive(Debug, Clone)] +pub struct DetectedConditional { + /// The pattern that matched. + pub pattern: String, + /// The variable name to control this pattern. + pub variable: String, + /// Human-readable description. + pub description: String, +} + +/// Detect which known optional file patterns exist in the project. +/// +/// Groups by variable name — e.g., multiple Docker files share `use_docker`. +pub fn detect_conditional_files(project_dir: &Path) -> Vec { + let mut detected = Vec::new(); + let mut seen_variables = std::collections::HashSet::new(); + + for known in KNOWN_PATTERNS { + let exists = if known.pattern.contains("**") { + // Directory pattern — check if the directory exists + let dir_part = known.pattern.split("/**").next().unwrap_or(known.pattern); + project_dir.join(dir_part).exists() + } else { + project_dir.join(known.pattern).exists() + }; + + if exists && seen_variables.insert(known.variable) { + detected.push(DetectedConditional { + pattern: known.pattern.to_string(), + variable: known.variable.to_string(), + description: known.description.to_string(), + }); + } + } + + detected +} + +/// Get all patterns for a given variable name from the known patterns list. +pub fn patterns_for_variable(variable: &str) -> Vec<&'static str> { + KNOWN_PATTERNS + .iter() + .filter(|p| p.variable == variable) + .map(|p| p.pattern) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_conditional_files_github() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github/workflows")).unwrap(); + + let detected = detect_conditional_files(dir.path()); + assert_eq!(detected.len(), 1); + assert_eq!(detected[0].variable, "use_github_actions"); + } + + #[test] + fn test_detect_conditional_files_docker() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Dockerfile"), "FROM alpine").unwrap(); + std::fs::write(dir.path().join("docker-compose.yml"), "version: '3'").unwrap(); + + let detected = detect_conditional_files(dir.path()); + // Should deduplicate by variable name + assert_eq!(detected.len(), 1); + assert_eq!(detected[0].variable, "use_docker"); + } + + #[test] + fn test_detect_conditional_files_empty() { + let dir = tempfile::tempdir().unwrap(); + let detected = detect_conditional_files(dir.path()); + assert!(detected.is_empty()); + } + + #[test] + fn test_patterns_for_variable() { + let docker_patterns = patterns_for_variable("use_docker"); + assert!(docker_patterns.contains(&"Dockerfile")); + assert!(docker_patterns.contains(&"docker-compose.yml")); + } +} diff --git a/src/extract/config_gen.rs b/src/extract/config_gen.rs new file mode 100644 index 0000000..91dea6c --- /dev/null +++ b/src/extract/config_gen.rs @@ -0,0 +1,206 @@ +/// A prompted variable entry for the generated config. +pub struct PromptedVariable { + pub name: String, + pub default_value: String, + pub prompt: String, +} + +/// A computed variable entry for the generated config. +pub struct ComputedVariable { + pub name: String, + pub expression: String, +} + +/// A conditional file entry for the generated config. +#[derive(Debug, Clone)] +pub struct ConditionalEntry { + pub patterns: Vec, + pub variable: String, + pub description: String, +} + +/// Options for generating the diecut.toml config file. +pub struct ConfigGenOptions { + pub template_name: String, + pub prompted_variables: Vec, + pub computed_variables: Vec, + pub exclude_patterns: Vec, + pub copy_without_render: Vec, + pub conditional_entries: Vec, +} + +/// Generate a diecut.toml config string with comments for readability. +/// +/// Uses manual TOML string building because the `toml` crate can't serialize comments, +/// and users need to read and edit this file. +pub fn generate_config_toml(options: &ConfigGenOptions) -> String { + let mut out = String::new(); + + // [template] section + out.push_str("[template]\n"); + out.push_str(&format!( + "name = {}\n", + escape_toml_string(&options.template_name) + )); + out.push_str("version = \"1.0.0\"\n"); + out.push_str("# description = \"A project template\"\n"); + out.push('\n'); + + // [variables] section — prompted variables first + if !options.prompted_variables.is_empty() || !options.computed_variables.is_empty() { + out.push_str("# ── Variables ──────────────────────────────────────────\n"); + out.push_str("# Prompted variables are asked during `diecut new`.\n"); + out.push_str("# Computed variables are auto-derived and never prompted.\n"); + out.push('\n'); + } + + for var in &options.prompted_variables { + out.push_str(&format!("[variables.{}]\n", var.name)); + out.push_str("type = \"string\"\n"); + out.push_str(&format!("prompt = {}\n", escape_toml_string(&var.prompt))); + out.push_str(&format!( + "default = {}\n", + escape_toml_string(&var.default_value) + )); + out.push('\n'); + } + + // Conditional file boolean variables + for entry in &options.conditional_entries { + out.push_str(&format!("# {} ({})\n", entry.variable, entry.description)); + out.push_str(&format!("[variables.{}]\n", entry.variable)); + out.push_str("type = \"bool\"\n"); + out.push_str(&format!( + "prompt = {}\n", + escape_toml_string(&format!("Include {}?", entry.description.to_lowercase())) + )); + out.push_str("default = true\n"); + out.push('\n'); + } + + // Computed variables + for var in &options.computed_variables { + out.push_str(&format!("[variables.{}]\n", var.name)); + out.push_str("type = \"string\"\n"); + out.push_str(&format!( + "computed = {}\n", + escape_toml_string(&format!("{{{{ {} }}}}", var.expression)) + )); + out.push('\n'); + } + + // [files] section + out.push_str("# ── Files ─────────────────────────────────────────────\n"); + out.push_str("[files]\n"); + + if !options.exclude_patterns.is_empty() { + out.push_str("exclude = [\n"); + for pattern in &options.exclude_patterns { + out.push_str(&format!(" {},\n", escape_toml_string(pattern))); + } + out.push_str("]\n"); + } + + if !options.copy_without_render.is_empty() { + out.push_str("copy_without_render = [\n"); + for pattern in &options.copy_without_render { + out.push_str(&format!(" {},\n", escape_toml_string(pattern))); + } + out.push_str("]\n"); + } + + out.push('\n'); + + // [[files.conditional]] entries + for entry in &options.conditional_entries { + for pattern in &entry.patterns { + out.push_str(&format!("# {}\n", entry.description)); + out.push_str("[[files.conditional]]\n"); + out.push_str(&format!("pattern = {}\n", escape_toml_string(pattern))); + out.push_str(&format!("when = {}\n", escape_toml_string(&entry.variable))); + out.push('\n'); + } + } + + // [hooks] section + out.push_str("# ── Hooks ─────────────────────────────────────────────\n"); + out.push_str("# [hooks]\n"); + out.push_str("# post_create = \"echo 'Project created!'\"\n"); + + out +} + +/// Escape a string for TOML output. +fn escape_toml_string(s: &str) -> String { + toml::Value::String(s.to_string()).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_config_basic() { + let options = ConfigGenOptions { + template_name: "my-template".to_string(), + prompted_variables: vec![PromptedVariable { + name: "project_name".to_string(), + default_value: "my-app".to_string(), + prompt: "Project name".to_string(), + }], + computed_variables: vec![ComputedVariable { + name: "project_name_snake".to_string(), + expression: "project_name | replace(from=\"-\", to=\"_\")".to_string(), + }], + exclude_patterns: vec![".git/".to_string()], + copy_without_render: vec!["*.png".to_string()], + conditional_entries: vec![], + }; + + let toml = generate_config_toml(&options); + + assert!(toml.contains("[template]")); + assert!(toml.contains("name = \"my-template\"")); + assert!(toml.contains("[variables.project_name]")); + assert!(toml.contains("type = \"string\"")); + assert!(toml.contains("[variables.project_name_snake]")); + assert!(toml.contains("computed =")); + assert!(toml.contains("[files]")); + assert!(toml.contains("\".git/\"")); + assert!(toml.contains("\"*.png\"")); + } + + #[test] + fn test_generate_config_with_conditionals() { + let options = ConfigGenOptions { + template_name: "test".to_string(), + prompted_variables: vec![], + computed_variables: vec![], + exclude_patterns: vec![], + copy_without_render: vec![], + conditional_entries: vec![ConditionalEntry { + patterns: vec![".github/**".to_string()], + variable: "use_github_actions".to_string(), + description: "GitHub Actions CI".to_string(), + }], + }; + + let toml = generate_config_toml(&options); + + assert!(toml.contains("[variables.use_github_actions]")); + assert!(toml.contains("type = \"bool\"")); + assert!(toml.contains("default = true")); + assert!(toml.contains("[[files.conditional]]")); + assert!(toml.contains("pattern = \".github/**\"")); + assert!(toml.contains("when = \"use_github_actions\"")); + } + + #[test] + fn test_escape_toml_string() { + assert_eq!(escape_toml_string("hello"), "\"hello\""); + // toml crate uses multi-line strings for values containing quotes + let escaped = escape_toml_string("it's \"fine\""); + assert!(escaped.contains("it's")); + assert!(escaped.contains("fine")); + } +} diff --git a/src/extract/exclude.rs b/src/extract/exclude.rs new file mode 100644 index 0000000..d8bac71 --- /dev/null +++ b/src/extract/exclude.rs @@ -0,0 +1,305 @@ +use std::path::Path; + +/// Default directories and files to exclude from template extraction. +const DEFAULT_EXCLUDES: &[&str] = &[ + ".git", + ".git/", + ".hg", + ".svn", + "node_modules", + "node_modules/", + ".DS_Store", + "Thumbs.db", + "__pycache__", + "__pycache__/", + "*.pyc", + ".tox", + ".nox", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + "target", + "target/", + ".venv", + ".env", + "dist", + "build", + ".next", + ".nuxt", + ".output", + ".turbo", + ".worktrees", + ".claude/worktrees", + ".astro", + ".diecut-answers.toml", +]; + +/// Patterns for files that should be copied without rendering (binary-like or problematic). +const DEFAULT_COPY_WITHOUT_RENDER: &[&str] = &[ + "*.png", + "*.jpg", + "*.jpeg", + "*.gif", + "*.ico", + "*.svg", + "*.webp", + "*.woff", + "*.woff2", + "*.ttf", + "*.eot", + "*.otf", + "*.zip", + "*.tar", + "*.gz", + "*.bz2", + "*.xz", + "*.pdf", + "*.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "Cargo.lock", + "Gemfile.lock", + "poetry.lock", + "composer.lock", +]; + +/// Return all default exclude patterns for use during scanning. +/// +/// All DEFAULT_EXCLUDES are always used during the scan phase because patterns +/// like `node_modules` can appear at any depth (e.g. `docs/node_modules/`). +pub fn all_default_excludes() -> Vec { + DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect() +} + +/// Return only the DEFAULT_EXCLUDES patterns that match at least one file in the +/// template output. These are the patterns worth writing to `diecut.toml`'s +/// `[files] exclude` — directory patterns like `.git/` or `node_modules/` that +/// were filtered during scan are omitted since those files never appear in the +/// template. +pub fn relevant_config_excludes(template_files: &[std::path::PathBuf]) -> Vec { + let all = all_default_excludes(); + all.into_iter() + .filter(|pattern| { + template_files + .iter() + .any(|f| should_exclude(f, std::slice::from_ref(pattern))) + }) + .collect() +} + +/// Detect which copy-without-render patterns are relevant based on files present. +pub fn detect_copy_without_render( + _project_dir: &Path, + files: &[std::path::PathBuf], +) -> Vec { + let mut found = Vec::new(); + + for pattern in DEFAULT_COPY_WITHOUT_RENDER { + if pattern.starts_with('*') { + // Extension pattern — check if any file matches + let ext = pattern.trim_start_matches("*."); + if files.iter().any(|f| { + f.extension() + .map(|e| e.to_string_lossy().eq_ignore_ascii_case(ext)) + .unwrap_or(false) + }) { + found.push(pattern.to_string()); + } + } else { + // Exact filename — check if present + if files.iter().any(|f| { + f.file_name() + .map(|n| n.to_string_lossy() == *pattern) + .unwrap_or(false) + }) { + found.push(pattern.to_string()); + } + } + } + + found +} + +/// Check if a file should be copied without rendering (lock files, binary-like assets). +/// +/// These files are included in the template but should never have replacements +/// applied during extraction — they're copied verbatim. +pub fn is_copy_without_render(path: &Path) -> bool { + for pattern in DEFAULT_COPY_WITHOUT_RENDER { + if let Some(ext) = pattern.strip_prefix("*.") { + if let Some(file_ext) = path.extension() { + if file_ext.to_string_lossy().eq_ignore_ascii_case(ext) { + return true; + } + } + } else if let Some(file_name) = path.file_name() { + if file_name.to_string_lossy() == *pattern { + return true; + } + } + } + false +} + +/// Check if a path should be excluded based on the exclude patterns. +pub fn should_exclude(relative_path: &Path, excludes: &[String]) -> bool { + let path_str = relative_path.to_string_lossy(); + + for pattern in excludes { + let clean = pattern.trim_end_matches('/'); + + if clean.contains('*') { + // Glob-style matching: *.pyc matches any .pyc file + if let Some(ext) = clean.strip_prefix("*.") { + if let Some(file_ext) = relative_path.extension() { + if file_ext.to_string_lossy().eq_ignore_ascii_case(ext) { + return true; + } + } + } + continue; + } + + // Exact directory/file match at any level + for component in relative_path.components() { + if let std::path::Component::Normal(os_str) = component { + if os_str.to_string_lossy() == clean { + return true; + } + } + } + + // Full path match + if path_str == clean || path_str.starts_with(&format!("{clean}/")) { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_should_exclude_git() { + let excludes = vec![".git/".to_string()]; + assert!(should_exclude(Path::new(".git/config"), &excludes)); + assert!(should_exclude(Path::new(".git/HEAD"), &excludes)); + } + + #[test] + fn test_should_exclude_node_modules() { + let excludes = vec!["node_modules".to_string()]; + assert!(should_exclude( + Path::new("node_modules/express/index.js"), + &excludes + )); + } + + #[test] + fn test_should_exclude_glob() { + let excludes = vec!["*.pyc".to_string()]; + assert!(should_exclude( + Path::new("module/__pycache__/foo.pyc"), + &excludes + )); + assert!(!should_exclude(Path::new("module/foo.py"), &excludes)); + } + + #[test] + fn test_should_not_exclude_normal_file() { + let excludes = vec![".git/".to_string(), "node_modules".to_string()]; + assert!(!should_exclude(Path::new("src/main.rs"), &excludes)); + assert!(!should_exclude(Path::new("README.md"), &excludes)); + } + + #[test] + fn test_all_default_excludes() { + let found = all_default_excludes(); + // All DEFAULT_EXCLUDES are always included + assert!(found.iter().any(|e| e.contains(".git"))); + assert!(found.iter().any(|e| e == ".DS_Store")); + assert!(found.iter().any(|e| e == "*.pyc")); + assert!(found.iter().any(|e| e.contains("node_modules"))); + } + + #[test] + fn test_relevant_config_excludes_empty_when_no_matches() { + // Typical template files won't match any DEFAULT_EXCLUDES + let files = vec![ + PathBuf::from("src/main.rs"), + PathBuf::from("README.md"), + PathBuf::from("Cargo.toml"), + ]; + let relevant = relevant_config_excludes(&files); + assert!(relevant.is_empty()); + } + + #[test] + fn test_relevant_config_excludes_finds_matching_patterns() { + let files = vec![ + PathBuf::from("src/main.py"), + PathBuf::from("src/__pycache__/main.pyc"), + PathBuf::from(".DS_Store"), + ]; + let relevant = relevant_config_excludes(&files); + assert!(relevant.contains(&"*.pyc".to_string())); + assert!(relevant.contains(&".DS_Store".to_string())); + assert!(relevant.contains(&"__pycache__".to_string())); + // Directory excludes that don't match should not appear + assert!(!relevant.contains(&".git".to_string())); + assert!(!relevant.contains(&"node_modules".to_string())); + } + + #[test] + fn test_should_exclude_claude_worktrees() { + let excludes = all_default_excludes(); + assert!(should_exclude( + Path::new(".claude/worktrees/agent-abc/Cargo.toml"), + &excludes + )); + // .claude/settings.local.json should NOT be excluded + assert!(!should_exclude( + Path::new(".claude/settings.local.json"), + &excludes + )); + } + + #[test] + fn test_should_exclude_astro() { + let excludes = all_default_excludes(); + assert!(should_exclude( + Path::new("docs/.astro/data-store.json"), + &excludes + )); + assert!(should_exclude(Path::new(".astro/settings.json"), &excludes)); + } + + #[test] + fn test_is_copy_without_render() { + assert!(is_copy_without_render(Path::new("Cargo.lock"))); + assert!(is_copy_without_render(Path::new("pnpm-lock.yaml"))); + assert!(is_copy_without_render(Path::new("package-lock.json"))); + assert!(is_copy_without_render(Path::new("logo.png"))); + assert!(is_copy_without_render(Path::new("deep/nested/file.lock"))); + assert!(!is_copy_without_render(Path::new("src/main.rs"))); + assert!(!is_copy_without_render(Path::new("README.md"))); + } + + #[test] + fn test_detect_copy_without_render() { + let files = vec![ + PathBuf::from("logo.png"), + PathBuf::from("font.woff2"), + PathBuf::from("README.md"), + ]; + let found = detect_copy_without_render(Path::new("."), &files); + assert!(found.contains(&"*.png".to_string())); + assert!(found.contains(&"*.woff2".to_string())); + assert!(!found.contains(&"*.jpg".to_string())); + } +} diff --git a/src/extract/interactive.rs b/src/extract/interactive.rs new file mode 100644 index 0000000..2f9b8e9 --- /dev/null +++ b/src/extract/interactive.rs @@ -0,0 +1,426 @@ +use std::collections::BTreeMap; + +use console::style; +use inquire::{Confirm, Select, Text}; + +use crate::config::schema::DEFAULT_TEMPLATES_SUFFIX; +use crate::error::{DicecutError, Result}; + +use super::auto_detect::{ConfidenceTier, DetectedCandidate}; +use super::conditional::DetectedConditional; +use super::variants::generate_variants; +use super::{ExtractVariable, PlannedExtractFile}; + +pub fn confirm_variants_interactive( + variables: Vec, +) -> Result> { + let mut confirmed = Vec::new(); + + for mut var in variables { + eprintln!( + "\n{} {} = {:?} {}", + style("──").dim(), + style(&var.name).bold(), + var.value, + style("──────────────────────────────────────").dim() + ); + + if var.variants.len() == 1 && var.variants[0].name == "verbatim" { + // Simple value — just show occurrence count + let (file_count, total_hits) = var + .occurrence_counts + .first() + .map(|(_, fc, th)| (*fc, *th)) + .unwrap_or((0, 0)); + if total_hits > 0 { + eprintln!( + " Found in {} files ({} occurrences)", + file_count, total_hits + ); + } else { + eprintln!( + " {} Value not found in any file (will still be added to config)", + style("⚠").yellow() + ); + } + confirmed.push(var); + continue; + } + + // Show detected variants with counts + eprintln!(" Detected case variants:"); + let mut found_any = false; + for (i, variant) in var.variants.iter().enumerate() { + let (_, file_count, total_hits) = &var.occurrence_counts[i]; + let mark = if *total_hits > 0 { + found_any = true; + style("✓").green().to_string() + } else { + style("✗").dim().to_string() + }; + let hits_str = if *total_hits > 0 { + format!( + "{} {} across {} {}", + total_hits, + if *total_hits == 1 { "hit" } else { "hits" }, + file_count, + if *file_count == 1 { "file" } else { "files" } + ) + } else { + "not found".to_string() + }; + eprintln!( + " {} {:<16} {:<20} {}", + mark, + variant.literal, + variant.name, + style(&hits_str).dim() + ); + } + + if !found_any { + eprintln!( + " {} No occurrences found for any variant (will still be added to config)", + style("⚠").yellow() + ); + // Keep just the first variant + var.variants.truncate(1); + confirmed.push(var); + continue; + } + + let keep = Confirm::new("Keep detected variants?") + .with_default(true) + .prompt() + .map_err(|_| DicecutError::PromptCancelled)?; + + if keep { + // Remove variants with zero occurrences + let counts = var.occurrence_counts.clone(); + var.variants.retain(|v| { + counts + .iter() + .any(|(name, _, hits)| name == v.name && *hits > 0) + }); + if var.variants.is_empty() { + let all = generate_variants(&var.name, &var.value); + if let Some(first) = all.into_iter().next() { + var.variants.push(first); + } + } + } else { + // Keep only the canonical variant + var.variants.truncate(1); + } + + confirmed.push(var); + } + + Ok(confirmed) +} + +pub fn confirm_excludes_interactive(mut excludes: Vec) -> Result> { + eprintln!( + "\n{} Excludes {}", + style("──").dim(), + style("─────────────────────────────────────────────").dim() + ); + if excludes.is_empty() { + eprintln!(" No exclude patterns needed for this template."); + } else { + eprintln!(" Patterns matching template files:"); + for e in &excludes { + eprintln!(" {}", e); + } + } + + let extra = Text::new("Add extra exclude patterns? (comma-separated, enter to skip)") + .with_default("") + .prompt() + .map_err(|_| DicecutError::PromptCancelled)?; + + if !extra.is_empty() { + for pattern in extra.split(',') { + let trimmed = pattern.trim().to_string(); + if !trimmed.is_empty() { + excludes.push(trimmed); + } + } + } + + Ok(excludes) +} + +pub fn confirm_conditionals_interactive( + detected: Vec, +) -> Result> { + eprintln!( + "\n{} Conditional files {}", + style("──").dim(), + style("────────────────────────────────────").dim() + ); + eprintln!(" These look optional. Make them conditional?"); + + let mut confirmed = Vec::new(); + for cond in detected { + let prompt = format!(" {} → {}", cond.pattern, cond.variable); + let include = Confirm::new(&prompt) + .with_default(false) + .prompt() + .map_err(|_| DicecutError::PromptCancelled)?; + + if include { + confirmed.push(cond); + } + } + + Ok(confirmed) +} + +pub fn resolve_candidates_yes( + candidates: &[DetectedCandidate], + explicit_vars: &[(String, String)], +) -> Vec<(String, String)> { + eprintln!( + "\n{} Auto-detected variables {}", + style("──").dim(), + style("──────────────────────────────────").dim() + ); + + // Group candidates by suggested_name + let mut groups: BTreeMap> = BTreeMap::new(); + for c in candidates { + groups.entry(c.suggested_name.clone()).or_default().push(c); + } + + let mut result = Vec::new(); + let mut skipped_freq = 0; + + for (name, mut group) in groups { + // Skip names already covered by explicit --var + if explicit_vars.iter().any(|(n, _)| n == &name) { + eprintln!( + " {} {} (explicit --var, skipping auto-detect)", + style("·").dim(), + style(&name).dim() + ); + continue; + } + + // For name collisions, pick highest confidence + group.sort_by(|a, b| b.confidence.total_cmp(&a.confidence)); + let winner = group[0]; + + // Skip frequency-analysis candidates in -y mode — too noisy for auto-accept + if winner.tier == ConfidenceTier::FrequencyAnalysis { + skipped_freq += 1; + continue; + } + + eprintln!( + " {} {} = {:?} ({:.0}% confidence, {})", + style("✓").green(), + style(&winner.suggested_name).bold(), + winner.value, + winner.confidence * 100.0, + winner.tier + ); + eprintln!(" {}", style(&winner.reason).dim()); + + if group.len() > 1 { + eprintln!( + " {} {} other candidates for this name (picked highest confidence)", + style("⚠").yellow(), + group.len() - 1 + ); + } + + result.push((winner.suggested_name.clone(), winner.value.clone())); + } + + if skipped_freq > 0 { + eprintln!( + " {} {} frequency-detected candidate(s) skipped (use interactive mode to review)", + style("·").dim(), + skipped_freq + ); + } + + result +} + +pub fn confirm_auto_detected_interactive( + candidates: Vec, + explicit_vars: &[(String, String)], +) -> Result> { + eprintln!( + "\n{} Auto-detected variables {}", + style("──").dim(), + style("──────────────────────────────────").dim() + ); + + // Group candidates by suggested_name + let mut groups: BTreeMap> = BTreeMap::new(); + for c in candidates { + groups.entry(c.suggested_name.clone()).or_default().push(c); + } + + let mut accepted = Vec::new(); + + for (name, mut group) in groups { + // Skip names already covered by explicit --var + if explicit_vars.iter().any(|(n, _)| n == &name) { + eprintln!( + "\n {} {} (provided via --var, skipping)", + style("·").dim(), + style(&name).dim() + ); + continue; + } + + // Sort by confidence descending + group.sort_by(|a, b| b.confidence.total_cmp(&a.confidence)); + + if group.len() == 1 { + // Single candidate — simple confirm + let candidate = &group[0]; + eprintln!( + "\n {} = {:?} ({:.0}% confidence, {})", + style(&candidate.suggested_name).bold(), + candidate.value, + candidate.confidence * 100.0, + candidate.tier + ); + eprintln!(" {}", style(&candidate.reason).dim()); + if candidate.total_occurrences > 0 { + eprintln!( + " {} occurrences across {} files", + candidate.total_occurrences, candidate.file_count + ); + } + + let accept = Confirm::new(&format!("Accept \"{}\"?", candidate.suggested_name)) + .with_default(true) + .prompt() + .map_err(|_| DicecutError::PromptCancelled)?; + + if accept { + accepted.push((candidate.suggested_name.clone(), candidate.value.clone())); + } + } else { + // Name collision — show selection prompt + eprintln!( + "\n {} Multiple candidates for {}:", + style("⚠").yellow(), + style(&name).bold() + ); + + let mut options: Vec = group + .iter() + .map(|c| { + format!( + "{:?} ({:.0}% confidence, {})", + c.value, + c.confidence * 100.0, + c.tier + ) + }) + .collect(); + options.push("Skip".to_string()); + + let selection = Select::new(&format!("Which value for \"{}\"?", name), options) + .prompt() + .map_err(|_| DicecutError::PromptCancelled)?; + + if selection != "Skip" { + // Find the matching candidate + if let Some(chosen) = group.iter().find(|c| { + format!( + "{:?} ({:.0}% confidence, {})", + c.value, + c.confidence * 100.0, + c.tier + ) == selection + }) { + accepted.push((chosen.suggested_name.clone(), chosen.value.clone())); + } + } + } + } + + Ok(accepted) +} + +pub fn confirm_files_interactive(files: &[PlannedExtractFile], dropped_count: usize) -> Result<()> { + let templated: Vec<_> = files.iter().filter(|f| f.has_replacements()).collect(); + let boilerplate: Vec<_> = files + .iter() + .filter(|f| !f.has_replacements() && !f.stubbed && !f.is_binary()) + .collect(); + let stubbed: Vec<_> = files.iter().filter(|f| f.stubbed).collect(); + let binary_count = files.iter().filter(|f| f.is_binary()).count(); + + eprintln!( + "\n{} File plan {}", + style("──").dim(), + style("──────────────────────────────────────────").dim() + ); + + // Templated files + eprintln!( + "\n {} ({} files, {} suffix):", + style("Templated").bold(), + templated.len(), + DEFAULT_TEMPLATES_SUFFIX + ); + for file in &templated { + eprintln!( + " {:<50} {} replacements", + file.template_path.display(), + file.replacement_count() + ); + } + + // Boilerplate files + eprintln!( + "\n {} (copied in full, {} files{}):", + style("Boilerplate").bold(), + boilerplate.len() + binary_count, + if binary_count > 0 { + format!(", {} binary", binary_count) + } else { + String::new() + } + ); + for file in &boilerplate { + eprintln!(" {}", file.template_path.display()); + } + + // Stubbed files + if !stubbed.is_empty() { + eprintln!( + "\n {} (structure only, {} files):", + style("Stubbed").bold(), + stubbed.len() + ); + for file in &stubbed { + eprintln!(" {}", file.template_path.display()); + } + } + + // Dropped files + if dropped_count > 0 { + eprintln!("\n {} ({} files):", style("Dropped").bold(), dropped_count); + } + + let proceed = Confirm::new("Proceed?") + .with_default(true) + .prompt() + .map_err(|_| DicecutError::PromptCancelled)?; + + if !proceed { + return Err(DicecutError::PromptCancelled); + } + + Ok(()) +} diff --git a/src/extract/mod.rs b/src/extract/mod.rs new file mode 100644 index 0000000..f9d1318 --- /dev/null +++ b/src/extract/mod.rs @@ -0,0 +1,601 @@ +pub mod auto_detect; +pub mod conditional; +pub mod config_gen; +pub mod exclude; +pub mod interactive; +pub mod replace; +pub mod scan; +pub mod stub; +pub mod variants; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use console::style; + +use crate::config::schema::DEFAULT_TEMPLATES_SUFFIX; +use crate::error::{DicecutError, Result}; + +use self::auto_detect::{auto_detect, count_occurrences}; +use self::conditional::{detect_conditional_files, patterns_for_variable}; +use self::config_gen::{ + generate_config_toml, ComputedVariable, ConditionalEntry, ConfigGenOptions, PromptedVariable, +}; +use self::exclude::{ + all_default_excludes, detect_copy_without_render, is_copy_without_render, + relevant_config_excludes, +}; +use self::interactive::{ + confirm_auto_detected_interactive, confirm_conditionals_interactive, + confirm_excludes_interactive, confirm_files_interactive, confirm_variants_interactive, + resolve_candidates_yes, +}; +use self::replace::{ + apply_path_replacements, apply_replacements, build_replacement_rules, ReplacementRule, +}; +use self::scan::scan_project; +use self::stub::{classify_file, generate_stub, FileRole}; +use self::variants::{ + computed_expression, detect_separator, generate_variants, is_canonical_variant, CaseVariant, +}; + +/// A variable with its value and confirmed case variants. +#[derive(Debug, Clone)] +pub struct ExtractVariable { + pub name: String, + pub value: String, + pub variants: Vec, + /// Per-variant occurrence counts: (variant_name, file_count, total_hits). + pub occurrence_counts: Vec<(String, usize, usize)>, +} + +/// The content of an extracted template file. +#[derive(Debug, Clone)] +pub enum ExtractedContent { + /// A text file with optional template replacements applied. + Text { + content: String, + replacement_count: usize, + }, + /// A binary file copied verbatim. + Binary(Vec), +} + +/// A file that will be part of the extracted template. +#[derive(Debug, Clone)] +pub struct PlannedExtractFile { + /// Relative path in the output template (may contain template expressions). + pub template_path: PathBuf, + /// The file content (text with replacements, or binary bytes). + pub content: ExtractedContent, + /// Whether this file was stubbed (content replaced with a minimal placeholder). + pub stubbed: bool, +} + +impl PlannedExtractFile { + /// Whether this file had template replacements applied. + pub fn has_replacements(&self) -> bool { + matches!(&self.content, ExtractedContent::Text { replacement_count, .. } if *replacement_count > 0) + } + + /// Whether this is a binary file. + pub fn is_binary(&self) -> bool { + matches!(&self.content, ExtractedContent::Binary(_)) + } + + /// Number of replacements made (0 for binary files). + pub fn replacement_count(&self) -> usize { + match &self.content { + ExtractedContent::Text { + replacement_count, .. + } => *replacement_count, + ExtractedContent::Binary(_) => 0, + } + } +} + +/// The full extraction plan, ready to be executed or reviewed. +#[derive(Debug)] +pub struct ExtractionPlan { + pub output_dir: PathBuf, + pub files: Vec, + pub config_toml: String, + pub variables: Vec, + pub conditional_entries: Vec, + pub exclude_patterns: Vec, + pub copy_without_render: Vec, + pub dropped_count: usize, + pub dropped_paths: Vec, +} + +/// Options for the extraction process. +pub struct ExtractOptions { + pub source_dir: PathBuf, + pub variables: Vec<(String, String)>, + pub output_dir: Option, + pub in_place: bool, + pub yes: bool, + pub min_confidence: f64, + pub stub_depth: usize, + pub dry_run: bool, +} + +/// Plan an extraction: scan the project, detect variants, build replacement rules. +pub fn plan_extraction(options: &ExtractOptions) -> Result { + let source_dir = &options.source_dir; + + if !source_dir.exists() { + return Err(DicecutError::ExtractSourceNotFound { + path: source_dir.clone(), + }); + } + + // Check if this is already a template + if source_dir.join("diecut.toml").exists() { + return Err(DicecutError::ExtractAlreadyTemplate { + path: source_dir.clone(), + }); + } + + let output_dir = if options.in_place { + source_dir.clone() + } else if let Some(ref out) = options.output_dir { + out.clone() + } else { + // Default: source dir name + "-template" + let dir_name = source_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "template".to_string()); + source_dir + .parent() + .unwrap_or(Path::new(".")) + .join(format!("{dir_name}-template")) + }; + + if !options.in_place && output_dir.exists() { + return Err(DicecutError::ExtractOutputExists { + path: output_dir.clone(), + }); + } + + // Phase 1: All default excludes for scanning (safety — never walks into .git/, node_modules/, etc.) + let scan_excludes = all_default_excludes(); + + // Phase 2: Scan project + eprintln!( + "\n{}", + style(format!("Scanning {}...", source_dir.display())).bold() + ); + let mut scan_result = scan_project(source_dir, &scan_excludes)?; + + // Drop non-boilerplate files deeper than stub_depth before auto-detect sees them. + // This prevents frequency analysis from detecting variables that only appear in + // files that would be dropped anyway. + let pre_filter_count = scan_result.files.len(); + scan_result.files.retain(|f| { + let depth = f.relative_path.components().count(); + depth <= options.stub_depth + || classify_file(&f.relative_path, options.stub_depth) == FileRole::Boilerplate + }); + let depth_dropped = pre_filter_count - scan_result.files.len(); + + eprintln!( + " {} files found, {} excluded{}", + scan_result.files.len(), + scan_result.excluded_count, + if depth_dropped > 0 { + format!(", {} too deep", depth_dropped) + } else { + String::new() + } + ); + + // Phase 2.5: Auto-detect variables (always runs), merge with explicit --var entries + let variables = { + let explicit_vars = options.variables.clone(); + let detect_result = auto_detect(source_dir, &scan_result); + + // Filter candidates below min_confidence threshold + let candidates: Vec<_> = detect_result + .candidates + .into_iter() + .filter(|c| c.confidence >= options.min_confidence) + .collect(); + + if candidates.is_empty() && explicit_vars.is_empty() { + return Err(DicecutError::ExtractNoVariables); + } + + // Resolve auto-detected candidates (merge with explicit vars) + let auto_vars = if candidates.is_empty() { + vec![] + } else if options.yes { + resolve_candidates_yes(&candidates, &explicit_vars) + } else { + confirm_auto_detected_interactive(candidates, &explicit_vars)? + }; + + // Merge: explicit vars first (pre-accepted), then auto-detected additions + let mut merged = explicit_vars; + for (name, value) in auto_vars { + if !merged.iter().any(|(n, _)| n == &name) { + merged.push((name, value)); + } + } + + if merged.is_empty() { + return Err(DicecutError::ExtractNoVariables); + } + + merged + }; + + // Phase 3: Generate variants and count occurrences + let mut extract_variables = Vec::new(); + + for (var_name, var_value) in &variables { + let all_variants = generate_variants(var_name, var_value); + + let mut occurrence_counts = Vec::new(); + for variant in &all_variants { + let (file_count, total_hits) = count_occurrences(&variant.literal, &scan_result); + occurrence_counts.push((variant.name.to_string(), file_count, total_hits)); + } + + extract_variables.push(ExtractVariable { + name: var_name.clone(), + value: var_value.clone(), + variants: all_variants, + occurrence_counts, + }); + } + + // Phase 4: Interactive variant confirmation + let confirmed_variables = if options.yes { + // Batch mode: auto-accept all found variants + extract_variables + .into_iter() + .map(|mut var| { + var.variants.retain(|v| { + var.occurrence_counts + .iter() + .any(|(name, _, hits)| name == v.name && *hits > 0) + || v.name == "verbatim" + }); + // Always keep at least the verbatim/canonical variant + if var.variants.is_empty() { + let all = generate_variants(&var.name, &var.value); + if let Some(first) = all.into_iter().next() { + var.variants.push(first); + } + } + var + }) + .collect() + } else { + confirm_variants_interactive(extract_variables)? + }; + + // Phase 6: Detect conditional files + let detected_conditionals = if options.yes { + vec![] // Batch mode: no conditional files + } else { + let detected = detect_conditional_files(source_dir); + if detected.is_empty() { + vec![] + } else { + confirm_conditionals_interactive(detected)? + } + }; + + // Phase 7: Build replacement rules + let mut rules = Vec::new(); + for var in &confirmed_variables { + for variant in &var.variants { + rules.push(ReplacementRule { + literal: variant.literal.clone(), + replacement: variant.tera_expr.clone(), + variable: var.name.clone(), + variant: variant.name.to_string(), + }); + } + } + build_replacement_rules(&mut rules); + + // Phase 8: Detect copy_without_render patterns + let file_paths: Vec = scan_result + .files + .iter() + .map(|f| f.relative_path.clone()) + .collect(); + let copy_without_render = detect_copy_without_render(source_dir, &file_paths); + + // Phase 9: Apply replacements to files + let mut planned_files = Vec::new(); + let mut dropped_count = depth_dropped; + let mut dropped_paths = Vec::new(); + + for file in &scan_result.files { + let template_path = apply_path_replacements(&file.relative_path, &rules); + + if file.is_binary { + let binary_content = + std::fs::read(&file.absolute_path).map_err(|e| DicecutError::Io { + context: format!("reading binary file {}", file.absolute_path.display()), + source: e, + })?; + planned_files.push(PlannedExtractFile { + template_path, + content: ExtractedContent::Binary(binary_content), + stubbed: false, + }); + } else if let Some(ref content) = file.content { + // Lock files and other copy-without-render files: skip replacement + if is_copy_without_render(&file.relative_path) { + planned_files.push(PlannedExtractFile { + template_path, + content: ExtractedContent::Text { + content: content.clone(), + replacement_count: 0, + }, + stubbed: false, + }); + continue; + } + + let (replaced, count) = apply_replacements(content, &rules); + + if count > 0 { + // Has template replacements — add .die suffix + let mut p = template_path.as_os_str().to_string_lossy().to_string(); + p.push_str(DEFAULT_TEMPLATES_SUFFIX); + planned_files.push(PlannedExtractFile { + template_path: PathBuf::from(p), + content: ExtractedContent::Text { + content: replaced, + replacement_count: count, + }, + stubbed: false, + }); + } else { + // No replacements — classify as boilerplate, content, or dropped + match classify_file(&file.relative_path, options.stub_depth) { + FileRole::Boilerplate => { + planned_files.push(PlannedExtractFile { + template_path, + content: ExtractedContent::Text { + content: replaced, + replacement_count: 0, + }, + stubbed: false, + }); + } + FileRole::Content => { + let stub = generate_stub(&file.relative_path); + planned_files.push(PlannedExtractFile { + template_path, + content: ExtractedContent::Text { + content: stub, + replacement_count: 0, + }, + stubbed: true, + }); + } + FileRole::Dropped => { + dropped_count += 1; + dropped_paths.push(file.relative_path.clone()); + } + } + } + } + } + + // Phase 9.5: Compute config-appropriate excludes from planned template files + // Only patterns that match files actually in the template are worth writing to diecut.toml + let template_paths: Vec = planned_files + .iter() + .map(|f| f.template_path.clone()) + .collect(); + let mut config_excludes = relevant_config_excludes(&template_paths); + + if !options.yes { + config_excludes = confirm_excludes_interactive(config_excludes)?; + } + + // Phase 10: Interactive file confirmation + if !options.yes { + confirm_files_interactive(&planned_files, dropped_count)?; + } + + // Phase 11: Build conditional entries + let conditional_entries: Vec = detected_conditionals + .iter() + .map(|d| { + let patterns = patterns_for_variable(&d.variable) + .into_iter() + .map(|p| p.to_string()) + .collect(); + ConditionalEntry { + patterns, + variable: d.variable.clone(), + description: d.description.clone(), + } + }) + .collect(); + + // Phase 12: Generate config + let canonical_seps: HashMap = confirmed_variables + .iter() + .map(|v| (v.name.clone(), detect_separator(&v.value))) + .collect(); + + let prompted_vars: Vec = confirmed_variables + .iter() + .map(|v| PromptedVariable { + name: v.name.clone(), + default_value: v.value.clone(), + prompt: v.name.replace(['_', '-'], " "), + }) + .collect(); + + let mut computed_vars = Vec::new(); + for var in &confirmed_variables { + let canonical_sep = canonical_seps.get(&var.name).copied().unwrap_or("-"); + for variant in &var.variants { + // Skip the canonical variant (it uses the variable directly) + if variant.name == "verbatim" { + continue; + } + // Skip the variant that matches the canonical separator + if is_canonical_variant(variant.name, canonical_sep) { + continue; + } + + let computed_name = format!("{}_{}", var.name, variant.name); + let expression = computed_expression(&var.name, variant.name, canonical_sep); + // Don't add if expression is just the variable name + if expression != var.name { + computed_vars.push(ComputedVariable { + name: computed_name, + expression, + }); + } + } + } + + let config_toml = generate_config_toml(&ConfigGenOptions { + template_name: source_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "template".to_string()), + prompted_variables: prompted_vars, + computed_variables: computed_vars, + exclude_patterns: config_excludes.clone(), + copy_without_render: copy_without_render.clone(), + conditional_entries: conditional_entries.clone(), + }); + + Ok(ExtractionPlan { + output_dir, + files: planned_files, + config_toml, + variables: confirmed_variables, + conditional_entries, + exclude_patterns: config_excludes, + copy_without_render, + dropped_count, + dropped_paths, + }) +} + +/// Execute an extraction plan: write files and config to the output directory. +pub fn execute_extraction(plan: &ExtractionPlan, _in_place: bool) -> Result<()> { + let output_dir = &plan.output_dir; + let template_dir = output_dir.join("template"); + + // Create output structure + std::fs::create_dir_all(&template_dir).map_err(|e| DicecutError::Io { + context: format!("creating template directory {}", template_dir.display()), + source: e, + })?; + + // Write template files + let mut rendered_count = 0; + let mut copied_count = 0; + let mut stubbed_count = 0; + + for file in &plan.files { + let dest = template_dir.join(&file.template_path); + + // Ensure parent directory exists + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| DicecutError::Io { + context: format!("creating directory {}", parent.display()), + source: e, + })?; + } + + match &file.content { + ExtractedContent::Text { + content, + replacement_count, + } => { + std::fs::write(&dest, content).map_err(|e| DicecutError::Io { + context: format!("writing file {}", dest.display()), + source: e, + })?; + if *replacement_count > 0 { + rendered_count += 1; + } else if file.stubbed { + stubbed_count += 1; + } else { + copied_count += 1; + } + } + ExtractedContent::Binary(bytes) => { + std::fs::write(&dest, bytes).map_err(|e| DicecutError::Io { + context: format!("writing binary file {}", dest.display()), + source: e, + })?; + copied_count += 1; + } + } + } + + // Write diecut.toml + let config_path = output_dir.join("diecut.toml"); + std::fs::write(&config_path, &plan.config_toml).map_err(|e| DicecutError::Io { + context: format!("writing {}", config_path.display()), + source: e, + })?; + + // Summary + let prompted_count = plan.variables.len(); + let computed_count = plan + .variables + .iter() + .flat_map(|v| &v.variants) + .filter(|variant| { + variant.name != "verbatim" + && !matches!( + ( + variant.name, + detect_separator( + plan.variables + .iter() + .find(|v2| v2.variants.contains(variant)) + .map(|v2| v2.value.as_str()) + .unwrap_or("") + ) + ), + ("kebab", "-") | ("snake", "_") | ("dot", ".") + ) + }) + .count(); + + eprintln!( + "\n{} Template extracted to {}", + style("✓").green().bold(), + style(output_dir.display()).cyan() + ); + eprintln!( + " {} variables ({} prompted, {} computed)", + prompted_count + computed_count, + prompted_count, + computed_count + ); + eprintln!( + " {} files templated, {} files copied, {} files stubbed, {} files dropped", + rendered_count, copied_count, stubbed_count, plan.dropped_count + ); + if !plan.conditional_entries.is_empty() { + eprintln!( + " {} conditional patterns added", + plan.conditional_entries.len() + ); + } + eprintln!(" Review diecut.toml to fine-tune"); + + Ok(()) +} diff --git a/src/extract/replace.rs b/src/extract/replace.rs new file mode 100644 index 0000000..95e36ad --- /dev/null +++ b/src/extract/replace.rs @@ -0,0 +1,251 @@ +use std::path::{Path, PathBuf}; + +/// A single replacement rule: find `literal` and replace with `replacement`. +#[derive(Debug, Clone)] +pub struct ReplacementRule { + pub literal: String, + pub replacement: String, + /// Which variable this rule belongs to (for reporting). + pub variable: String, + /// Which variant this rule belongs to (for reporting). + pub variant: String, +} + +/// Build replacement rules from all variables and their confirmed variants. +/// +/// Rules are sorted by descending literal length so that longest matches apply first. +/// This prevents shorter overlapping matches from corrupting longer ones. +pub fn build_replacement_rules(rules: &mut [ReplacementRule]) { + rules.sort_by(|a, b| b.literal.len().cmp(&a.literal.len())); +} + +/// Whether a character is "word-like" for the purpose of boundary detection. +/// +/// Alphanumeric, underscore, and hyphen are all considered word characters +/// because they appear as separators in identifiers (kebab-case, snake_case). +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '-' +} + +/// Apply replacement rules to a string, longest-match-first, in a single pass. +/// +/// All match positions are identified first against the original text, then +/// applied in one pass so that replacement output is never re-scanned by later +/// rules. Uses word-boundary-aware matching to prevent replacing substrings +/// inside longer words (e.g., "app" inside "application"). +/// +/// Returns the modified string and the number of replacements made. +pub fn apply_replacements(content: &str, rules: &[ReplacementRule]) -> (String, usize) { + if rules.is_empty() { + return (content.to_string(), 0); + } + + // Collect all (start, end, replacement_index) matches across all rules. + let mut matches: Vec<(usize, usize, usize)> = Vec::new(); + + for (rule_idx, rule) in rules.iter().enumerate() { + if rule.literal.is_empty() { + continue; + } + let literal = &rule.literal; + let literal_len = literal.len(); + let text_len = content.len(); + + if text_len < literal_len { + continue; + } + + let mut start = 0; + while start <= text_len - literal_len { + match content[start..].find(literal) { + Some(pos) => { + let match_start = start + pos; + let match_end = match_start + literal_len; + + let ok_before = match_start == 0 + || !is_word_char(content[..match_start].chars().next_back().unwrap()); + let ok_after = match_end == text_len + || !is_word_char(content[match_end..].chars().next().unwrap()); + + if ok_before && ok_after { + matches.push((match_start, match_end, rule_idx)); + } + + let next = match_start + + content[match_start..] + .char_indices() + .nth(1) + .map(|(i, _)| i) + .unwrap_or(1); + start = next; + } + None => break, + } + } + } + + if matches.is_empty() { + return (content.to_string(), 0); + } + + // Sort by start position; on tie, prefer the longer match (lower rule index + // already means longer literal due to build_replacement_rules sorting). + matches.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1))); + + // Greedily select non-overlapping matches. + let mut result = String::with_capacity(content.len()); + let mut total_count = 0; + let mut cursor = 0; + + for (m_start, m_end, rule_idx) in &matches { + if *m_start < cursor { + continue; // overlaps with a previously accepted match + } + result.push_str(&content[cursor..*m_start]); + result.push_str(&rules[*rule_idx].replacement); + total_count += 1; + cursor = *m_end; + } + result.push_str(&content[cursor..]); + + (result, total_count) +} + +/// Apply replacement rules to path components. +/// +/// Returns the new path with template expressions in directory and file names. +pub fn apply_path_replacements(path: &Path, rules: &[ReplacementRule]) -> PathBuf { + let mut components = Vec::new(); + + for component in path.components() { + match component { + std::path::Component::Normal(os_str) => { + let s = os_str.to_string_lossy(); + let (replaced, _) = apply_replacements(&s, rules); + components.push(replaced); + } + other => { + components.push(other.as_os_str().to_string_lossy().into_owned()); + } + } + } + + components.iter().collect() +} + +/// Count occurrences of a literal in a string. +pub fn count_occurrences(content: &str, literal: &str) -> usize { + if literal.is_empty() { + return 0; + } + content.matches(literal).count() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_rule(literal: &str, replacement: &str) -> ReplacementRule { + ReplacementRule { + literal: literal.to_string(), + replacement: replacement.to_string(), + variable: "test".to_string(), + variant: "test".to_string(), + } + } + + #[test] + fn test_apply_replacements_basic() { + let rules = vec![make_rule("my-app", "{{ project_name }}")]; + let (result, count) = apply_replacements("Welcome to my-app!", &rules); + assert_eq!(result, "Welcome to {{ project_name }}!"); + assert_eq!(count, 1); + } + + #[test] + fn test_apply_replacements_multiple() { + let rules = vec![make_rule("my-app", "{{ project_name }}")]; + let (result, count) = apply_replacements("my-app is great, use my-app", &rules); + assert_eq!( + result, + "{{ project_name }} is great, use {{ project_name }}" + ); + assert_eq!(count, 2); + } + + #[test] + fn test_longest_match_first() { + let mut rules = vec![ + make_rule("my", "{{ org }}"), + make_rule("my-app", "{{ project_name }}"), + ]; + build_replacement_rules(&mut rules); + + // "my-app" should match before "my" + assert_eq!(rules[0].literal, "my-app"); + assert_eq!(rules[1].literal, "my"); + } + + #[test] + fn test_apply_replacements_empty_rules() { + let (result, count) = apply_replacements("hello world", &[]); + assert_eq!(result, "hello world"); + assert_eq!(count, 0); + } + + #[test] + fn test_apply_path_replacements() { + let rules = vec![make_rule("my-app", "{{ project_name }}")]; + let path = Path::new("my-app/src/main.rs"); + let result = apply_path_replacements(path, &rules); + assert_eq!(result, PathBuf::from("{{ project_name }}/src/main.rs")); + } + + #[test] + fn test_count_occurrences() { + assert_eq!(count_occurrences("my-app and my-app", "my-app"), 2); + assert_eq!(count_occurrences("hello world", "missing"), 0); + assert_eq!(count_occurrences("anything", ""), 0); + } + + #[test] + fn test_no_substring_collision_suffix() { + let rules = vec![make_rule("app", "{{ name }}")]; + let (result, count) = apply_replacements("application startup", &rules); + assert_eq!(result, "application startup"); + assert_eq!(count, 0); + } + + #[test] + fn test_no_substring_collision_prefix() { + let rules = vec![make_rule("app", "{{ name }}")]; + let (result, count) = apply_replacements("webapp is cool", &rules); + assert_eq!(result, "webapp is cool"); + assert_eq!(count, 0); + } + + #[test] + fn test_standalone_match_with_punctuation() { + let rules = vec![make_rule("app", "{{ name }}")]; + let (result, count) = apply_replacements("run app. start app!", &rules); + assert_eq!(result, "run {{ name }}. start {{ name }}!"); + assert_eq!(count, 2); + } + + #[test] + fn test_match_at_string_boundaries() { + let rules = vec![make_rule("app", "{{ name }}")]; + let (result, count) = apply_replacements("app", &rules); + assert_eq!(result, "{{ name }}"); + assert_eq!(count, 1); + } + + #[test] + fn test_compound_literal_still_matches() { + // Multi-word literals like "my-app" should still match inside strings + let rules = vec![make_rule("my-app", "{{ name }}")]; + let (result, count) = apply_replacements("name = \"my-app\"", &rules); + assert_eq!(result, "name = \"{{ name }}\""); + assert_eq!(count, 1); + } +} diff --git a/src/extract/scan.rs b/src/extract/scan.rs new file mode 100644 index 0000000..544aa87 --- /dev/null +++ b/src/extract/scan.rs @@ -0,0 +1,182 @@ +use std::path::{Path, PathBuf}; + +use walkdir::WalkDir; + +use super::exclude::should_exclude; +use crate::render::file::is_binary_file; + +/// A scanned file from the project directory. +#[derive(Debug, Clone)] +pub struct ScannedFile { + /// Path relative to the project root. + pub relative_path: PathBuf, + /// Absolute path on disk. + pub absolute_path: PathBuf, + /// Whether the file is binary. + pub is_binary: bool, + /// File content (only loaded for text files). + pub content: Option, +} + +/// Result of scanning a project directory. +#[derive(Debug)] +pub struct ScanResult { + pub files: Vec, + pub excluded_count: usize, +} + +/// Scan a project directory, applying exclude patterns. +/// +/// Returns all non-excluded files with their content loaded (for text files). +pub fn scan_project(project_dir: &Path, excludes: &[String]) -> crate::error::Result { + let project_dir = project_dir + .canonicalize() + .map_err(|e| crate::error::DicecutError::Io { + context: format!("canonicalizing project directory {}", project_dir.display()), + source: e, + })?; + + let mut files = Vec::new(); + let mut excluded_count = 0; + + for entry in WalkDir::new(&project_dir).min_depth(1) { + let entry = entry.map_err(|e| crate::error::DicecutError::Io { + context: format!("walking project directory: {}", e), + source: e + .into_io_error() + .unwrap_or_else(|| std::io::Error::other("walkdir error")), + })?; + + // Skip directories (including symlinks to directories, e.g. pnpm's + // node_modules/.pnpm uses symlinks that point to directories). + if entry.file_type().is_dir() { + continue; + } + if entry.path_is_symlink() && entry.path().is_dir() { + continue; + } + + let relative_path = entry + .path() + .strip_prefix(&project_dir) + .unwrap_or(entry.path()) + .to_path_buf(); + + if should_exclude(&relative_path, excludes) { + excluded_count += 1; + continue; + } + + let absolute_path = entry.path().to_path_buf(); + + let (is_binary, content) = if is_binary_file(&absolute_path) { + (true, None) + } else { + match std::fs::read_to_string(&absolute_path) { + Ok(s) => (false, Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::InvalidData => (true, None), + Err(e) => { + return Err(crate::error::DicecutError::Io { + context: format!("reading file {}", absolute_path.display()), + source: e, + }); + } + } + }; + + files.push(ScannedFile { + relative_path, + absolute_path, + is_binary, + content, + }); + } + + Ok(ScanResult { + files, + excluded_count, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scan_project_basic() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("README.md"), "# Hello").unwrap(); + std::fs::create_dir(dir.path().join("src")).unwrap(); + std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap(); + + let result = scan_project(dir.path(), &[]).unwrap(); + assert_eq!(result.files.len(), 2); + assert_eq!(result.excluded_count, 0); + } + + #[test] + fn test_scan_project_with_excludes() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("README.md"), "# Hello").unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + std::fs::write(dir.path().join(".git/config"), "").unwrap(); + + let excludes = vec![".git".to_string()]; + let result = scan_project(dir.path(), &excludes).unwrap(); + assert_eq!(result.files.len(), 1); + assert_eq!(result.excluded_count, 1); + assert_eq!(result.files[0].relative_path, PathBuf::from("README.md")); + } + + #[cfg(unix)] + #[test] + fn test_scan_project_skips_symlinks_to_directories() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("real.txt"), "hello").unwrap(); + + // Create a subdirectory and a symlink pointing to it + let subdir = dir.path().join("subdir"); + std::fs::create_dir(&subdir).unwrap(); + std::fs::write(subdir.join("nested.txt"), "nested").unwrap(); + std::os::unix::fs::symlink(&subdir, dir.path().join("link-to-dir")).unwrap(); + + let result = scan_project(dir.path(), &[]).unwrap(); + // Should find real.txt and subdir/nested.txt, but NOT choke on link-to-dir + let paths: Vec = result + .files + .iter() + .map(|f| f.relative_path.to_string_lossy().to_string()) + .collect(); + assert!(paths.contains(&"real.txt".to_string())); + assert!(paths.contains(&"subdir/nested.txt".to_string())); + assert!(!paths.iter().any(|p| p.contains("link-to-dir"))); + } + + #[test] + fn test_scan_project_binary_detection() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("text.txt"), "hello").unwrap(); + std::fs::write( + dir.path().join("binary.bin"), + &(0..256).map(|i| i as u8).collect::>(), + ) + .unwrap(); + + let result = scan_project(dir.path(), &[]).unwrap(); + let text_file = result + .files + .iter() + .find(|f| f.relative_path.to_string_lossy() == "text.txt") + .unwrap(); + let binary_file = result + .files + .iter() + .find(|f| f.relative_path.to_string_lossy() == "binary.bin") + .unwrap(); + + assert!(!text_file.is_binary); + assert!(text_file.content.is_some()); + assert!(binary_file.is_binary); + assert!(binary_file.content.is_none()); + } +} diff --git a/src/extract/stub.rs b/src/extract/stub.rs new file mode 100644 index 0000000..8c6ce47 --- /dev/null +++ b/src/extract/stub.rs @@ -0,0 +1,222 @@ +use std::path::Path; + +/// Whether a file is boilerplate (copy in full), content (stub), or too deep (drop). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileRole { + /// Config, dotfiles, CI — copy verbatim into the template. + Boilerplate, + /// Prose, docs, source — stub to minimal placeholder. + Content, + /// Content deeper than stub_depth — drop entirely. + Dropped, +} + +/// Filenames (case-insensitive) that are always boilerplate. +const BOILERPLATE_FILENAMES: &[&str] = &[ + ".gitignore", + ".gitattributes", + ".editorconfig", + ".prettierrc", + ".npmrc", + ".nvmrc", + ".gitkeep", + "makefile", + "dockerfile", + "justfile", + "license", + "licence", + "procfile", +]; + +/// Extensions (case-insensitive, without dot) that are always boilerplate. +const BOILERPLATE_EXTENSIONS: &[&str] = &[ + "toml", "yaml", "yml", "json", "jsonc", "json5", "xml", "sh", "bash", "zsh", "bat", "cmd", + "ps1", "cfg", "ini", "conf", +]; + +/// Directory prefixes — files under these dirs are boilerplate. +const BOILERPLATE_DIR_PREFIXES: &[&str] = &[".github/", ".gitlab/", ".circleci/", ".vscode/"]; + +/// Classify a file as boilerplate, content, or dropped based on its relative path. +/// +/// Only called for text files with 0 template replacements. +/// Files deeper than `stub_depth` path components are dropped entirely. +pub fn classify_file(path: &Path, stub_depth: usize) -> FileRole { + let path_str = path.to_string_lossy(); + + // Check directory prefix + for prefix in BOILERPLATE_DIR_PREFIXES { + if path_str.starts_with(prefix) { + return FileRole::Boilerplate; + } + } + + // Check filename (case-insensitive) + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { + let lower = filename.to_lowercase(); + if BOILERPLATE_FILENAMES.contains(&lower.as_str()) { + return FileRole::Boilerplate; + } + } + + // Check extension (case-insensitive) + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + let lower = ext.to_lowercase(); + if BOILERPLATE_EXTENSIONS.contains(&lower.as_str()) { + return FileRole::Boilerplate; + } + } + + let depth = path.components().count(); + if depth > stub_depth { + FileRole::Dropped + } else { + FileRole::Content + } +} + +/// Generate a minimal stub for a content file. +/// +/// - `.md` files get `# {Title}\n` where Title is derived from the filename. +/// - Everything else gets an empty string. +pub fn generate_stub(path: &Path) -> String { + let is_md = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("md")); + + if is_md { + let title = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled"); + // Title-case: capitalize first letter, leave rest as-is + let title = title_case(title); + format!("# {title}\n") + } else { + String::new() + } +} + +/// Convert a filename stem like "craft" or "SKILL" into title case. +/// +/// Splits on `-` and `_`, capitalizes each word's first letter. +fn title_case(s: &str) -> String { + s.split(['-', '_']) + .filter(|w| !w.is_empty()) + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(first) => { + let rest: String = chars.collect::().to_lowercase(); + format!("{}{rest}", first.to_uppercase()) + } + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + // ── classify_file ──────────────────────────────────────────────── + + #[rstest] + #[case(".gitignore", FileRole::Boilerplate)] + #[case(".editorconfig", FileRole::Boilerplate)] + #[case("Makefile", FileRole::Boilerplate)] + #[case("Dockerfile", FileRole::Boilerplate)] + #[case("LICENSE", FileRole::Boilerplate)] + #[case("Procfile", FileRole::Boilerplate)] + fn classify_boilerplate_filenames(#[case] filename: &str, #[case] expected: FileRole) { + assert_eq!(classify_file(Path::new(filename), 2), expected); + } + + #[rstest] + #[case("Cargo.toml", FileRole::Boilerplate)] + #[case("config.yaml", FileRole::Boilerplate)] + #[case("settings.yml", FileRole::Boilerplate)] + #[case("package.json", FileRole::Boilerplate)] + #[case("tsconfig.json", FileRole::Boilerplate)] + #[case("setup.cfg", FileRole::Boilerplate)] + #[case("build.sh", FileRole::Boilerplate)] + #[case("deploy.ps1", FileRole::Boilerplate)] + #[case("app.conf", FileRole::Boilerplate)] + fn classify_boilerplate_extensions(#[case] filename: &str, #[case] expected: FileRole) { + assert_eq!(classify_file(Path::new(filename), 2), expected); + } + + #[rstest] + #[case(".github/workflows/ci.yml", FileRole::Boilerplate)] + #[case(".github/CODEOWNERS", FileRole::Boilerplate)] + #[case(".gitlab/ci/deploy.yml", FileRole::Boilerplate)] + #[case(".circleci/config.yml", FileRole::Boilerplate)] + #[case(".vscode/settings.json", FileRole::Boilerplate)] + fn classify_boilerplate_directories(#[case] path: &str, #[case] expected: FileRole) { + assert_eq!(classify_file(Path::new(path), 2), expected); + } + + #[rstest] + #[case("README.md", 2)] + #[case("docs/guide.md", 2)] + #[case("src/main.rs", 2)] + #[case("src/lib.py", 2)] + #[case("index.html", 2)] + #[case("app.css", 2)] + #[case("skills/convention-mining/SKILL.md", 3)] // depth 3, stub_depth 3 → Content + fn classify_content(#[case] path: &str, #[case] stub_depth: usize) { + assert_eq!( + classify_file(Path::new(path), stub_depth), + FileRole::Content + ); + } + + #[rstest] + #[case("skills/convention-mining/SKILL.md", 2)] // depth 3 > stub_depth 2 + #[case("skills/writing-skills/craft.md", 2)] // depth 3 > stub_depth 2 + #[case("a/b/c/deep.md", 2)] // depth 4 > stub_depth 2 + #[case("docs/guide.md", 1)] // depth 2 > stub_depth 1 + fn classify_dropped(#[case] path: &str, #[case] stub_depth: usize) { + assert_eq!( + classify_file(Path::new(path), stub_depth), + FileRole::Dropped + ); + } + + // ── generate_stub ──────────────────────────────────────────────── + + #[rstest] + #[case("README.md", "# Readme\n")] + #[case("craft.md", "# Craft\n")] + #[case("SKILL.md", "# Skill\n")] + #[case("getting-started.md", "# Getting Started\n")] + #[case("my_notes.md", "# My Notes\n")] + fn stub_md_files(#[case] filename: &str, #[case] expected: &str) { + assert_eq!(generate_stub(Path::new(filename)), expected); + } + + #[rstest] + #[case("src/main.rs")] + #[case("index.html")] + #[case("app.css")] + #[case("data.txt")] + fn stub_non_md_files(#[case] filename: &str) { + assert_eq!(generate_stub(Path::new(filename)), ""); + } + + // ── title_case ─────────────────────────────────────────────────── + + #[rstest] + #[case("craft", "Craft")] + #[case("SKILL", "Skill")] + #[case("getting-started", "Getting Started")] + #[case("my_notes", "My Notes")] + #[case("README", "Readme")] + fn test_title_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(title_case(input), expected); + } +} diff --git a/src/extract/variants.rs b/src/extract/variants.rs new file mode 100644 index 0000000..525b475 --- /dev/null +++ b/src/extract/variants.rs @@ -0,0 +1,309 @@ +use std::sync::LazyLock; + +use regex_lite::Regex; + +static CAMEL_SPLIT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"[A-Z][a-z]*|[a-z]+|[0-9]+").unwrap()); + +/// A case variant of a variable value, with its literal text and Tera expression. +#[derive(Debug, Clone, PartialEq)] +pub struct CaseVariant { + pub name: &'static str, + pub literal: String, + pub tera_expr: String, +} + +/// Split a string value into words for case variant generation. +/// +/// Handles kebab-case, snake_case, camelCase, PascalCase, dot.case, and space-separated. +pub fn split_into_words(value: &str) -> Vec { + if value.contains('-') { + return value.split('-').map(|s| s.to_lowercase()).collect(); + } + if value.contains('_') { + return value.split('_').map(|s| s.to_lowercase()).collect(); + } + if value.contains('.') { + return value.split('.').map(|s| s.to_lowercase()).collect(); + } + if value.contains(' ') { + return value.split_whitespace().map(|s| s.to_lowercase()).collect(); + } + + // camelCase / PascalCase splitting + let words: Vec = CAMEL_SPLIT_RE + .find_iter(value) + .map(|m| m.as_str().to_lowercase()) + .collect(); + + if words.is_empty() { + vec![value.to_lowercase()] + } else { + words + } +} + +/// Detect if a value is "multi-word" in a way that supports case variants. +/// +/// Single words and space-separated phrases skip variant detection. +fn supports_case_variants(value: &str) -> bool { + let words = split_into_words(value); + if words.len() < 2 { + return false; + } + // Space-separated values (like author names) skip variant detection + if value.contains(' ') { + return false; + } + true +} + +fn to_kebab(words: &[String]) -> String { + words.join("-") +} + +fn to_snake(words: &[String]) -> String { + words.join("_") +} + +fn to_screaming_snake(words: &[String]) -> String { + words + .iter() + .map(|w| w.to_uppercase()) + .collect::>() + .join("_") +} + +fn to_screaming_kebab(words: &[String]) -> String { + words + .iter() + .map(|w| w.to_uppercase()) + .collect::>() + .join("-") +} + +fn to_pascal(words: &[String]) -> String { + words + .iter() + .map(|w| { + let mut chars = w.chars(); + match chars.next() { + Some(c) => { + let upper: String = c.to_uppercase().collect(); + upper + chars.as_str() + } + None => String::new(), + } + }) + .collect() +} + +fn to_camel(words: &[String]) -> String { + let pascal = to_pascal(words); + let mut chars = pascal.chars(); + match chars.next() { + Some(c) => { + let lower: String = c.to_lowercase().collect(); + lower + chars.as_str() + } + None => String::new(), + } +} + +fn to_dot(words: &[String]) -> String { + words.join(".") +} + +/// Detect the canonical separator in the original value. +pub fn detect_separator(value: &str) -> &'static str { + if value.contains('-') { + "-" + } else if value.contains('_') { + "_" + } else if value.contains('.') { + "." + } else { + // PascalCase/camelCase — treat as kebab canonical + "-" + } +} + +/// Check whether a variant is the canonical one (matches the input separator). +/// +/// Canonical variants use the bare `{{ var_name }}` expression and do not get +/// a computed variable in diecut.toml. +pub fn is_canonical_variant(variant_name: &str, canonical_sep: &str) -> bool { + matches!( + (variant_name, canonical_sep), + ("kebab", "-") | ("snake", "_") | ("dot", ".") + ) +} + +/// Build a Tera expression for a variant, given the variable name and canonical separator. +/// +/// Canonical variants use `{{ var_name }}` directly. Non-canonical variants reference +/// their computed variable (e.g., `{{ var_name_snake }}`), which is defined in diecut.toml. +fn tera_expr_for_variant(var_name: &str, variant_name: &str, canonical_sep: &str) -> String { + if variant_name == "verbatim" || is_canonical_variant(variant_name, canonical_sep) { + return format!("{{{{ {var_name} }}}}"); + } + // Non-canonical variants reference their computed variable name + format!("{{{{ {var_name}_{variant_name} }}}}") +} + +/// Generate all case variants for a given variable value. +/// +/// Returns the canonical variant first, followed by alternatives. +/// Only returns variants whose literal differs from the canonical form. +/// Single-word values and space-separated phrases return only a verbatim replacement. +pub fn generate_variants(var_name: &str, value: &str) -> Vec { + if !supports_case_variants(value) { + return vec![CaseVariant { + name: "verbatim", + literal: value.to_string(), + tera_expr: format!("{{{{ {var_name} }}}}"), + }]; + } + + let words = split_into_words(value); + let canonical_sep = detect_separator(value); + + let candidates: Vec<(&str, String)> = vec![ + ("kebab", to_kebab(&words)), + ("snake", to_snake(&words)), + ("screaming_snake", to_screaming_snake(&words)), + ("screaming_kebab", to_screaming_kebab(&words)), + ("pascal", to_pascal(&words)), + ("camel", to_camel(&words)), + ("dot", to_dot(&words)), + ]; + + // Deduplicate: some variants produce the same literal (e.g., single-word) + let mut seen = std::collections::HashSet::new(); + let mut variants = Vec::new(); + + for (name, literal) in candidates { + if seen.insert(literal.clone()) { + let tera_expr = tera_expr_for_variant(var_name, name, canonical_sep); + variants.push(CaseVariant { + name, + literal, + tera_expr, + }); + } + } + + variants +} + +/// Build a computed Tera expression for a named variant variable. +/// +/// This is used in diecut.toml for computed variables like `project_name_snake`. +pub fn computed_expression(var_name: &str, variant_name: &str, canonical_sep: &str) -> String { + match (variant_name, canonical_sep) { + ("snake", sep) if sep != "_" => { + format!("{var_name} | replace(from=\"{sep}\", to=\"_\")") + } + ("screaming_snake", sep) => { + if sep == "_" { + format!("{var_name} | upper") + } else { + format!("{var_name} | replace(from=\"{sep}\", to=\"_\") | upper") + } + } + ("screaming_kebab", sep) => { + if sep == "-" { + format!("{var_name} | upper") + } else { + format!("{var_name} | replace(from=\"{sep}\", to=\"-\") | upper") + } + } + ("pascal", sep) => { + format!("{var_name} | replace(from=\"{sep}\", to=\" \") | title | replace(from=\" \", to=\"\")") + } + ("camel", sep) => { + format!("{var_name} | camelcase(sep=\"{sep}\")") + } + ("kebab", sep) if sep != "-" => { + format!("{var_name} | replace(from=\"{sep}\", to=\"-\")") + } + ("dot", sep) if sep != "." => { + format!("{var_name} | replace(from=\"{sep}\", to=\".\")") + } + _ => var_name.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("my-app", vec!["my", "app"])] + #[case("my_app", vec!["my", "app"])] + #[case("MyApp", vec!["my", "app"])] + #[case("myApp", vec!["my", "app"])] + #[case("my.app", vec!["my", "app"])] + #[case("my app", vec!["my", "app"])] + #[case("single", vec!["single"])] + fn test_split_into_words(#[case] input: &str, #[case] expected: Vec<&str>) { + assert_eq!(split_into_words(input), expected); + } + + #[test] + fn test_generate_variants_kebab() { + let variants = generate_variants("project_name", "my-app"); + let names: Vec<&str> = variants.iter().map(|v| v.name).collect(); + assert!(names.contains(&"kebab")); + assert!(names.contains(&"snake")); + assert!(names.contains(&"pascal")); + + let kebab = variants.iter().find(|v| v.name == "kebab").unwrap(); + assert_eq!(kebab.literal, "my-app"); + + let snake = variants.iter().find(|v| v.name == "snake").unwrap(); + assert_eq!(snake.literal, "my_app"); + + let pascal = variants.iter().find(|v| v.name == "pascal").unwrap(); + assert_eq!(pascal.literal, "MyApp"); + } + + #[test] + fn test_generate_variants_single_word() { + let variants = generate_variants("name", "hello"); + assert_eq!(variants.len(), 1); + assert_eq!(variants[0].name, "verbatim"); + assert_eq!(variants[0].literal, "hello"); + } + + #[test] + fn test_generate_variants_space_separated() { + let variants = generate_variants("author", "Jane Doe"); + assert_eq!(variants.len(), 1); + assert_eq!(variants[0].name, "verbatim"); + assert_eq!(variants[0].literal, "Jane Doe"); + } + + #[test] + fn test_generate_variants_screaming_snake() { + let variants = generate_variants("project_name", "my-app"); + let ss = variants + .iter() + .find(|v| v.name == "screaming_snake") + .unwrap(); + assert_eq!(ss.literal, "MY_APP"); + } + + #[test] + fn test_tera_expr_kebab_canonical() { + let expr = tera_expr_for_variant("project_name", "kebab", "-"); + assert_eq!(expr, "{{ project_name }}"); + } + + #[test] + fn test_tera_expr_snake_from_kebab() { + let expr = tera_expr_for_variant("project_name", "snake", "-"); + assert_eq!(expr, "{{ project_name_snake }}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index a57e60c..4091828 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod adapter; pub mod answers; pub mod config; pub mod error; +pub mod extract; pub mod hooks; pub mod prompt; pub mod render; diff --git a/src/main.rs b/src/main.rs index 20cf462..11dec94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,5 +19,24 @@ fn main() -> miette::Result<()> { template, output, data, defaults, overwrite, no_hooks, dry_run, verbose, ), Commands::List => commands::list::run(), + Commands::Extract { + source, + vars, + output, + in_place, + yes, + min_confidence, + stub_depth, + dry_run, + } => commands::extract::run( + source, + vars, + output, + in_place, + yes, + min_confidence, + stub_depth, + dry_run, + ), } } diff --git a/src/prompt/engine.rs b/src/prompt/engine.rs index 4de7253..47fc847 100644 --- a/src/prompt/engine.rs +++ b/src/prompt/engine.rs @@ -96,7 +96,7 @@ fn evaluate_computed( computed_expr: &str, values: &BTreeMap, ) -> Result { - let mut tera = tera::Tera::default(); + let mut tera = crate::render::tera_with_filters(); tera.add_raw_template("__computed__", computed_expr) .map_err(|e| DicecutError::ComputedEvaluation { name: name.to_string(), diff --git a/src/render/context.rs b/src/render/context.rs index f29f678..f530022 100644 --- a/src/render/context.rs +++ b/src/render/context.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use tera::{Context, Tera, Value}; @@ -10,14 +10,85 @@ pub fn build_context(variables: &BTreeMap) -> Context { context } +/// Create a Tera instance with custom filters registered. +/// +/// This should be used instead of `Tera::default()` anywhere templates or +/// computed expressions are evaluated, so that custom filters like `camelcase` +/// are available. +pub fn tera_with_filters() -> Tera { + let mut tera = Tera::default(); + tera.register_filter("camelcase", camelcase_filter); + tera +} + +/// Custom Tera filter: convert a separated string to camelCase. +/// +/// Usage: `{{ value | camelcase }}` or `{{ value | camelcase(sep="-") }}` +/// +/// Splits on the separator (default `-`), lowercases the first word, +/// title-cases the rest, and joins them. +fn camelcase_filter(value: &Value, args: &HashMap) -> Result { + let s = value + .as_str() + .ok_or_else(|| tera::Error::msg("camelcase filter requires a string value"))?; + + let sep = args.get("sep").and_then(|v| v.as_str()).unwrap_or("-"); + + let words: Vec<&str> = s.split(sep).collect(); + if words.is_empty() { + return Ok(Value::String(String::new())); + } + + let mut result = words[0].to_lowercase(); + for word in &words[1..] { + let mut chars = word.chars(); + if let Some(first) = chars.next() { + result.extend(first.to_uppercase()); + result.push_str(&chars.as_str().to_lowercase()); + } + } + + Ok(Value::String(result)) +} + /// Evaluate a Tera boolean expression against a variable context. /// /// Returns `Ok(true)` if the expression evaluates to true, `Ok(false)` otherwise. /// Returns `Err` if the expression fails to parse or render. pub fn eval_bool_expr(expr: &str, context: &Context) -> std::result::Result { - let mut tera = Tera::default(); + let mut tera = tera_with_filters(); let template_str = format!("{{% if {expr} %}}true{{% else %}}false{{% endif %}}"); tera.add_raw_template("__when__", &template_str)?; let result = tera.render("__when__", context)?; Ok(result.trim() == "true") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_camelcase_filter_kebab() { + let val = Value::String("my-cool-app".to_string()); + let args = HashMap::new(); + let result = camelcase_filter(&val, &args).unwrap(); + assert_eq!(result, Value::String("myCoolApp".to_string())); + } + + #[test] + fn test_camelcase_filter_custom_sep() { + let val = Value::String("my_cool_app".to_string()); + let mut args = HashMap::new(); + args.insert("sep".to_string(), Value::String("_".to_string())); + let result = camelcase_filter(&val, &args).unwrap(); + assert_eq!(result, Value::String("myCoolApp".to_string())); + } + + #[test] + fn test_camelcase_filter_single_word() { + let val = Value::String("hello".to_string()); + let args = HashMap::new(); + let result = camelcase_filter(&val, &args).unwrap(); + assert_eq!(result, Value::String("hello".to_string())); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index 5674674..8a87f30 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -2,7 +2,7 @@ pub mod context; pub mod file; pub mod walker; -pub use context::{build_context, eval_bool_expr}; +pub use context::{build_context, eval_bool_expr, tera_with_filters}; pub use walker::{ execute_plan, plan_render, walk_and_render, GeneratedProject, GenerationPlan, PlannedFile, }; diff --git a/src/render/walker.rs b/src/render/walker.rs index caf9e26..97b1e96 100644 --- a/src/render/walker.rs +++ b/src/render/walker.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use globset::{Glob, GlobSet, GlobSetBuilder}; -use tera::{Context, Tera, Value}; +use tera::{Context, Value}; use walkdir::WalkDir; use crate::adapter::ResolvedTemplate; @@ -104,7 +104,7 @@ pub fn plan_render( source: e, })?; - let mut tera = Tera::default(); + let mut tera = crate::render::tera_with_filters(); let template_name = rel_str.to_string(); let parse_result = tera.add_raw_template(&template_name, &content); let render_result = parse_result.and_then(|_| tera.render(&template_name, context)); diff --git a/tests/integration.rs b/tests/integration.rs index 4005080..bee61fc 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use diecut::adapter; use diecut::config::load_config; +use diecut::extract::{execute_extraction, plan_extraction, ExtractOptions}; use diecut::prompt::PromptOptions; use diecut::render::{build_context, execute_plan, plan_render, walk_and_render}; use diecut::template::source::{resolve_source, resolve_source_full}; @@ -622,3 +623,485 @@ fn test_plan_generation_verbose_has_content() { "at least one rendered file should contain the resolved project name" ); } + +// ── Extract command tests ──────────────────────────────────────────────── + +#[test] +fn test_extract_batch_basic() { + // Create a simple project to extract from + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("README.md"), "# my-app\nBy Jane Doe\n").unwrap(); + std::fs::create_dir(project.path().join("src")).unwrap(); + std::fs::write( + project.path().join("src/main.rs"), + "fn main() {\n println!(\"Welcome to my-app!\");\n}\n", + ) + .unwrap(); + std::fs::write( + project.path().join("Cargo.toml"), + "[package]\nname = \"my-app\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().join("extracted"); + + let options = ExtractOptions { + source_dir: project.path().to_path_buf(), + variables: vec![ + ("project_name".to_string(), "my-app".to_string()), + ("author".to_string(), "Jane Doe".to_string()), + ], + output_dir: Some(output_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let plan = plan_extraction(&options).unwrap(); + execute_extraction(&plan, false).unwrap(); + + // Verify diecut.toml was created + assert!(output_path.join("diecut.toml").exists()); + let config_content = std::fs::read_to_string(output_path.join("diecut.toml")).unwrap(); + assert!(config_content.contains("[template]")); + assert!(config_content.contains("[variables.project_name]")); + assert!(config_content.contains("[variables.author]")); + + // Verify template directory structure + assert!(output_path.join("template").exists()); + + // Verify files with replacements got .die suffix + let template_dir = output_path.join("template"); + let has_die_files = walkdir::WalkDir::new(&template_dir) + .into_iter() + .filter_map(|e| e.ok()) + .any(|e| e.path().to_string_lossy().ends_with(".die")); + assert!(has_die_files, "should have files with .die suffix"); +} + +#[test] +fn test_extract_detects_case_variants() { + let project = tempfile::tempdir().unwrap(); + std::fs::write( + project.path().join("config.toml"), + "[package]\nname = \"my-app\"\nmodule = \"my_app\"\nclass = \"MyApp\"\nenv = \"MY_APP_PORT\"\n", + ) + .unwrap(); + + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().join("extracted"); + + let options = ExtractOptions { + source_dir: project.path().to_path_buf(), + variables: vec![("project_name".to_string(), "my-app".to_string())], + output_dir: Some(output_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let plan = plan_extraction(&options).unwrap(); + + // Should detect variants used in the file + let var = plan + .variables + .iter() + .find(|v| v.name == "project_name") + .unwrap(); + let variant_names: Vec<&str> = var.variants.iter().map(|v| v.name).collect(); + assert!( + variant_names.contains(&"kebab"), + "should detect kebab variant" + ); + assert!( + variant_names.contains(&"snake"), + "should detect snake variant" + ); + assert!( + variant_names.contains(&"pascal"), + "should detect pascal variant" + ); + assert!( + variant_names.contains(&"screaming_snake"), + "should detect screaming_snake variant" + ); + + execute_extraction(&plan, false).unwrap(); + + // The config should have computed variables for variants + let config = std::fs::read_to_string(output_path.join("diecut.toml")).unwrap(); + assert!( + config.contains("project_name_snake"), + "should have snake computed var" + ); +} + +#[test] +fn test_extract_dry_run_writes_nothing() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("hello.txt"), "hello my-app").unwrap(); + + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().join("dry-run-output"); + + let options = ExtractOptions { + source_dir: project.path().to_path_buf(), + variables: vec![("project_name".to_string(), "my-app".to_string())], + output_dir: Some(output_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: true, + }; + + let plan = plan_extraction(&options).unwrap(); + // Don't execute — just verify plan exists and no output written + assert!(!plan.files.is_empty()); + assert!(!plan.config_toml.is_empty()); + assert!( + !output_path.exists(), + "dry run should not create output directory" + ); +} + +#[test] +fn test_extract_rejects_already_template() { + let project = tempfile::tempdir().unwrap(); + std::fs::write( + project.path().join("diecut.toml"), + "[template]\nname = \"existing\"", + ) + .unwrap(); + + let options = ExtractOptions { + source_dir: project.path().to_path_buf(), + variables: vec![("name".to_string(), "val".to_string())], + output_dir: None, + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let result = plan_extraction(&options); + assert!(result.is_err()); +} + +#[test] +fn test_extract_rejects_no_variables() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("hello.txt"), "hello").unwrap(); + + // With min_confidence=1.0, no auto-detected candidates can pass, and no explicit + // vars are given, so extraction should fail with ExtractNoVariables + let options = ExtractOptions { + source_dir: project.path().to_path_buf(), + variables: vec![], + output_dir: None, + in_place: false, + yes: true, + min_confidence: 1.0, + stub_depth: 2, + dry_run: false, + }; + + let result = plan_extraction(&options); + assert!(result.is_err()); +} + +#[test] +fn test_extract_templates_path_components() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join("my-app")).unwrap(); + std::fs::write(project.path().join("my-app/main.rs"), "fn main() {}\n").unwrap(); + + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().join("extracted"); + + let options = ExtractOptions { + source_dir: project.path().to_path_buf(), + variables: vec![("project_name".to_string(), "my-app".to_string())], + output_dir: Some(output_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let plan = plan_extraction(&options).unwrap(); + + // Check that path components got templated + let has_templated_path = plan.files.iter().any(|f| { + f.template_path + .to_string_lossy() + .contains("{{ project_name }}") + }); + assert!( + has_templated_path, + "should template path components containing the variable value" + ); + + execute_extraction(&plan, false).unwrap(); +} + +#[test] +fn test_extract_round_trip() { + // Step 1: Generate a project from an existing template + let template_dir = fixture_path("basic-template"); + let resolved = adapter::resolve_template(&template_dir).unwrap(); + + let mut variables = BTreeMap::new(); + variables.insert( + "project_name".to_string(), + tera::Value::String("my-app".to_string()), + ); + variables.insert( + "author".to_string(), + tera::Value::String("Jane Doe".to_string()), + ); + variables.insert("use_docker".to_string(), tera::Value::Bool(false)); + variables.insert( + "license".to_string(), + tera::Value::String("MIT".to_string()), + ); + variables.insert( + "project_slug".to_string(), + tera::Value::String("my-app".to_string()), + ); + + let context = build_context(&variables); + let generated = tempfile::tempdir().unwrap(); + walk_and_render(&resolved, generated.path(), &variables, &context).unwrap(); + + // The generated project has files under generated/my-app/ + let project_dir = generated.path().join("my-app"); + assert!(project_dir.exists(), "generated project should exist"); + + // Step 2: Extract it back into a template + let extracted = tempfile::tempdir().unwrap(); + let extracted_path = extracted.path().join("extracted-template"); + + let options = ExtractOptions { + source_dir: project_dir.clone(), + variables: vec![("project_name".to_string(), "my-app".to_string())], + output_dir: Some(extracted_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let plan = plan_extraction(&options).unwrap(); + execute_extraction(&plan, false).unwrap(); + + // Verify the extracted template has the key structure + assert!(extracted_path.join("diecut.toml").exists()); + assert!(extracted_path.join("template").exists()); + + let config = std::fs::read_to_string(extracted_path.join("diecut.toml")).unwrap(); + assert!(config.contains("project_name")); + + // Verify template files exist and contain template syntax + let template_files: Vec<_> = walkdir::WalkDir::new(extracted_path.join("template")) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .collect(); + assert!(!template_files.is_empty(), "should have template files"); + + // Files with .die suffix should contain template expressions + for entry in &template_files { + if entry.path().to_string_lossy().ends_with(".die") { + let content = std::fs::read_to_string(entry.path()).unwrap(); + assert!( + content.contains("{{") || content.contains("{%"), + "file {} should contain template syntax, got: {}", + entry.path().display(), + content + ); + } + } +} + +// ── Auto-detect tests ──────────────────────────────────────────────────── + +#[test] +fn test_extract_auto_yes() { + let project = tempfile::tempdir().unwrap(); + let project_dir = project.path().join("data-pipeline"); + std::fs::create_dir(&project_dir).unwrap(); + std::fs::write( + project_dir.join("Cargo.toml"), + "[package]\nname = \"data-pipeline\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + std::fs::write( + project_dir.join("README.md"), + "# data-pipeline\nWelcome to data-pipeline\n", + ) + .unwrap(); + std::fs::create_dir(project_dir.join("src")).unwrap(); + std::fs::write( + project_dir.join("src/main.rs"), + "fn main() {\n println!(\"data-pipeline starting\");\n}\n", + ) + .unwrap(); + + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().join("auto-extracted"); + + let options = ExtractOptions { + source_dir: project_dir.clone(), + variables: vec![], + output_dir: Some(output_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let plan = plan_extraction(&options).unwrap(); + execute_extraction(&plan, false).unwrap(); + + let project_var = plan.variables.iter().find(|v| v.name == "project_name"); + assert!( + project_var.is_some(), + "should auto-detect project_name, got vars: {:?}", + plan.variables.iter().map(|v| &v.name).collect::>() + ); + assert_eq!(project_var.unwrap().value, "data-pipeline"); + + assert!(output_path.join("diecut.toml").exists()); + let config = std::fs::read_to_string(output_path.join("diecut.toml")).unwrap(); + assert!(config.contains("project_name")); +} + +#[test] +fn test_extract_auto_explicit_vars_merged() { + let project = tempfile::tempdir().unwrap(); + let project_dir = project.path().join("my-service"); + std::fs::create_dir(&project_dir).unwrap(); + std::fs::write( + project_dir.join("Cargo.toml"), + "[package]\nname = \"my-service\"\n", + ) + .unwrap(); + std::fs::write(project_dir.join("README.md"), "# my-service\n").unwrap(); + + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().join("explicit-extracted"); + + let options = ExtractOptions { + source_dir: project_dir.clone(), + variables: vec![("app_name".to_string(), "my-service".to_string())], + output_dir: Some(output_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let plan = plan_extraction(&options).unwrap(); + + let has_app_name = plan.variables.iter().any(|v| v.name == "app_name"); + assert!(has_app_name, "should use explicit var app_name"); + // Auto-detect still runs and merges additional candidates + // (project_name may or may not appear depending on dedup with app_name's value) +} + +#[test] +fn test_extract_auto_frequency_fallback() { + let project = tempfile::tempdir().unwrap(); + let project_dir = project.path().join("cool-widget"); + std::fs::create_dir(&project_dir).unwrap(); + std::fs::write( + project_dir.join("main.txt"), + "cool-widget is great\ncool_widget module\nCoolWidget class\n", + ) + .unwrap(); + std::fs::write( + project_dir.join("config.txt"), + "name = cool-widget\nmodule = cool_widget\n", + ) + .unwrap(); + std::fs::write( + project_dir.join("test.txt"), + "testing cool-widget\nCOOL_WIDGET env\n", + ) + .unwrap(); + + let output = tempfile::tempdir().unwrap(); + let output_path = output.path().join("freq-extracted"); + + let options = ExtractOptions { + source_dir: project_dir.clone(), + variables: vec![], + output_dir: Some(output_path.clone()), + in_place: false, + yes: true, + min_confidence: 0.5, + stub_depth: 2, + dry_run: false, + }; + + let plan = plan_extraction(&options).unwrap(); + + let has_relevant_var = plan + .variables + .iter() + .any(|v| v.value.contains("cool") || v.name.contains("cool")); + assert!( + has_relevant_var, + "should detect cool-widget related variable, got: {:?}", + plan.variables + .iter() + .map(|v| format!("{}={}", v.name, v.value)) + .collect::>() + ); +} + +#[test] +fn test_extract_min_confidence_filters() { + let project = tempfile::tempdir().unwrap(); + let project_dir = project.path().join("tiny-app"); + std::fs::create_dir(&project_dir).unwrap(); + std::fs::write( + project_dir.join("Cargo.toml"), + "[package]\nname = \"tiny-app\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + std::fs::write( + project_dir.join("README.md"), + "# tiny-app\nWelcome to tiny-app\n", + ) + .unwrap(); + + // With a very high threshold, all auto-detected candidates should be filtered out + let options = ExtractOptions { + source_dir: project_dir.clone(), + variables: vec![], + output_dir: None, + in_place: false, + yes: true, + min_confidence: 0.99, + stub_depth: 2, + dry_run: true, + }; + + let result = plan_extraction(&options); + assert!( + result.is_err(), + "high min_confidence should filter out all candidates" + ); +}