Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
100695c
feat: add `diecut extract` command to create templates from existing …
raiderrobert Feb 27, 2026
1ddb7d3
fix(extract): correct camelCase handling and computed variable expres…
raiderrobert Feb 27, 2026
69099b8
fix(extract): prevent substring collision in replacements
raiderrobert Feb 27, 2026
f5ad9ed
refactor(extract): use computed variable names in template files
raiderrobert Feb 27, 2026
5ccdaa1
feat(extract): add auto-detection of template variables
raiderrobert Feb 28, 2026
3ec6800
refactor(extract): make auto-detect default, rename --batch to -y/--yes
raiderrobert Feb 28, 2026
375616a
fix: resolve cargo fmt and clippy warnings
raiderrobert Feb 28, 2026
cc93d61
fix(extract): resolve merge chains in cluster deduplication
raiderrobert Feb 28, 2026
bdd420b
refactor(extract): use enum for PlannedExtractFile content
raiderrobert Feb 28, 2026
917ea77
fix(extract): replace partial_cmp().unwrap() with total_cmp() for NaN…
raiderrobert Feb 28, 2026
a35eb2f
fix(extract): use dedicated error variant for malformed --var arguments
raiderrobert Feb 28, 2026
9bb5be9
fix(extract): disable git terminal prompts during auto-detection
raiderrobert Feb 28, 2026
b490f8c
refactor(extract): consolidate duplicate count_occurrences functions
raiderrobert Feb 28, 2026
c163f04
perf(extract): use LazyLock for Regex compilation
raiderrobert Feb 28, 2026
761fcd5
fix(extract): address code audit findings
raiderrobert Feb 28, 2026
fb22906
fix(extract): handle nested excludes and symlinks to directories
raiderrobert Feb 28, 2026
e28b004
fix(extract): exclude .worktrees/ from template extraction
raiderrobert Feb 28, 2026
5cc9672
feat(extract): stub content files instead of copying verbatim
raiderrobert Feb 28, 2026
fc75d15
fix(extract): commit missing exclude.rs refactor
raiderrobert Feb 28, 2026
257fea5
feat(extract): drop deep content files, add --stub-depth flag
raiderrobert Feb 28, 2026
b0d69da
refactor: autodetect
raiderrobert Feb 28, 2026
5657b71
refactor(extract): simplify auto-detect and extract interactive UI
raiderrobert Feb 28, 2026
23491a8
fix(extract): apply stub-depth to templated files too
raiderrobert Feb 28, 2026
0760e23
fix(extract): filter deep files before auto-detect
raiderrobert Feb 28, 2026
2f62404
refactor: improve extraction
raiderrobert Feb 28, 2026
674be8c
refactor(extract): trim to engine-only for PR 1
raiderrobert Mar 4, 2026
094222a
refactor(extract): trim to verbatim-only for PR review
raiderrobert Mar 5, 2026
e9e76b8
refactor(extract): load excludes from embedded file with override
raiderrobert Mar 5, 2026
13c313a
test(extract): add unit tests for replace.rs
raiderrobert Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Output directory for the extracted template
#[arg(short, long)]
output: Option<String>,

/// 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<String>,

/// Show what would be extracted without writing files
#[arg(long)]
dry_run: bool,
},
}
93 changes: 93 additions & 0 deletions src/commands/extract.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
output: Option<String>,
in_place: bool,
exclude_from: Option<String>,
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<Vec<(String, String)>> {
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());
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod extract;
pub mod list;
pub mod new;
24 changes: 24 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = std::result::Result<T, DicecutError>;
12 changes: 12 additions & 0 deletions src/extract/default_excludes.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Version control
.git

# Rust
target
Cargo.lock

# macOS
.DS_Store

# Diecut
.diecut-answers.toml
82 changes: 82 additions & 0 deletions src/extract/exclude.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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"]);
}
}
Loading