//! Driving the `cookiecutter` CLI to scaffold a new manuscript project. //! //! Cookiecutter is a Python tool that renders a directory template through //! Jinja, so this module shells out to it rather than reimplementing it. The //! work here is finding the executable (it is usually installed in a conda or //! per-user prefix that a desktop-launched app does not have on `PATH`), //! building the non-interactive command line, and working out which directory //! the run produced. use std::collections::HashSet; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; /// Name of the executable looked for when no explicit path is configured. const BIN_NAME: &str = "cookiecutter"; /// Prefixes searched for the executable when `PATH` does not have it, in order. /// Cookiecutter usually arrives via conda or `pip install --user`, neither of /// which is on the `PATH` of a process started from a desktop launcher. const FALLBACK_PREFIXES: &[&str] = &[ ".anaconda3/bin", "anaconda3/bin", "miniconda3/bin", "miniforge3/bin", ".local/bin", ]; /// Absolute directories searched after the per-user prefixes. const SYSTEM_DIRS: &[&str] = &["/usr/local/bin", "/usr/bin"]; /// One `key=value` override handed to cookiecutter. pub struct Var { pub key: String, pub value: String, } impl Var { pub fn new(key: &str, value: &str) -> Self { Var { key: key.to_string(), value: value.to_string(), } } } /// Everything one generation run needs. pub struct Request { /// The `cookiecutter` executable, already resolved. pub bin: PathBuf, /// Directory holding the template's `cookiecutter.json`. pub template: PathBuf, /// Directory the new project is created inside. pub output_dir: PathBuf, /// Template variable overrides (`project_name=…`). pub vars: Vec, /// Environment given to the child, on top of the inherited one. Used for the /// credentials a template's hooks read. pub env: Vec<(String, String)>, /// Whether to run the template's pre/post-generation hooks. pub run_hooks: bool, } /// Look for the `cookiecutter` executable. /// /// `configured` wins when it is set: a value containing a separator is taken as /// a path, anything else as a program name to look up. With no configuration, /// `PATH` is searched first, then the usual per-user Python prefixes. pub fn resolve_binary(configured: &str, home: Option<&Path>) -> Option { let configured = configured.trim(); if !configured.is_empty() { if configured.contains('/') { let path = PathBuf::from(configured); return is_program(&path).then_some(path); } return search_path(configured); } if let Some(found) = search_path(BIN_NAME) { return Some(found); } let user_dirs = home.into_iter().flat_map(|home| { FALLBACK_PREFIXES .iter() .map(move |prefix| home.join(prefix)) }); let system_dirs = SYSTEM_DIRS.iter().map(PathBuf::from); user_dirs .chain(system_dirs) .map(|dir| dir.join(BIN_NAME)) .find(|candidate| is_program(candidate)) } /// Whether `path` is an existing file we could execute. fn is_program(path: &Path) -> bool { path.is_file() } /// Find `name` in the `PATH` environment variable. fn search_path(name: &str) -> Option { let path = std::env::var_os("PATH")?; std::env::split_paths(&path) .map(|dir| dir.join(name)) .find(|candidate| is_program(candidate)) } /// Build the command line for a request: the flags, the template, then the /// variable overrides. Kept separate from running it so the argument order can /// be asserted in tests without invoking Python. pub fn build_args(request: &Request) -> Vec { let mut args: Vec = vec![ // Every value comes from the dialog, so the child must never block on a // prompt — there is no terminal attached to read it. "--no-input".into(), "--output-dir".into(), request.output_dir.clone().into(), "--accept-hooks".into(), if request.run_hooks { "yes" } else { "no" }.into(), request.template.clone().into(), ]; for var in &request.vars { args.push(format!("{}={}", var.key, var.value).into()); } args } /// Directory entry names directly inside `dir`, or an empty set if unreadable. fn entries(dir: &Path) -> HashSet { match std::fs::read_dir(dir) { Ok(read) => read.flatten().map(|e| e.file_name()).collect(), Err(_) => HashSet::new(), } } /// Run cookiecutter and return the directory it created. /// /// The new project is identified by diffing the output directory before and /// after, which works whatever a template names its root — the rendered folder /// name is a Jinja expression this app never sees. pub fn generate(request: &Request) -> Result { if !request.template.join("cookiecutter.json").is_file() { return Err(format!( "No cookiecutter.json in {} — that folder is not a template.", request.template.display() )); } std::fs::create_dir_all(&request.output_dir) .map_err(|e| format!("Cannot use {}: {e}", request.output_dir.display()))?; let before = entries(&request.output_dir); let mut command = Command::new(&request.bin); command.args(build_args(request)); // A template's hooks call out to other programs (git, curl); when // cookiecutter lives in a conda prefix, so do they, and that prefix is not // otherwise on the child's PATH. if let Some(bin_dir) = request.bin.parent() { command.env("PATH", prepend_path(bin_dir)); } for (key, value) in &request.env { command.env(key, value); } let output = command.output().map_err(|e| { format!("Could not run {}: {e}", request.bin.display()) })?; if !output.status.success() { return Err(describe_failure(&output)); } let after = entries(&request.output_dir); let mut created: Vec = after.difference(&before).cloned().collect(); if created.len() == 1 { return Ok(request.output_dir.join(created.remove(0))); } // Nothing new means the template re-rendered over a directory that was // already there; fall back to the name the project was asked to have. if created.is_empty() { if let Some(name) = request.vars.iter().find(|v| v.key == "project_name") { let guess = request.output_dir.join(&name.value); if guess.is_dir() { return Ok(guess); } } } Err(format!( "cookiecutter finished but did not leave a single new folder in {}.", request.output_dir.display() )) } /// `dir` followed by the inherited `PATH`. fn prepend_path(dir: &Path) -> OsString { let mut dirs = vec![dir.to_path_buf()]; if let Some(existing) = std::env::var_os("PATH") { dirs.extend(std::env::split_paths(&existing)); } std::env::join_paths(dirs).unwrap_or_else(|_| dir.as_os_str().to_owned()) } /// Turn a failed run into a one-line message, preferring the last meaningful /// line of stderr — cookiecutter puts the real cause there (a JSON decoding /// error, a hook that exited non-zero) under a long Python traceback. fn describe_failure(output: &std::process::Output) -> String { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let detail = last_meaningful_line(&stderr) .or_else(|| last_meaningful_line(&stdout)) .unwrap_or_else(|| "no output".to_string()); format!("cookiecutter failed: {detail}") } /// The last non-blank line that is not part of a Python traceback frame. fn last_meaningful_line(text: &str) -> Option { text.lines() .map(str::trim) .rfind(|line| { !line.is_empty() && !line.starts_with("File \"") && !line.starts_with('^') && !line.starts_with('~') && *line != "Traceback (most recent call last):" }) .map(str::to_string) } #[cfg(test)] mod tests { use super::*; fn request(run_hooks: bool) -> Request { Request { bin: PathBuf::from("/usr/bin/cookiecutter"), template: PathBuf::from("/tmpl/snowflake"), output_dir: PathBuf::from("/out"), vars: vec![ Var::new("project_name", "The Winter Gate"), Var::new("author", "Lane Wight"), ], env: Vec::new(), run_hooks, } } #[test] fn args_are_non_interactive_and_carry_the_overrides() { let args: Vec = build_args(&request(true)) .iter() .map(|a| a.to_string_lossy().into_owned()) .collect(); assert_eq!( args, vec![ "--no-input", "--output-dir", "/out", "--accept-hooks", "yes", "/tmpl/snowflake", "project_name=The Winter Gate", "author=Lane Wight", ] ); } #[test] fn hooks_can_be_turned_off() { let args: Vec = build_args(&request(false)) .iter() .map(|a| a.to_string_lossy().into_owned()) .collect(); let at = args.iter().position(|a| a == "--accept-hooks").unwrap(); assert_eq!(args[at + 1], "no"); } /// The template argument has to precede the `key=value` overrides, or /// cookiecutter reads the first override as the template to render. #[test] fn the_template_comes_before_the_overrides() { let args: Vec = build_args(&request(true)) .iter() .map(|a| a.to_string_lossy().into_owned()) .collect(); let template = args.iter().position(|a| a == "/tmpl/snowflake").unwrap(); let first_var = args .iter() .position(|a| a.starts_with("project_name=")) .unwrap(); assert!(template < first_var, "got {args:?}"); } #[test] fn a_value_with_spaces_stays_one_argument() { let args = build_args(&request(true)); assert!(args .iter() .any(|a| a.to_string_lossy() == "project_name=The Winter Gate")); } #[test] fn generate_rejects_a_folder_that_is_not_a_template() { let dir = std::env::temp_dir().join("md_manuscript_cc_not_a_template"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let mut req = request(false); req.template = dir.clone(); let err = generate(&req).unwrap_err(); assert!(err.contains("not a template"), "got {err}"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn an_explicit_binary_path_is_used_when_it_exists() { let dir = std::env::temp_dir().join("md_manuscript_cc_bin"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let bin = dir.join("cookiecutter"); std::fs::write(&bin, "#!/bin/sh\n").unwrap(); assert_eq!( resolve_binary(bin.to_str().unwrap(), None), Some(bin.clone()) ); // A configured path that is not there resolves to nothing rather than // silently falling back to some other cookiecutter. assert_eq!(resolve_binary("/nope/cookiecutter", None), None); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_per_user_python_prefix_is_searched_when_path_lacks_it() { let home = std::env::temp_dir().join("md_manuscript_cc_home"); let _ = std::fs::remove_dir_all(&home); let bin_dir = home.join(".anaconda3/bin"); std::fs::create_dir_all(&bin_dir).unwrap(); let bin = bin_dir.join("cookiecutter"); std::fs::write(&bin, "#!/bin/sh\n").unwrap(); // Only reached because the name is not on this test process's PATH. if search_path(BIN_NAME).is_none() { assert_eq!(resolve_binary("", Some(&home)), Some(bin)); } let _ = std::fs::remove_dir_all(&home); } #[test] fn failure_text_skips_the_python_traceback() { let text = "Traceback (most recent call last):\n \ File \"/x/hooks.py\", line 1, in run\n \ ^^^^^\n\ jinja2.exceptions.TemplateSyntaxError: expected token\n"; assert_eq!( last_meaningful_line(text).as_deref(), Some("jinja2.exceptions.TemplateSyntaxError: expected token") ); } #[test] fn failure_text_handles_output_with_nothing_useful() { assert_eq!(last_meaningful_line(""), None); assert_eq!(last_meaningful_line(" \n\n \n"), None); } /// Drive the real `cookiecutter` against the real template, which is the /// only check that the argument list above is one the tool actually accepts. /// Skips when either is absent, so a machine without them still passes. /// /// Hooks are off here to keep the test hermetic: the snowflake template's /// post-gen hook publishes to a Gitea server when `GITEA_*` is set, and a /// test must not depend on — or touch — the network. #[test] fn generates_a_project_from_the_configured_template() { let Some(bin) = resolve_binary("", dirs::home_dir().as_deref()) else { eprintln!("skipping: no cookiecutter on this machine"); return; }; let template = crate::config::default_project_template(); if !template.join("cookiecutter.json").is_file() { eprintln!("skipping: no template at {}", template.display()); return; } let out = std::env::temp_dir().join("md_manuscript_cc_e2e"); let _ = std::fs::remove_dir_all(&out); let request = Request { bin, template, output_dir: out.clone(), vars: vec![ Var::new("project_name", "Test Project With Spaces"), Var::new("author", "Nobody"), Var::new("description", "Generated by a unit test."), ], env: Vec::new(), run_hooks: false, }; let project = generate(&request).expect("generation should succeed"); // The diff-the-directory approach must find the folder the template // named from `project_name`, spaces and all. assert_eq!( project.file_name().and_then(|n| n.to_str()), Some("Test Project With Spaces") ); assert!(project.is_dir()); // And the subfolder the app opens as a workspace has to be in there. let subdir = crate::config::default_project_open_subdir(); assert!( project.join(&subdir).is_dir(), "expected a {subdir} folder in {project:?}" ); let _ = std::fs::remove_dir_all(&out); } }