diff --git a/src/cli.rs b/src/cli.rs index ab84986..58594f4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,4 +49,30 @@ 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, + + /// File with exclude patterns (one per line, # comments) + #[arg(long, value_name = "FILE")] + exclude_from: Option, + + /// 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..5b6efc6 --- /dev/null +++ b/src/commands/extract.rs @@ -0,0 +1,93 @@ +use std::path::PathBuf; + +use console::style; + +use diecut::error::DicecutError; +use diecut::extract::{execute_extraction, plan_extraction, ExtractOptions}; +use miette::Result; + +pub fn run( + source: String, + vars: Vec, + output: Option, + in_place: bool, + exclude_from: Option, + 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, + exclude_file: exclude_from.map(PathBuf::from), + }; + + let plan = plan_extraction(&options)?; + + if dry_run { + print_dry_run(&plan); + return Ok(()); + } + + execute_extraction(&plan)?; + + 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 copied: Vec<_> = plan + .files + .iter() + .filter(|f| !f.has_replacements()) + .collect(); + + eprintln!("\nTemplated files ({}):", templated.len()); + for file in &templated { + eprintln!( + " {} ({} replacements)", + file.template_path.display(), + file.replacement_count() + ); + } + + eprintln!("\nCopied ({}):", copied.len()); + for file in &copied { + eprintln!(" {}", file.template_path.display()); + } + + eprintln!("\nVariables:"); + for var in &plan.variables { + eprintln!(" {} = {:?}", var.name, var.value); + } + + 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/default_excludes.txt b/src/extract/default_excludes.txt new file mode 100644 index 0000000..4219dd1 --- /dev/null +++ b/src/extract/default_excludes.txt @@ -0,0 +1,12 @@ +# Version control +.git + +# Rust +target +Cargo.lock + +# macOS +.DS_Store + +# Diecut +.diecut-answers.toml diff --git a/src/extract/exclude.rs b/src/extract/exclude.rs new file mode 100644 index 0000000..b9e7b55 --- /dev/null +++ b/src/extract/exclude.rs @@ -0,0 +1,82 @@ +use std::path::Path; + +const DEFAULT_EXCLUDES: &str = include_str!("default_excludes.txt"); + +/// Load exclude patterns from a file, or use the built-in defaults. +pub fn load_excludes(override_file: Option<&Path>) -> Vec { + let text = match override_file { + Some(path) => { + std::fs::read_to_string(path).unwrap_or_else(|_| DEFAULT_EXCLUDES.to_string()) + } + None => DEFAULT_EXCLUDES.to_string(), + }; + text.lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|l| l.to_string()) + .collect() +} + +/// 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 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; + } + + for component in relative_path.components() { + if let std::path::Component::Normal(os_str) = component { + if os_str.to_string_lossy() == clean { + return true; + } + } + } + + if path_str == clean || path_str.starts_with(&format!("{clean}/")) { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_defaults() { + let excludes = load_excludes(None); + assert!(excludes.contains(&".git".to_string())); + assert!(excludes.contains(&"target".to_string())); + assert!(excludes.contains(&".DS_Store".to_string())); + assert!(!excludes.iter().any(|e| e.starts_with('#'))); + } + + #[test] + fn test_should_exclude_matches() { + let excludes = vec![".git".to_string(), "*.pyc".to_string()]; + assert!(should_exclude(Path::new(".git/HEAD"), &excludes)); + assert!(should_exclude(Path::new("pkg/foo.pyc"), &excludes)); + assert!(!should_exclude(Path::new("src/main.rs"), &excludes)); + } + + #[test] + fn test_override_file() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("excludes.txt"); + std::fs::write(&file, "# custom\nvendor\n*.log\n").unwrap(); + + let excludes = load_excludes(Some(&file)); + assert_eq!(excludes, vec!["vendor", "*.log"]); + } +} diff --git a/src/extract/mod.rs b/src/extract/mod.rs new file mode 100644 index 0000000..094f27c --- /dev/null +++ b/src/extract/mod.rs @@ -0,0 +1,319 @@ +pub mod exclude; +pub mod replace; +pub mod scan; + +use std::path::{Path, PathBuf}; + +use console::style; + +use crate::config::schema::DEFAULT_TEMPLATES_SUFFIX; +use crate::error::{DicecutError, Result}; + +use self::exclude::load_excludes; +use self::replace::{ + apply_path_replacements, apply_replacements, build_replacement_rules, ReplacementRule, +}; +use self::scan::scan_project; + +/// A variable with its value. +#[derive(Debug, Clone)] +pub struct ExtractVariable { + pub name: String, + pub value: String, +} + +/// 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, +} + +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, +} + +/// 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 exclude_file: Option, +} + +/// Plan an extraction: scan the project, build replacement rules, apply replacements. +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(), + }); + } + + // Scan project + let scan_excludes = load_excludes(options.exclude_file.as_deref()); + eprintln!( + "\n{}", + style(format!("Scanning {}...", source_dir.display())).bold() + ); + let scan_result = scan_project(source_dir, &scan_excludes)?; + + eprintln!( + " {} files found, {} excluded", + scan_result.files.len(), + scan_result.excluded_count, + ); + + // Validate that at least one --var was provided + let variables = options.variables.clone(); + if variables.is_empty() { + return Err(DicecutError::ExtractNoVariables); + } + + // Build extract variables (verbatim only) + let extract_variables: Vec = variables + .iter() + .map(|(name, value)| ExtractVariable { + name: name.clone(), + value: value.clone(), + }) + .collect(); + + // Build replacement rules — one rule per variable, verbatim only + let mut rules: Vec = extract_variables + .iter() + .map(|var| ReplacementRule { + literal: var.value.clone(), + replacement: format!("{{{{ {} }}}}", var.name), + variable: var.name.clone(), + variant: "verbatim".to_string(), + }) + .collect(); + build_replacement_rules(&mut rules); + + // Apply replacements to files + let mut planned_files = 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), + }); + } else if let Some(ref content) = file.content { + 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, + }, + }); + } else { + // No replacements — copy verbatim + planned_files.push(PlannedExtractFile { + template_path, + content: ExtractedContent::Text { + content: replaced, + replacement_count: 0, + }, + }); + } + } + } + + // Generate minimal config TOML inline + let template_name = source_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "template".to_string()); + + let config_toml = generate_minimal_config(&template_name, &extract_variables); + + Ok(ExtractionPlan { + output_dir, + files: planned_files, + config_toml, + variables: extract_variables, + }) +} + +/// Execute an extraction plan: write files and config to the output directory. +pub fn execute_extraction(plan: &ExtractionPlan) -> 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; + + 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 { + 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 + eprintln!( + "\n{} Template extracted to {}", + style("✓").green().bold(), + style(output_dir.display()).cyan() + ); + eprintln!( + " {} variables, {} files templated, {} files copied", + plan.variables.len(), + rendered_count, + copied_count, + ); + eprintln!(" Review diecut.toml to fine-tune"); + + Ok(()) +} + +fn generate_minimal_config(template_name: &str, variables: &[ExtractVariable]) -> String { + let escape = |s: &str| toml::Value::String(s.to_string()).to_string(); + let mut out = String::new(); + + out.push_str(&format!("[template]\nname = {}\n", escape(template_name))); + out.push_str("version = \"1.0.0\"\n\n"); + + for var in variables { + out.push_str(&format!("[variables.{}]\n", var.name)); + out.push_str(&format!( + "type = \"string\"\nprompt = {}\n", + escape(&var.name.replace(['_', '-'], " ")) + )); + out.push_str(&format!("default = {}\n\n", escape(&var.value))); + } + + out.push_str("[files]\n# exclude = []\n# copy_without_render = []\n\n"); + out.push_str("# [hooks]\n# post_create = \"echo 'Project created!'\"\n"); + out +} diff --git a/src/extract/replace.rs b/src/extract/replace.rs new file mode 100644 index 0000000..1ea9092 --- /dev/null +++ b/src/extract/replace.rs @@ -0,0 +1,408 @@ +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() +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::path::Path; + + /// Helper to build a single rule with minimal boilerplate. + fn rule(literal: &str, replacement: &str) -> ReplacementRule { + ReplacementRule { + literal: literal.to_string(), + replacement: replacement.to_string(), + variable: "var".to_string(), + variant: "verbatim".to_string(), + } + } + + /// Helper to build and sort a rule set, ready for apply_replacements. + fn sorted(rules: Vec) -> Vec { + let mut rules = rules; + build_replacement_rules(&mut rules); + rules + } + + // ── is_word_char ────────────────────────────────────────────── + + #[rstest] + #[case('a', true)] + #[case('Z', true)] + #[case('0', true)] + #[case('_', true)] + #[case('-', true)] + #[case(' ', false)] + #[case('.', false)] + #[case('/', false)] + #[case('{', false)] + #[case('é', true)] // alphanumeric per char::is_alphanumeric + fn word_char(#[case] c: char, #[case] expected: bool) { + assert_eq!(is_word_char(c), expected, "is_word_char({c:?})"); + } + + // ── build_replacement_rules (sorting) ───────────────────────── + + #[test] + fn sorts_longest_literal_first() { + let mut rules = vec![ + rule("app", "{{ x }}"), + rule("my-app", "{{ y }}"), + rule("a", "{{ z }}"), + ]; + build_replacement_rules(&mut rules); + + let lengths: Vec = rules.iter().map(|r| r.literal.len()).collect(); + assert_eq!(lengths, vec![6, 3, 1]); + } + + // ── apply_replacements: basic ───────────────────────────────── + + #[test] + fn no_rules_returns_original() { + let (out, count) = apply_replacements("hello world", &[]); + assert_eq!(out, "hello world"); + assert_eq!(count, 0); + } + + #[test] + fn no_match_returns_original() { + let rules = sorted(vec![rule("missing", "{{ x }}")]); + let (out, count) = apply_replacements("hello world", &rules); + assert_eq!(out, "hello world"); + assert_eq!(count, 0); + } + + #[test] + fn simple_replacement() { + let rules = sorted(vec![rule("my-app", "{{ project_name }}")]); + let (out, count) = apply_replacements("name = \"my-app\"", &rules); + assert_eq!(out, "name = \"{{ project_name }}\""); + assert_eq!(count, 1); + } + + #[test] + fn multiple_occurrences() { + let rules = sorted(vec![rule("foo", "{{ x }}")]); + let (out, count) = apply_replacements("foo and foo again foo", &rules); + assert_eq!(out, "{{ x }} and {{ x }} again {{ x }}"); + assert_eq!(count, 3); + } + + #[test] + fn empty_content() { + let rules = sorted(vec![rule("x", "{{ x }}")]); + let (out, count) = apply_replacements("", &rules); + assert_eq!(out, ""); + assert_eq!(count, 0); + } + + #[test] + fn empty_literal_is_skipped() { + let rules = vec![rule("", "{{ x }}")]; + let (out, count) = apply_replacements("hello", &rules); + assert_eq!(out, "hello"); + assert_eq!(count, 0); + } + + // ── apply_replacements: word boundaries ─────────────────────── + + #[test] + fn no_match_inside_longer_word() { + let rules = sorted(vec![rule("app", "{{ name }}")]); + let (out, count) = apply_replacements("the application is great", &rules); + assert_eq!(out, "the application is great"); + assert_eq!(count, 0); + } + + #[test] + fn no_match_with_prefix_attached() { + let rules = sorted(vec![rule("app", "{{ name }}")]); + let (out, count) = apply_replacements("myapp works", &rules); + assert_eq!(out, "myapp works"); + assert_eq!(count, 0); + } + + #[test] + fn no_match_with_suffix_attached() { + let rules = sorted(vec![rule("app", "{{ name }}")]); + let (out, count) = apply_replacements("apps are great", &rules); + assert_eq!(out, "apps are great"); + assert_eq!(count, 0); + } + + #[rstest] + #[case("app is here", "{{ n }} is here", 1)] + #[case("use app", "use {{ n }}", 1)] + #[case("app", "{{ n }}", 1)] + #[case("(app)", "({{ n }})", 1)] + #[case("\"app\"", "\"{{ n }}\"", 1)] + #[case("app.config", "{{ n }}.config", 1)] + #[case("/app/", "/{{ n }}/", 1)] + fn boundary_at_non_word_chars( + #[case] input: &str, + #[case] expected: &str, + #[case] expected_count: usize, + ) { + let rules = sorted(vec![rule("app", "{{ n }}")]); + let (out, count) = apply_replacements(input, &rules); + assert_eq!(out, expected, "input: {input:?}"); + assert_eq!(count, expected_count); + } + + #[test] + fn hyphen_is_word_boundary_blocker() { + // "my-app" contains "app" but hyphen is a word char, so "app" alone + // should NOT match inside "my-app". + let rules = sorted(vec![rule("app", "{{ name }}")]); + let (out, count) = apply_replacements("my-app", &rules); + assert_eq!(out, "my-app"); + assert_eq!(count, 0); + } + + #[test] + fn underscore_is_word_boundary_blocker() { + let rules = sorted(vec![rule("app", "{{ name }}")]); + let (out, count) = apply_replacements("my_app", &rules); + assert_eq!(out, "my_app"); + assert_eq!(count, 0); + } + + // ── apply_replacements: longest-match-first / overlap ───────── + + #[test] + fn longest_match_wins() { + let rules = sorted(vec![ + rule("my-app", "{{ full }}"), + rule("my", "{{ prefix }}"), + ]); + let (out, count) = apply_replacements("name: my-app", &rules); + assert_eq!(out, "name: {{ full }}"); + assert_eq!(count, 1); + } + + #[test] + fn shorter_rule_still_matches_elsewhere() { + let rules = sorted(vec![ + rule("my-app", "{{ full }}"), + rule("my", "{{ prefix }}"), + ]); + let (out, count) = apply_replacements("my-app by my", &rules); + assert_eq!(out, "{{ full }} by {{ prefix }}"); + assert_eq!(count, 2); + } + + #[test] + fn adjacent_non_overlapping_matches() { + // Two rules that match at adjacent positions separated by a dot. + let rules = sorted(vec![rule("foo", "{{ a }}"), rule("bar", "{{ b }}")]); + let (out, count) = apply_replacements("foo.bar", &rules); + assert_eq!(out, "{{ a }}.{{ b }}"); + assert_eq!(count, 2); + } + + // ── apply_replacements: no re-scanning ──────────────────────── + + #[test] + fn replacement_output_is_not_rescanned() { + // If re-scanning occurred, the "x" in "{{ x }}" could trigger rule 2. + let rules = sorted(vec![rule("foo", "{{ x }}"), rule("x", "WRONG")]); + let (out, count) = apply_replacements("foo", &rules); + assert_eq!(out, "{{ x }}"); + assert_eq!(count, 1); + } + + // ── apply_replacements: unicode ─────────────────────────────── + + #[test] + fn unicode_content_preserved() { + // CJK chars are alphanumeric (is_word_char → true), so the literal + // must appear at a non-word boundary to match. + let rules = sorted(vec![rule("my-app", "{{ name }}")]); + let (out, count) = apply_replacements("プロジェクト: my-app です", &rules); + assert_eq!(out, "プロジェクト: {{ name }} です"); + assert_eq!(count, 1); + } + + #[test] + fn cjk_neighbors_block_boundary() { + // CJK characters are alphanumeric → word chars, so a literal + // flanked by them is not at a word boundary. + let rules = sorted(vec![rule("名前", "{{ name }}")]); + let (out, count) = apply_replacements("私の名前はアプリです", &rules); + assert_eq!(out, "私の名前はアプリです"); + assert_eq!(count, 0); + } + + #[test] + fn multibyte_boundary_respected() { + // "café" contains "é" which is alphanumeric → word char. + // Rule for "caf" should NOT match inside "café". + let rules = sorted(vec![rule("caf", "{{ x }}")]); + let (out, count) = apply_replacements("café", &rules); + assert_eq!(out, "café"); + assert_eq!(count, 0); + } + + // ── apply_path_replacements ─────────────────────────────────── + + #[test] + fn replaces_in_path_components() { + let rules = sorted(vec![rule("my-app", "{{ name }}")]); + let path = Path::new("src/my-app/main.rs"); + let result = apply_path_replacements(path, &rules); + assert_eq!(result, PathBuf::from("src/{{ name }}/main.rs")); + } + + #[test] + fn replaces_in_filename() { + let rules = sorted(vec![rule("my-app", "{{ name }}")]); + let path = Path::new("my-app.toml"); + let result = apply_path_replacements(path, &rules); + assert_eq!(result, PathBuf::from("{{ name }}.toml")); + } + + #[test] + fn no_match_across_path_separator() { + // "src/app" should not match "src/app" as a single literal — each + // component is replaced independently. + let rules = sorted(vec![rule("src/app", "{{ x }}")]); + let path = Path::new("src/app/main.rs"); + let result = apply_path_replacements(path, &rules); + // Should be unchanged because "/" is a component separator, not part + // of any single component. + assert_eq!(result, PathBuf::from("src/app/main.rs")); + } +} diff --git a/src/extract/scan.rs b/src/extract/scan.rs new file mode 100644 index 0000000..c5ea216 --- /dev/null +++ b/src/extract/scan.rs @@ -0,0 +1,129 @@ +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, + }) +} + +/// Count how many files contain `value` and the total number of hits across all files. +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) +} 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..e2c0167 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,5 +19,13 @@ 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, + exclude_from, + dry_run, + } => commands::extract::run(source, vars, output, in_place, exclude_from, dry_run), } } diff --git a/tests/integration.rs b/tests/integration.rs index 4005080..ca73855 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,156 @@ 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, + exclude_file: None, + }; + + let plan = plan_extraction(&options).unwrap(); + execute_extraction(&plan).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_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, + exclude_file: None, + }; + + 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, + exclude_file: None, + }; + + 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(); + + // No --var provided → should fail with ExtractNoVariables + let options = ExtractOptions { + source_dir: project.path().to_path_buf(), + variables: vec![], + output_dir: None, + in_place: false, + exclude_file: None, + }; + + 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, + exclude_file: None, + }; + + 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).unwrap(); +}