Add nested folders to the file panel and File ▸ New project
Two features that arrived together, since the second depends on the first to show what it generates. Nested folders -------------- `App::files` now holds workspace-relative paths (`part-1/ch-03.md`) rather than bare names, and the workspace scan recurses eight levels, skipping dot-directories, `target/` and `node_modules/`. `order::tree_order` normalises the flat order so every folder's files are contiguous and each folder sits where its earliest-ordered file put it — which is what keeps the panel and the export in agreement: the export concatenates the tree read top to bottom. The panel draws a collapsible tree. Dragging within a folder reorders as before; dropping onto a folder header, or among another folder's files, moves the file on disk and carries its title override, session word baseline and cached header info with it. Emptied folders are pruned. New files take a path (`part-1/ch-01`) to create folders, and the Rename box now holds the whole relative path, so editing its folder part moves the file. File ▸ New project ------------------ Scaffolds a project from a cookiecutter template and opens its drafting subfolder (`06-First Draft`) as the workspace. `cookiecutter.rs` resolves the executable from PATH and the usual per-user Python prefixes — a desktop launcher inherits neither a conda PATH nor the tools a template's hooks shell out to, so the resolved binary's directory is prepended for the child — builds the non-interactive command line, and identifies the result by diffing the output directory, which works whatever a template names its root. Generation runs off the UI thread because hooks can reach the network. Settings ▸ New project… covers the template path, the subfolder to open, the cookiecutter path, a hooks toggle, and the Gitea credentials passed to hooks as GITEA_URL / GITEA_USER / GITEA_TOKEN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZGoPiDuZ7vmryNCJWjYSD
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
//! 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<Var>,
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<OsString> {
|
||||
let mut args: Vec<OsString> = 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<OsString> {
|
||||
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<PathBuf, String> {
|
||||
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<OsString> = 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<String> {
|
||||
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<String> = 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<String> = 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<String> = 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user