diff --git a/.gitignore b/.gitignore index ad67955..23f5f27 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ target # Generated by cargo mutants # Contains mutation testing data **/mutants.out*/ +CLAUDE.md # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can diff --git a/Cargo.lock b/Cargo.lock index db8ed13..9cee2e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,12 +319,34 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "generic-array" version = "0.14.7" @@ -346,6 +368,19 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "globset" version = "0.4.18" @@ -370,6 +405,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -415,6 +459,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ignore" version = "0.4.25" @@ -438,7 +488,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -485,6 +537,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.178" @@ -497,6 +555,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.14" @@ -688,6 +752,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -699,7 +773,7 @@ dependencies = [ [[package]] name = "ptmp" -version = "0.1.1" +version = "0.1.3" dependencies = [ "anyhow", "chrono", @@ -707,6 +781,7 @@ dependencies = [ "inquire", "sanitize-filename", "serde", + "tempfile", "tera", "toml", "walkdir", @@ -721,6 +796,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -748,7 +829,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -789,6 +870,19 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -826,6 +920,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -964,6 +1064,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tera" version = "1.20.1" @@ -1077,6 +1190,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1105,6 +1224,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.106" @@ -1150,6 +1287,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1324,6 +1495,94 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zerocopy" version = "0.8.31" diff --git a/Cargo.toml b/Cargo.toml index f8d9b41..adfee8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ptmp" -version = "0.1.1" +version = "0.1.3" edition = "2021" authors = ["Michael DeWitt "] description = "Copy a template directory and render Tera placeholders via a TUI." @@ -23,3 +23,6 @@ tera = "1.19" inquire = "0.6" chrono = "0.4" sanitize-filename = "0.5" + +[dev-dependencies] +tempfile = "3" diff --git a/README.Md b/README.Md index 81a6ad3..c8e262f 100644 --- a/README.Md +++ b/README.Md @@ -1,7 +1,7 @@ # ptmp A single-binary Rust CLI that copies a template directory and renders [Tera](https://keats.github.io/tera/) placeholders via an interactive TUI. -Targets research and academic projects with LaTeX, R, Julia, and Taskfile/Makefile scaffolding. +Targets research and academic projects with LaTeX, R, Julia, Quarto, and Taskfile/Makefile scaffolding. ## Installation @@ -41,6 +41,13 @@ ptmp readme # README.md ptmp r # R/example.R ptmp julia # src/example.jl ptmp project-toml # Project.toml +ptmp slurm # example.slurm +ptmp gitignore # .gitignore +ptmp editorconfig # .editorconfig +ptmp citation # CITATION.cff +ptmp license # LICENSE (MIT) +ptmp quarto # report.qmd +ptmp slides # slides.qmd ``` ## Template rendering diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..4ccd78b --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,82 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "ptmp")] +#[command( + about = "Copy a template directory and render Tera placeholders via a TUI", + long_about = None +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Initialize a default template skeleton at the given path + Init { + /// Path where the template skeleton will be created + #[arg(short, long)] + template_path: PathBuf, + }, + + /// Create a new project from a template directory + New { + /// Path to the template directory + #[arg(short, long)] + template_path: PathBuf, + + /// Destination directory for the new project + #[arg(short, long)] + destination: PathBuf, + + /// Optional project name override (defaults to destination folder name) + #[arg(short, long)] + project_name: Option, + }, + + /// Emit a Taskfile.yml into the current directory + Taskfile, + + /// Emit a main.tex into the current directory + Latex, + + /// Emit a Makefile into the current directory + #[command(name = "makefile")] + Makefile, + + /// Emit a README.md into the current directory + Readme, + + /// Emit R/example.R into the current directory + R, + + /// Emit src/example.jl into the current directory + Julia, + + /// Emit a Project.toml into the current directory + #[command(name = "project-toml")] + ProjectToml, + + /// Emit an example.slurm job script into the current directory + Slurm, + + /// Emit a .gitignore into the current directory + Gitignore, + + /// Emit an .editorconfig into the current directory + Editorconfig, + + /// Emit a CITATION.cff into the current directory + Citation, + + /// Emit an MIT LICENSE into the current directory + License, + + /// Emit a Quarto report (report.qmd) into the current directory + Quarto, + + /// Emit a Quarto revealjs slide deck (slides.qmd) into the current directory + Slides, +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..7fc93ce --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,214 @@ +use crate::components::{collect_fields_for_component, Component}; +use crate::render::make_tera_context; +use crate::templates; +use anyhow::{Context, Result}; +use inquire::Confirm; +use sanitize_filename::sanitize; +use std::fs::{self, File}; +use std::io::Write; +use std::path::Path; +use tera::Tera; + +/// Scaffold a default template directory at `path`. +pub fn init_default_template(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + fs::create_dir_all(path.join("src"))?; + fs::create_dir_all(path.join("figures"))?; + fs::create_dir_all(path.join("R"))?; + fs::create_dir_all(path.join("output"))?; + fs::create_dir_all(path.join("notes"))?; + fs::create_dir_all(path.join("data-raw"))?; + + File::create(path.join(".here"))?.write_all(b"")?; + fs::write(path.join("README.md"), templates::README)?; + fs::write(path.join("main.tex"), templates::MAIN_TEX)?; + fs::write(path.join("references.bib"), templates::REFERENCES_BIB)?; + fs::write(path.join("R").join("example.R"), templates::R_EXAMPLE)?; + fs::write(path.join("fields.toml"), templates::FIELDS_TOML)?; + fs::write(path.join("Makefile"), templates::MAKEFILE)?; + fs::write(path.join("Project.toml"), templates::PROJECT_TOML)?; + fs::write( + path.join("src").join("example.jl"), + templates::JULIA_EXAMPLE, + )?; + fs::write(path.join("Taskfile.yml"), templates::TASKFILE)?; + + Ok(()) +} + +/// Ensure `destination` exists and is safe to write into. +/// +/// - Creates the directory if it does not exist. +/// - Prompts for confirmation if the directory is non-empty. +pub fn ensure_destination_ready(destination: &Path) -> Result<()> { + if destination.exists() { + let is_empty = destination + .read_dir() + .map(|mut it| it.next().is_none()) + .unwrap_or(true); + + if !is_empty { + let proceed = Confirm::new(&format!( + "Destination '{}' is not empty. \ + Continue and potentially overwrite files?", + destination.display() + )) + .with_default(false) + .prompt() + .context("Confirmation prompt failed")?; + + if !proceed { + anyhow::bail!("Aborted: destination not empty."); + } + } + } else { + fs::create_dir_all(destination) + .with_context(|| format!("Failed to create destination {}", destination.display()))?; + } + Ok(()) +} + +/// Emit a single component file into the current directory. +pub fn emit_component(component: &Component) -> Result<()> { + let cwd = std::env::current_dir().context("Failed to determine current directory")?; + + let project_name = cwd + .file_name() + .and_then(|n| n.to_str()) + .map(sanitize) + .unwrap_or_else(|| "project".to_string()); + + let output = cwd.join(component.output_path); + + if output.exists() { + let proceed = Confirm::new(&format!( + "'{}' already exists. Overwrite?", + component.output_path + )) + .with_default(false) + .prompt() + .context("Confirmation prompt failed")?; + + if !proceed { + anyhow::bail!("Aborted."); + } + } + + let values = collect_fields_for_component(component)?; + let ctx = make_tera_context(values, &project_name); + + let rendered = Tera::one_off(component.template, &ctx, false) + .with_context(|| format!("Failed to render template for '{}'", component.name))?; + + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&output, rendered)?; + + println!("Wrote {}", component.output_path); + + for &(path, content) in component.extra_text { + let dest = cwd.join(path); + if dest.exists() { + let proceed = Confirm::new(&format!("'{}' already exists. Overwrite?", path)) + .with_default(false) + .prompt() + .context("Confirmation prompt failed")?; + if !proceed { + continue; + } + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&dest, content)?; + println!("Wrote {}", path); + } + + for &(path, content) in component.extra_binary { + let dest = cwd.join(path); + if dest.exists() { + let proceed = Confirm::new(&format!("'{}' already exists. Overwrite?", path)) + .with_default(false) + .prompt() + .context("Confirmation prompt failed")?; + if !proceed { + continue; + } + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&dest, content)?; + println!("Wrote {}", path); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // --- init_default_template --- + + #[test] + fn init_default_template_creates_dirs() { + let dir = tempdir().unwrap(); + init_default_template(dir.path()).unwrap(); + + for subdir in &["src", "figures", "R", "output"] { + assert!( + dir.path().join(subdir).is_dir(), + "missing subdirectory: {subdir}" + ); + } + } + + #[test] + fn init_default_template_creates_files() { + let dir = tempdir().unwrap(); + init_default_template(dir.path()).unwrap(); + + let expected = &[ + "README.md", + "main.tex", + "Makefile", + "Taskfile.yml", + "fields.toml", + "Project.toml", + "references.bib", + ".here", + "R/example.R", + "src/example.jl", + ]; + for name in expected { + assert!(dir.path().join(name).exists(), "missing file: {name}"); + } + } + + // --- ensure_destination_ready --- + + #[test] + fn ensure_destination_ready_creates_missing_dir() { + let dir = tempdir().unwrap(); + let target = dir.path().join("new_project"); + assert!(!target.exists()); + ensure_destination_ready(&target).unwrap(); + assert!(target.is_dir()); + } + + #[test] + fn ensure_destination_ready_accepts_empty_dir() { + let dir = tempdir().unwrap(); + let target = dir.path().join("empty"); + fs::create_dir_all(&target).unwrap(); + // Should succeed without prompting because the dir is empty. + ensure_destination_ready(&target).unwrap(); + assert!(target.is_dir()); + } + + // Non-empty destination requires TUI confirmation — not unit-testable without + // mocking `inquire::Confirm`. Covered manually / via integration testing. +} diff --git a/src/components.rs b/src/components.rs new file mode 100644 index 0000000..eb74fa0 --- /dev/null +++ b/src/components.rs @@ -0,0 +1,309 @@ +use crate::cli::Commands; +use crate::templates; +use anyhow::{Context, Result}; +use inquire::Text; +use std::collections::HashMap; + +pub struct FieldDef { + pub name: &'static str, + pub prompt: &'static str, + pub default: &'static str, + pub required: bool, +} + +pub static KNOWN_FIELDS: &[FieldDef] = &[ + FieldDef { + name: "title", + prompt: "Project title", + default: "My Analysis", + required: true, + }, + FieldDef { + name: "author", + prompt: "Author name", + default: "", + required: true, + }, + FieldDef { + name: "affiliation", + prompt: "Affiliation (e.g., Institution/Lab)", + default: "", + required: false, + }, + FieldDef { + name: "main_file", + prompt: "Main LaTeX file name", + default: "main.tex", + required: false, + }, + FieldDef { + name: "latex_engine", + prompt: "LaTeX engine (pdf/xelatex/lualatex)", + default: "pdf", + required: true, + }, + FieldDef { + name: "slurm_account", + prompt: "SLURM account/group", + default: "", + required: true, + }, + FieldDef { + name: "slurm_email", + prompt: "Email for SLURM notifications", + default: "", + required: true, + }, + FieldDef { + name: "orcid", + prompt: "ORCID identifier (e.g., 0000-0001-2345-6789)", + default: "", + required: false, + }, +]; + +pub struct Component { + pub name: &'static str, + pub output_path: &'static str, + pub template: &'static str, + pub fields: &'static [&'static str], + pub extra_text: &'static [(&'static str, &'static str)], + pub extra_binary: &'static [(&'static str, &'static [u8])], +} + +pub static COMPONENTS: &[Component] = &[ + Component { + name: "taskfile", + output_path: "Taskfile.yml", + template: templates::TASKFILE, + fields: &["title", "main_file", "latex_engine"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "latex", + output_path: "main.tex", + template: templates::MAIN_TEX, + fields: &["title", "author", "affiliation"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "makefile", + output_path: "Makefile", + template: templates::MAKEFILE, + fields: &["main_file", "latex_engine"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "readme", + output_path: "README.md", + template: templates::README, + fields: &["title", "author", "affiliation"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "r", + output_path: "R/example.R", + template: templates::R_EXAMPLE, + fields: &["title", "author"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "julia", + output_path: "src/example.jl", + template: templates::JULIA_EXAMPLE, + fields: &["title", "author"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "project-toml", + output_path: "Project.toml", + template: templates::PROJECT_TOML, + fields: &["title", "author"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "slurm", + output_path: "example.slurm", + template: templates::SLURM, + fields: &["title", "slurm_account", "slurm_email"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "gitignore", + output_path: ".gitignore", + template: templates::GITIGNORE, + fields: &[], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "editorconfig", + output_path: ".editorconfig", + template: templates::EDITORCONFIG, + fields: &[], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "citation", + output_path: "CITATION.cff", + template: templates::CITATION, + fields: &["title", "author", "orcid"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "license", + output_path: "LICENSE", + template: templates::LICENSE, + fields: &["author"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "quarto", + output_path: "report.qmd", + template: templates::QUARTO, + fields: &["title", "author"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "slides", + output_path: "slides.qmd", + template: templates::SLIDES, + fields: &["title", "author"], + extra_text: &[("reveal-theme.scss", templates::REVEAL_THEME)], + extra_binary: &[("assets/logo-wide.png", templates::LOGO_WIDE)], + }, +]; + +/// Map a CLI command variant to its registered [`Component`], if applicable. +/// +/// Returns `None` for `Init` and `New`, which are handled separately. +pub fn component_for_command(cmd: &Commands) -> Option<&'static Component> { + let name = match cmd { + Commands::Taskfile => "taskfile", + Commands::Latex => "latex", + Commands::Makefile => "makefile", + Commands::Readme => "readme", + Commands::R => "r", + Commands::Julia => "julia", + Commands::ProjectToml => "project-toml", + Commands::Slurm => "slurm", + Commands::Gitignore => "gitignore", + Commands::Editorconfig => "editorconfig", + Commands::Citation => "citation", + Commands::License => "license", + Commands::Quarto => "quarto", + Commands::Slides => "slides", + _ => return None, + }; + COMPONENTS.iter().find(|c| c.name == name) +} + +/// Prompt only for the fields required by a component. +pub fn collect_fields_for_component(component: &Component) -> Result> { + let mut values = HashMap::new(); + for &needed in component.fields { + let def = KNOWN_FIELDS + .iter() + .find(|f| f.name == needed) + .unwrap_or_else(|| panic!("Unknown field '{needed}' in component registry")); + let mut prompt = Text::new(def.prompt); + if !def.default.is_empty() { + prompt = prompt.with_default(def.default); + } + if def.required { + prompt = prompt.with_help_message("Required"); + } + let val = prompt + .prompt() + .with_context(|| format!("Prompt failed for '{}'", def.name))?; + if def.required && val.trim().is_empty() { + anyhow::bail!("Field '{}' is required and cannot be empty", def.name); + } + values.insert(def.name.to_string(), val); + } + Ok(values) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Commands; + use std::path::PathBuf; + + #[test] + fn component_for_command_taskfile() { + let cmd = Commands::Taskfile; + let comp = component_for_command(&cmd).expect("Taskfile should resolve"); + assert_eq!(comp.name, "taskfile"); + assert_eq!(comp.output_path, "Taskfile.yml"); + } + + #[test] + fn component_for_command_all_emit_variants() { + let cases: &[(Commands, &str)] = &[ + (Commands::Taskfile, "taskfile"), + (Commands::Latex, "latex"), + (Commands::Makefile, "makefile"), + (Commands::Readme, "readme"), + (Commands::R, "r"), + (Commands::Julia, "julia"), + (Commands::ProjectToml, "project-toml"), + (Commands::Slurm, "slurm"), + (Commands::Gitignore, "gitignore"), + (Commands::Editorconfig, "editorconfig"), + (Commands::Citation, "citation"), + (Commands::License, "license"), + (Commands::Quarto, "quarto"), + (Commands::Slides, "slides"), + ]; + for (cmd, expected_name) in cases { + let comp = component_for_command(cmd) + .unwrap_or_else(|| panic!("Expected component for {expected_name}")); + assert_eq!(comp.name, *expected_name); + } + } + + #[test] + fn component_for_command_init_returns_none() { + let cmd = Commands::Init { + template_path: PathBuf::from("/tmp"), + }; + assert!(component_for_command(&cmd).is_none()); + } + + #[test] + fn component_for_command_new_returns_none() { + let cmd = Commands::New { + template_path: PathBuf::from("/tmp"), + destination: PathBuf::from("/tmp/out"), + project_name: None, + }; + assert!(component_for_command(&cmd).is_none()); + } + + #[test] + fn known_fields_covers_all_component_fields() { + for comp in COMPONENTS { + for &field_name in comp.fields { + let found = KNOWN_FIELDS.iter().any(|f| f.name == field_name); + assert!( + found, + "Component '{}' references field '{}' not in KNOWN_FIELDS", + comp.name, field_name + ); + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 39e8567..a8da712 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,781 +24,23 @@ //! are prompted for; built-in variables (`project_name`, `today`, //! `year`) are injected automatically. -use anyhow::{Context, Result}; -use chrono::Local; -use clap::{Parser, Subcommand}; -use inquire::{Confirm, Text}; +use anyhow::Result; +use clap::Parser; use sanitize_filename::sanitize; -use serde::Deserialize; use std::collections::HashMap; -use std::fs::{self, File}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use tera::{Context as TeraContext, Tera}; -use walkdir::WalkDir; -// --------------------------------------------------------------- -// Templates -// --------------------------------------------------------------- - -mod templates { - pub const README: &str = r#"# {{ title }} - -**Author:** {{ author }} -**Affiliation:** {{ affiliation }} -**Date:** {{ today }} - -## Overview -This project was generated on {{ today }} ({{ year }}) via the template copier. - -## Structure -- `src/` — source code (Julia scripts, etc.) -- `figures/` — plots and figures -- `R/` — R scripts -- `output/` — generated outputs/results - -## Notes -- The root `.here` file marks the project directory for the R `{here}` package. -- Julia project managed via `Project.toml`. -- LaTeX main file: `main.tex` -- Run `task --list` to see available development tasks. -"#; - - pub const MAIN_TEX: &str = r#"\documentclass[12pt,letterpaper]{article} -\usepackage{setspace} -% Packages -\usepackage[margin=1in]{geometry} -\usepackage{graphicx} % Required for inserting images -\usepackage{comment} -\usepackage[T1]{fontenc} -\usepackage[utf8]{inputenc} -\usepackage{lmodern} -\usepackage{natbib} - -\title{ {{ title }} } -\author{ {{ author }} \\ {{ affiliation }} } -\date{ {{ today }} } - -\begin{document} -\maketitle - -\section{Introduction} -Write your introduction here. - -\section{Methods} -Describe your methods here. - -\section{Results} -Present results here. Figures can be stored in `figures/`. - -\section{Discussion} -Discuss findings here. - -\bibliographystyle{plain} -\bibliography{references} - -\end{document} -"#; - - pub const REFERENCES_BIB: &str = r#"% Bibliography for {{ title }} -% Add BibTeX entries below -"#; - - pub const MAKEFILE: &str = r#"############################################################ -# Project Makefile — generated via template copier -# -# Targets: -# make pdf - build the PDF with latexmk -# make watch - watch and rebuild on changes (latexmk -pvc) -# make figures - run all R/*.R scripts -# make open - open the generated PDF -# make clean - clean LaTeX aux files in output/ -# make distclean - clean plus remove output/* artifacts -# make check - print configuration -############################################################ - -# --- Configuration (Tera-injected) --- -MAIN := {{ main_file }} -OUTDIR := output -FIGDIR := figures - -# LaTeX engine selection: 'pdf', 'xelatex', or 'lualatex' -# (Collected via TUI; defaults to 'pdf') -ENGINE_FLAG := {% if latex_engine == "xelatex" %}-xelatex{% elif latex_engine == "lualatex" %}-lualatex{% else %}-pdf{% endif %} - -LATEXMK := latexmk -RSCRIPTS := $(wildcard R/*.R) -PDF := $(OUTDIR)/$(MAIN:.tex=.pdf) - -# Detect platform for 'open' -UNAME_S := $(shell uname -s) -ifeq ($(UNAME_S),Darwin) - OPEN_CMD := open -else - OPEN_CMD := xdg-open -endif - -.PHONY: all pdf watch figures open clean distclean check ensure_dirs - -all: pdf - -ensure_dirs: - @mkdir -p $(OUTDIR) $(FIGDIR) - -# Build PDF with latexmk -pdf: ensure_dirs $(PDF) - -$(PDF): $(MAIN) - $(LATEXMK) $(ENGINE_FLAG) -interaction=nonstopmode -halt-on-error -file-line-error -outdir=$(OUTDIR) $(MAIN) - -# Continuous preview (rebuild on file changes) -watch: ensure_dirs - $(LATEXMK) $(ENGINE_FLAG) -pvc -interaction=nonstopmode -file-line-error -outdir=$(OUTDIR) $(MAIN) - -# Run all R scripts in R/ -figures: ensure_dirs - @for f in $(RSCRIPTS); do \ - echo "Running $$f"; \ - Rscript "$$f"; \ - done - -# Open the built PDF (macOS: open; Linux: xdg-open) -open: pdf - $(OPEN_CMD) "$(PDF)" - -# Clean LaTeX intermediate files from output/ -clean: - $(LATEXMK) -C -outdir=$(OUTDIR) - @rm -f "$(PDF)" - -# Deep clean all artifacts in output/ -distclean: clean - @rm -rf $(OUTDIR)/* - -check: - @echo "Main file: $(MAIN)" - @echo "Output dir: $(OUTDIR)" - @echo "Figures dir: $(FIGDIR)" - @echo "Engine flag: $(ENGINE_FLAG)" - @echo "R scripts: $(RSCRIPTS)" -"#; - - pub const TASKFILE: &str = r#"# Taskfile for research project: {{ title }} -# https://taskfile.dev -version: "3" - -vars: - MAIN: "{{ main_file }}" - OUTDIR: output - FIGDIR: figures - ENGINE: - sh: | - case "{{ latex_engine }}" in - xelatex) echo "-xelatex" ;; - lualatex) echo "-lualatex" ;; - *) echo "-pdf" ;; - esac - -{% raw %} -tasks: - default: - desc: Build the PDF - deps: [latex] - - latex: - desc: Build PDF with latexmk - cmds: - - mkdir -p {{.OUTDIR}} - - >- - latexmk {{.ENGINE}} - -interaction=nonstopmode - -halt-on-error - -file-line-error - -outdir={{.OUTDIR}} - {{.MAIN}} - - watch: - desc: Continuous rebuild on file changes - cmds: - - >- - latexmk {{.ENGINE}} - -pvc - -interaction=nonstopmode - -file-line-error - -outdir={{.OUTDIR}} - {{.MAIN}} - - tikz: - desc: Render TikZ standalone figures - cmds: - - mkdir -p {{.FIGDIR}} - - | - for f in figures/*.tex; do - [ -f "$f" ] || continue - latexmk {{.ENGINE}} \ - -interaction=nonstopmode \ - -outdir={{.FIGDIR}} "$f" - done - - r: - desc: Run all R scripts in R/ - cmds: - - | - for f in R/*.R; do - [ -f "$f" ] || continue - echo "Running $f" - Rscript "$f" - done - - julia: - desc: Run Julia scripts in src/ - cmds: - - | - for f in src/*.jl; do - [ -f "$f" ] || continue - echo "Running $f" - julia --project=. "$f" - done - - figures: - desc: Generate all figures (R + Julia) - deps: [r, julia] - - clean: - desc: Clean LaTeX auxiliary files - cmds: - - latexmk -C -outdir={{.OUTDIR}} - - rm -f {{.OUTDIR}}/{{.MAIN}} - - open: - desc: Open the generated PDF - deps: [latex] - cmds: - - open {{.OUTDIR}}/{{.MAIN}} -{% endraw %} -"#; - - pub const R_EXAMPLE: &str = r#"# Example R script -# Project: {{ title }} -# Author: {{ author }} -# Date: {{ today }} - -library(tidyverse) -library(data.table) -library(here) -# library(gtsummary) -# library(flextable) -# library(officer) - -message(paste("Project root is:", here::here())) -"#; - - pub const JULIA_EXAMPLE: &str = r#"# Example Julia script -# Project: {{ title }} -# Author: {{ author }} -# Date: {{ today }} - -using CSV, DataFrames, Plots - -println("Project root: ", @__DIR__) -"#; - - pub const PROJECT_TOML: &str = r#"name = "{{ title }}" -authors = ["{{ author }}"] - -[deps] -CSV = "0.10" -DataFrames = "1.6" -Plots = "1.40" -"#; - - pub const FIELDS_TOML: &str = r#" -# Define fields collected via TUI and injected into Tera templates -[[fields]] -name = "title" -prompt = "Project title" -default = "My Analysis" -required = true - -[[fields]] -name = "author" -prompt = "Author name" -default = "" -required = true - -[[fields]] -name = "affiliation" -prompt = "Affiliation (e.g., Institution/Lab)" -default = "" -required = false - - -[[fields]] -name = "main_file" -prompt = "Main LaTeX file name" -default = "main.tex" -required = false - -[[fields]] -name = "latex_engine" -prompt = "LaTeX engine (pdf/xelatex/lualatex)" -default = "pdf" -required = true - -"#; -} - -// --------------------------------------------------------------- -// CLI -// --------------------------------------------------------------- - -#[derive(Parser, Debug)] -#[command(name = "ptmp")] -#[command( - about = "Copy a template directory and render Tera placeholders via a TUI", - long_about = None -)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Initialize a default template skeleton at the given path - Init { - /// Path where the template skeleton will be created - #[arg(short, long)] - template_path: PathBuf, - }, - - /// Create a new project from a template directory - New { - /// Path to the template directory - #[arg(short, long)] - template_path: PathBuf, - - /// Destination directory for the new project - #[arg(short, long)] - destination: PathBuf, - - /// Optional project name override - /// (defaults to destination folder name) - #[arg(short, long)] - project_name: Option, - }, - - /// Emit a Taskfile.yml into the current directory - Taskfile, - - /// Emit a main.tex into the current directory - Latex, - - /// Emit a Makefile into the current directory - #[command(name = "makefile")] - Makefile, - - /// Emit a README.md into the current directory - Readme, - - /// Emit R/example.R into the current directory - R, - - /// Emit src/example.jl into the current directory - Julia, - - /// Emit a Project.toml into the current directory - #[command(name = "project-toml")] - ProjectToml, -} - -// --------------------------------------------------------------- -// Component registry -// --------------------------------------------------------------- - -struct FieldDef { - name: &'static str, - prompt: &'static str, - default: &'static str, - required: bool, -} - -static KNOWN_FIELDS: &[FieldDef] = &[ - FieldDef { - name: "title", - prompt: "Project title", - default: "My Analysis", - required: true, - }, - FieldDef { - name: "author", - prompt: "Author name", - default: "", - required: true, - }, - FieldDef { - name: "affiliation", - prompt: "Affiliation (e.g., Institution/Lab)", - default: "", - required: false, - }, - FieldDef { - name: "main_file", - prompt: "Main LaTeX file name", - default: "main.tex", - required: false, - }, - FieldDef { - name: "latex_engine", - prompt: "LaTeX engine (pdf/xelatex/lualatex)", - default: "pdf", - required: true, - }, -]; - -struct Component { - name: &'static str, - output_path: &'static str, - template: &'static str, - fields: &'static [&'static str], -} - -static COMPONENTS: &[Component] = &[ - Component { - name: "taskfile", - output_path: "Taskfile.yml", - template: templates::TASKFILE, - fields: &["title", "main_file", "latex_engine"], - }, - Component { - name: "latex", - output_path: "main.tex", - template: templates::MAIN_TEX, - fields: &["title", "author", "affiliation"], - }, - Component { - name: "makefile", - output_path: "Makefile", - template: templates::MAKEFILE, - fields: &["main_file", "latex_engine"], - }, - Component { - name: "readme", - output_path: "README.md", - template: templates::README, - fields: &["title", "author", "affiliation"], - }, - Component { - name: "r", - output_path: "R/example.R", - template: templates::R_EXAMPLE, - fields: &["title", "author"], - }, - Component { - name: "julia", - output_path: "src/example.jl", - template: templates::JULIA_EXAMPLE, - fields: &["title", "author"], - }, - Component { - name: "project-toml", - output_path: "Project.toml", - template: templates::PROJECT_TOML, - fields: &["title", "author"], - }, -]; - -fn component_for_command(cmd: &Commands) -> Option<&'static Component> { - let name = match cmd { - Commands::Taskfile => "taskfile", - Commands::Latex => "latex", - Commands::Makefile => "makefile", - Commands::Readme => "readme", - Commands::R => "r", - Commands::Julia => "julia", - Commands::ProjectToml => "project-toml", - _ => return None, - }; - COMPONENTS.iter().find(|c| c.name == name) -} - -// --------------------------------------------------------------- -// Template field manifest (`fields.toml`) -// --------------------------------------------------------------- - -/// Top-level structure of a `fields.toml` manifest. -/// -/// Each entry in `fields` describes one variable that the TUI -/// will prompt the user for. The collected values are injected -/// into the Tera rendering context alongside the built-in -/// variables (`project_name`, `today`, `year`). -#[derive(Debug, Deserialize)] -struct FieldsManifest { - fields: Vec, -} - -/// A single field definition inside `fields.toml`. -/// -/// Example TOML: -/// -/// ```toml -/// [[fields]] -/// name = "author" -/// prompt = "Author name" -/// default = "" -/// required = true -/// ``` -#[derive(Debug, Deserialize)] -struct Field { - /// Variable name referenced in Tera templates as `{{ name }}`. - name: String, - /// Human-readable prompt displayed in the TUI. - prompt: String, - /// Optional default value pre-filled in the prompt. - default: Option, - /// When true, an empty response is rejected. - #[serde(default)] - required: bool, -} - -// --------------------------------------------------------------- -// Utilities -// --------------------------------------------------------------- - -/// Return `true` if the entire file at `path` is valid UTF-8. -fn is_text_utf8(path: &Path) -> bool { - let mut f = match File::open(path) { - Ok(f) => f, - Err(_) => return false, - }; - let mut buf = Vec::new(); - if f.read_to_end(&mut buf).is_err() { - return false; - } - std::str::from_utf8(&buf).is_ok() -} - -/// Process a single file from the template directory. -fn render_or_copy_file( - src: &Path, - dst: &Path, - tera_ctx: &TeraContext, - enable_rendering: bool, -) -> Result<()> { - if src.file_name().and_then(|n| n.to_str()) == Some("fields.toml") { - return Ok(()); - } - - fs::create_dir_all(dst.parent().unwrap())?; - - if enable_rendering && is_text_utf8(src) { - let mut s = String::new(); - File::open(src)?.read_to_string(&mut s)?; - let rendered = Tera::one_off(&s, tera_ctx, false) - .with_context(|| format!("Failed to render template file: {}", src.display()))?; - let mut out = File::create(dst)?; - out.write_all(rendered.as_bytes())?; - } else { - fs::copy(src, dst)?; - } - Ok(()) -} - -/// Load `fields.toml` from the template root, if it exists. -fn load_fields_manifest(template_root: &Path) -> Result> { - let manifest_path = template_root.join("fields.toml"); - if manifest_path.exists() { - let s = fs::read_to_string(&manifest_path) - .with_context(|| format!("Failed to read {}", manifest_path.display()))?; - let manifest: FieldsManifest = toml::from_str(&s) - .with_context(|| format!("Failed to parse {}", manifest_path.display()))?; - Ok(Some(manifest)) - } else { - Ok(None) - } -} - -/// Prompt the user for each field defined in the manifest. -fn collect_field_values(manifest: &FieldsManifest) -> Result> { - let mut values = HashMap::new(); - for f in &manifest.fields { - let mut prompt = Text::new(&f.prompt); - if let Some(default) = &f.default { - prompt = prompt.with_default(default); - } - if f.required { - prompt = prompt.with_help_message("Required"); - } - let val = prompt - .prompt() - .with_context(|| format!("Prompt failed for '{}'", f.name))?; - if f.required && val.trim().is_empty() { - anyhow::bail!("Field '{}' is required and cannot be empty", f.name); - } - values.insert(f.name.clone(), val); - } - Ok(values) -} - -/// Prompt only for the fields required by a component. -fn collect_fields_for_component(component: &Component) -> Result> { - let mut values = HashMap::new(); - for &needed in component.fields { - let def = KNOWN_FIELDS - .iter() - .find(|f| f.name == needed) - .unwrap_or_else(|| panic!("Unknown field '{needed}' in component registry")); - let mut prompt = Text::new(def.prompt); - if !def.default.is_empty() { - prompt = prompt.with_default(def.default); - } - if def.required { - prompt = prompt.with_help_message("Required"); - } - let val = prompt - .prompt() - .with_context(|| format!("Prompt failed for '{}'", def.name))?; - if def.required && val.trim().is_empty() { - anyhow::bail!("Field '{}' is required and cannot be empty", def.name); - } - values.insert(def.name.to_string(), val); - } - Ok(values) -} - -/// Build a [`TeraContext`] from collected field values. -fn make_tera_context(mut values: HashMap, project_name: &str) -> TeraContext { - let today = Local::now().format("%Y-%m-%d").to_string(); - let year = Local::now().format("%Y").to_string(); - - values.insert("project_name".to_string(), project_name.to_string()); - values.insert("today".to_string(), today); - values.insert("year".to_string(), year); - - TeraContext::from_serialize(values).expect("Serializable context") -} - -/// Recursively copy a template directory to `destination`. -fn copy_template( - template_root: &Path, - destination: &Path, - tera_ctx: &TeraContext, - enable_rendering: bool, -) -> Result<()> { - for entry in WalkDir::new(template_root) - .into_iter() - .filter_map(|e| e.ok()) - { - let src_path = entry.path(); - if src_path == template_root { - continue; - } - let rel = src_path.strip_prefix(template_root).unwrap(); - let dst_path = destination.join(rel); - - if src_path.is_dir() { - fs::create_dir_all(&dst_path)?; - } else { - render_or_copy_file(src_path, &dst_path, tera_ctx, enable_rendering)?; - } - } - Ok(()) -} - -/// Ensure `destination` exists and is safe to write into. -fn ensure_destination_ready(destination: &Path) -> Result<()> { - if destination.exists() { - let is_empty = destination - .read_dir() - .map(|mut it| it.next().is_none()) - .unwrap_or(true); - - if !is_empty { - let proceed = Confirm::new(&format!( - "Destination '{}' is not empty. \ - Continue and potentially overwrite files?", - destination.display() - )) - .with_default(false) - .prompt() - .context("Confirmation prompt failed")?; - - if !proceed { - anyhow::bail!("Aborted: destination not empty."); - } - } - } else { - fs::create_dir_all(destination) - .with_context(|| format!("Failed to create destination {}", destination.display()))?; - } - Ok(()) -} - -/// Emit a single component file into the current directory. -fn emit_component(component: &Component) -> Result<()> { - let cwd = std::env::current_dir().context("Failed to determine current directory")?; - - let project_name = cwd - .file_name() - .and_then(|n| n.to_str()) - .map(sanitize) - .unwrap_or_else(|| "project".to_string()); - - let output = cwd.join(component.output_path); - - if output.exists() { - let proceed = Confirm::new(&format!( - "'{}' already exists. Overwrite?", - component.output_path - )) - .with_default(false) - .prompt() - .context("Confirmation prompt failed")?; - - if !proceed { - anyhow::bail!("Aborted."); - } - } - - let values = collect_fields_for_component(component)?; - let ctx = make_tera_context(values, &project_name); - - let rendered = Tera::one_off(component.template, &ctx, false) - .with_context(|| format!("Failed to render template for '{}'", component.name))?; - - if let Some(parent) = output.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&output, rendered)?; - - println!("Wrote {}", component.output_path); - Ok(()) -} - -/// Scaffold a default template directory at `path`. -fn init_default_template(path: &Path) -> Result<()> { - fs::create_dir_all(path)?; - fs::create_dir_all(path.join("src"))?; - fs::create_dir_all(path.join("figures"))?; - fs::create_dir_all(path.join("R"))?; - fs::create_dir_all(path.join("output"))?; - - File::create(path.join(".here"))?.write_all(b"")?; - fs::write(path.join("README.md"), templates::README)?; - fs::write(path.join("main.tex"), templates::MAIN_TEX)?; - fs::write(path.join("references.bib"), templates::REFERENCES_BIB)?; - fs::write(path.join("R").join("example.R"), templates::R_EXAMPLE)?; - fs::write(path.join("fields.toml"), templates::FIELDS_TOML)?; - fs::write(path.join("Makefile"), templates::MAKEFILE)?; - fs::write(path.join("Project.toml"), templates::PROJECT_TOML)?; - fs::write( - path.join("src").join("example.jl"), - templates::JULIA_EXAMPLE, - )?; - fs::write(path.join("Taskfile.yml"), templates::TASKFILE)?; - - Ok(()) -} +mod cli; +mod commands; +mod components; +mod manifest; +mod render; +mod templates; + +use cli::{Cli, Commands}; +use commands::{emit_component, ensure_destination_ready, init_default_template}; +use components::component_for_command; +use manifest::{collect_field_values, load_fields_manifest}; +use render::{copy_template, make_tera_context}; fn main() -> Result<()> { let cli = Cli::parse(); diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..46c301d --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,133 @@ +use anyhow::{Context, Result}; +use inquire::Text; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Top-level structure of a `fields.toml` manifest. +/// +/// Each entry in `fields` describes one variable that the TUI +/// will prompt the user for. The collected values are injected +/// into the Tera rendering context alongside the built-in +/// variables (`project_name`, `today`, `year`). +#[derive(Debug, Deserialize)] +pub struct FieldsManifest { + pub fields: Vec, +} + +/// A single field definition inside `fields.toml`. +/// +/// Example TOML: +/// +/// ```toml +/// [[fields]] +/// name = "author" +/// prompt = "Author name" +/// default = "" +/// required = true +/// ``` +#[derive(Debug, Deserialize)] +pub struct Field { + /// Variable name referenced in Tera templates as `{{ name }}`. + pub name: String, + /// Human-readable prompt displayed in the TUI. + pub prompt: String, + /// Optional default value pre-filled in the prompt. + pub default: Option, + /// When true, an empty response is rejected. + #[serde(default)] + pub required: bool, +} + +/// Load `fields.toml` from the template root, if it exists. +pub fn load_fields_manifest(template_root: &Path) -> Result> { + let manifest_path = template_root.join("fields.toml"); + if manifest_path.exists() { + let s = fs::read_to_string(&manifest_path) + .with_context(|| format!("Failed to read {}", manifest_path.display()))?; + let manifest: FieldsManifest = toml::from_str(&s) + .with_context(|| format!("Failed to parse {}", manifest_path.display()))?; + Ok(Some(manifest)) + } else { + Ok(None) + } +} + +/// Prompt the user for each field defined in the manifest. +pub fn collect_field_values(manifest: &FieldsManifest) -> Result> { + let mut values = HashMap::new(); + for f in &manifest.fields { + let mut prompt = Text::new(&f.prompt); + if let Some(default) = &f.default { + prompt = prompt.with_default(default); + } + if f.required { + prompt = prompt.with_help_message("Required"); + } + let val = prompt + .prompt() + .with_context(|| format!("Prompt failed for '{}'", f.name))?; + if f.required && val.trim().is_empty() { + anyhow::bail!("Field '{}' is required and cannot be empty", f.name); + } + values.insert(f.name.clone(), val); + } + Ok(values) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn load_fields_manifest_valid() { + let dir = tempdir().unwrap(); + let manifest_path = dir.path().join("fields.toml"); + let mut f = std::fs::File::create(&manifest_path).unwrap(); + writeln!( + f, + r#" +[[fields]] +name = "title" +prompt = "Project title" +default = "My Analysis" +required = true + +[[fields]] +name = "author" +prompt = "Author name" +required = false +"# + ) + .unwrap(); + + let result = load_fields_manifest(dir.path()).unwrap(); + assert!(result.is_some()); + let manifest = result.unwrap(); + assert_eq!(manifest.fields.len(), 2); + assert_eq!(manifest.fields[0].name, "title"); + assert_eq!(manifest.fields[0].default.as_deref(), Some("My Analysis")); + assert!(manifest.fields[0].required); + assert_eq!(manifest.fields[1].name, "author"); + assert!(!manifest.fields[1].required); + } + + #[test] + fn load_fields_manifest_missing() { + let dir = tempdir().unwrap(); + let result = load_fields_manifest(dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn load_fields_manifest_invalid_toml() { + let dir = tempdir().unwrap(); + let manifest_path = dir.path().join("fields.toml"); + std::fs::write(&manifest_path, b"not valid toml [[[").unwrap(); + let result = load_fields_manifest(dir.path()); + assert!(result.is_err()); + } +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..6eb78c2 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,246 @@ +use anyhow::{Context, Result}; +use chrono::Local; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::Path; +use tera::{Context as TeraContext, Tera}; +use walkdir::WalkDir; + +/// Return `true` if the entire file at `path` is valid UTF-8. +pub fn is_text_utf8(path: &Path) -> bool { + let mut f = match File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let mut buf = Vec::new(); + if f.read_to_end(&mut buf).is_err() { + return false; + } + std::str::from_utf8(&buf).is_ok() +} + +/// Build a [`TeraContext`] from collected field values. +/// +/// Injects the built-in variables `project_name`, `today` (YYYY-MM-DD), +/// and `year` in addition to any user-supplied values. +pub fn make_tera_context(mut values: HashMap, project_name: &str) -> TeraContext { + let today = Local::now().format("%Y-%m-%d").to_string(); + let year = Local::now().format("%Y").to_string(); + + values.insert("project_name".to_string(), project_name.to_string()); + values.insert("today".to_string(), today); + values.insert("year".to_string(), year); + + TeraContext::from_serialize(values).expect("Serializable context") +} + +/// Process a single file from the template directory. +/// +/// - Skips `fields.toml` entirely. +/// - Renders UTF-8 files through Tera when `enable_rendering` is true. +/// - Binary-copies non-UTF-8 files or when rendering is disabled. +pub fn render_or_copy_file( + src: &Path, + dst: &Path, + tera_ctx: &TeraContext, + enable_rendering: bool, +) -> Result<()> { + if src.file_name().and_then(|n| n.to_str()) == Some("fields.toml") { + return Ok(()); + } + + fs::create_dir_all(dst.parent().unwrap())?; + + if enable_rendering && is_text_utf8(src) { + let mut s = String::new(); + File::open(src)?.read_to_string(&mut s)?; + let rendered = Tera::one_off(&s, tera_ctx, false) + .with_context(|| format!("Failed to render template file: {}", src.display()))?; + let mut out = File::create(dst)?; + out.write_all(rendered.as_bytes())?; + } else { + fs::copy(src, dst)?; + } + Ok(()) +} + +/// Recursively copy a template directory to `destination`. +/// +/// Applies Tera rendering to UTF-8 files when `enable_rendering` is true, +/// and skips `fields.toml`. +pub fn copy_template( + template_root: &Path, + destination: &Path, + tera_ctx: &TeraContext, + enable_rendering: bool, +) -> Result<()> { + for entry in WalkDir::new(template_root) + .into_iter() + .filter_map(|e| e.ok()) + { + let src_path = entry.path(); + if src_path == template_root { + continue; + } + let rel = src_path.strip_prefix(template_root).unwrap(); + let dst_path = destination.join(rel); + + if src_path.is_dir() { + fs::create_dir_all(&dst_path)?; + } else { + render_or_copy_file(src_path, &dst_path, tera_ctx, enable_rendering)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use tempfile::tempdir; + + fn simple_ctx() -> TeraContext { + let mut vals = HashMap::new(); + vals.insert("title".to_string(), "Test Title".to_string()); + make_tera_context(vals, "test_project") + } + + // --- is_text_utf8 --- + + #[test] + fn is_text_utf8_valid() { + let dir = tempdir().unwrap(); + let p = dir.path().join("text.txt"); + fs::write(&p, "hello world").unwrap(); + assert!(is_text_utf8(&p)); + } + + #[test] + fn is_text_utf8_binary() { + let dir = tempdir().unwrap(); + let p = dir.path().join("bin.dat"); + fs::write(&p, b"\xff\xfe\x00\x01").unwrap(); + assert!(!is_text_utf8(&p)); + } + + #[test] + fn is_text_utf8_missing() { + let p = PathBuf::from("/tmp/ptmp_test_nonexistent_abc123.txt"); + assert!(!is_text_utf8(&p)); + } + + // --- make_tera_context --- + + #[test] + fn make_tera_context_injects_builtins() { + let ctx = make_tera_context(HashMap::new(), "myproject"); + let json = ctx.clone().into_json(); + assert_eq!(json["project_name"], "myproject"); + let today = json["today"].as_str().unwrap(); + assert!(today.len() == 10, "today should be YYYY-MM-DD, got {today}"); + assert!(today.chars().nth(4) == Some('-')); + let year = json["year"].as_str().unwrap(); + assert_eq!(year.len(), 4); + } + + #[test] + fn make_tera_context_preserves_user_values() { + let mut vals = HashMap::new(); + vals.insert("author".to_string(), "Jane Doe".to_string()); + let ctx = make_tera_context(vals, "proj"); + let json = ctx.into_json(); + assert_eq!(json["author"], "Jane Doe"); + } + + // --- render_or_copy_file --- + + #[test] + fn render_or_copy_file_renders_template() { + let src_dir = tempdir().unwrap(); + let dst_dir = tempdir().unwrap(); + let src = src_dir.path().join("file.md"); + let dst = dst_dir.path().join("file.md"); + fs::write(&src, "# {{ title }}").unwrap(); + + render_or_copy_file(&src, &dst, &simple_ctx(), true).unwrap(); + + let content = fs::read_to_string(&dst).unwrap(); + assert_eq!(content, "# Test Title"); + } + + #[test] + fn render_or_copy_file_skips_fields_toml() { + let src_dir = tempdir().unwrap(); + let dst_dir = tempdir().unwrap(); + let src = src_dir.path().join("fields.toml"); + let dst = dst_dir.path().join("fields.toml"); + fs::write(&src, "[[fields]]").unwrap(); + + render_or_copy_file(&src, &dst, &simple_ctx(), true).unwrap(); + + assert!( + !dst.exists(), + "fields.toml should not be copied to destination" + ); + } + + #[test] + fn render_or_copy_file_copies_without_rendering() { + let src_dir = tempdir().unwrap(); + let dst_dir = tempdir().unwrap(); + let src = src_dir.path().join("file.txt"); + let dst = dst_dir.path().join("file.txt"); + let raw = "{{ title }} is not rendered"; + fs::write(&src, raw).unwrap(); + + render_or_copy_file(&src, &dst, &simple_ctx(), false).unwrap(); + + let content = fs::read_to_string(&dst).unwrap(); + assert_eq!(content, raw); + } + + #[test] + fn render_or_copy_file_binary_copy() { + let src_dir = tempdir().unwrap(); + let dst_dir = tempdir().unwrap(); + let src = src_dir.path().join("image.png"); + let dst = dst_dir.path().join("image.png"); + let bytes: Vec = vec![0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe]; + fs::write(&src, &bytes).unwrap(); + + render_or_copy_file(&src, &dst, &simple_ctx(), true).unwrap(); + + let copied = fs::read(&dst).unwrap(); + assert_eq!(copied, bytes); + } + + // --- copy_template --- + + #[test] + fn copy_template_preserves_structure() { + let src_dir = tempdir().unwrap(); + let dst_dir = tempdir().unwrap(); + + let sub = src_dir.path().join("subdir"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("note.md"), "# {{ title }}").unwrap(); + fs::write(src_dir.path().join("README.md"), "# {{ title }}").unwrap(); + fs::write(src_dir.path().join("fields.toml"), "[[fields]]").unwrap(); + + copy_template(src_dir.path(), dst_dir.path(), &simple_ctx(), true).unwrap(); + + assert!(dst_dir.path().join("subdir").is_dir()); + assert!(dst_dir.path().join("subdir/note.md").exists()); + assert!(dst_dir.path().join("README.md").exists()); + assert!( + !dst_dir.path().join("fields.toml").exists(), + "fields.toml must be excluded" + ); + + let readme = fs::read_to_string(dst_dir.path().join("README.md")).unwrap(); + assert_eq!(readme, "# Test Title"); + } +} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..b9abcc8 --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,18 @@ +pub const README: &str = include_str!("templates/readme.md"); +pub const MAIN_TEX: &str = include_str!("templates/main.tex"); +pub const REFERENCES_BIB: &str = include_str!("templates/references.bib"); +pub const MAKEFILE: &str = include_str!("templates/Makefile"); +pub const TASKFILE: &str = include_str!("templates/Taskfile.yml"); +pub const R_EXAMPLE: &str = include_str!("templates/example.R"); +pub const JULIA_EXAMPLE: &str = include_str!("templates/example.jl"); +pub const PROJECT_TOML: &str = include_str!("templates/Project.toml"); +pub const FIELDS_TOML: &str = include_str!("templates/fields.toml"); +pub const SLURM: &str = include_str!("templates/example.slurm"); +pub const GITIGNORE: &str = include_str!("templates/gitignore"); +pub const EDITORCONFIG: &str = include_str!("templates/editorconfig"); +pub const CITATION: &str = include_str!("templates/CITATION.cff"); +pub const LICENSE: &str = include_str!("templates/LICENSE"); +pub const QUARTO: &str = include_str!("templates/report.qmd"); +pub const SLIDES: &str = include_str!("templates/slides.qmd"); +pub const REVEAL_THEME: &str = include_str!("templates/reveal-theme.scss"); +pub const LOGO_WIDE: &[u8] = include_bytes!("templates/logo-wide.png"); diff --git a/src/templates/CITATION.cff b/src/templates/CITATION.cff new file mode 100644 index 0000000..5a7a746 --- /dev/null +++ b/src/templates/CITATION.cff @@ -0,0 +1,8 @@ +cff-version: 1.2.0 +title: "{{ title }}" +message: "If you use this software, please cite it as below." +type: software +authors: + - name: "{{ author }}" + orcid: "{{ orcid }}" +date-released: "{{ today }}" diff --git a/src/templates/LICENSE b/src/templates/LICENSE new file mode 100644 index 0000000..b100cf9 --- /dev/null +++ b/src/templates/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {{ year }} {{ author }} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/templates/Makefile b/src/templates/Makefile new file mode 100644 index 0000000..74bf46b --- /dev/null +++ b/src/templates/Makefile @@ -0,0 +1,77 @@ +############################################################ +# Project Makefile — generated via template copier +# +# Targets: +# make pdf - build the PDF with latexmk +# make watch - watch and rebuild on changes (latexmk -pvc) +# make figures - run all R/*.R scripts +# make open - open the generated PDF +# make clean - clean LaTeX aux files in output/ +# make distclean - clean plus remove output/* artifacts +# make check - print configuration +############################################################ + +# --- Configuration (Tera-injected) --- +MAIN := {{ main_file }} +OUTDIR := output +FIGDIR := figures + +# LaTeX engine selection: 'pdf', 'xelatex', or 'lualatex' +# (Collected via TUI; defaults to 'pdf') +ENGINE_FLAG := {% if latex_engine == "xelatex" %}-xelatex{% elif latex_engine == "lualatex" %}-lualatex{% else %}-pdf{% endif %} + +LATEXMK := latexmk +RSCRIPTS := $(wildcard R/*.R) +PDF := $(OUTDIR)/$(MAIN:.tex=.pdf) + +# Detect platform for 'open' +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + OPEN_CMD := open +else + OPEN_CMD := xdg-open +endif + +.PHONY: all pdf watch figures open clean distclean check ensure_dirs + +all: pdf + +ensure_dirs: + @mkdir -p $(OUTDIR) $(FIGDIR) + +# Build PDF with latexmk +pdf: ensure_dirs $(PDF) + +$(PDF): $(MAIN) + $(LATEXMK) $(ENGINE_FLAG) -interaction=nonstopmode -halt-on-error -file-line-error -outdir=$(OUTDIR) $(MAIN) + +# Continuous preview (rebuild on file changes) +watch: ensure_dirs + $(LATEXMK) $(ENGINE_FLAG) -pvc -interaction=nonstopmode -file-line-error -outdir=$(OUTDIR) $(MAIN) + +# Run all R scripts in R/ +figures: ensure_dirs + @for f in $(RSCRIPTS); do \ + echo "Running $$f"; \ + Rscript "$$f"; \ + done + +# Open the built PDF (macOS: open; Linux: xdg-open) +open: pdf + $(OPEN_CMD) "$(PDF)" + +# Clean LaTeX intermediate files from output/ +clean: + $(LATEXMK) -C -outdir=$(OUTDIR) + @rm -f "$(PDF)" + +# Deep clean all artifacts in output/ +distclean: clean + @rm -rf $(OUTDIR)/* + +check: + @echo "Main file: $(MAIN)" + @echo "Output dir: $(OUTDIR)" + @echo "Figures dir: $(FIGDIR)" + @echo "Engine flag: $(ENGINE_FLAG)" + @echo "R scripts: $(RSCRIPTS)" diff --git a/src/templates/Project.toml b/src/templates/Project.toml new file mode 100644 index 0000000..7cfbcc6 --- /dev/null +++ b/src/templates/Project.toml @@ -0,0 +1,7 @@ +name = "{{ title }}" +authors = ["{{ author }}"] + +[deps] +CSV = "0.10" +DataFrames = "1.6" +Plots = "1.40" diff --git a/src/templates/Taskfile.yml b/src/templates/Taskfile.yml new file mode 100644 index 0000000..51a8ee4 --- /dev/null +++ b/src/templates/Taskfile.yml @@ -0,0 +1,93 @@ +# Taskfile for research project: {{ title }} +# https://taskfile.dev +version: "3" + +vars: + MAIN: "{{ main_file }}" + OUTDIR: output + FIGDIR: figures + ENGINE: + sh: | + case "{{ latex_engine }}" in + xelatex) echo "-xelatex" ;; + lualatex) echo "-lualatex" ;; + *) echo "-pdf" ;; + esac + +{% raw %} +tasks: + default: + desc: Build the PDF + deps: [latex] + + latex: + desc: Build PDF with latexmk + cmds: + - mkdir -p {{.OUTDIR}} + - >- + latexmk {{.ENGINE}} + -interaction=nonstopmode + -halt-on-error + -file-line-error + -outdir={{.OUTDIR}} + {{.MAIN}} + + watch: + desc: Continuous rebuild on file changes + cmds: + - >- + latexmk {{.ENGINE}} + -pvc + -interaction=nonstopmode + -file-line-error + -outdir={{.OUTDIR}} + {{.MAIN}} + + tikz: + desc: Render TikZ standalone figures + cmds: + - mkdir -p {{.FIGDIR}} + - | + for f in figures/*.tex; do + [ -f "$f" ] || continue + latexmk {{.ENGINE}} \ + -interaction=nonstopmode \ + -outdir={{.FIGDIR}} "$f" + done + + r: + desc: Run all R scripts in R/ + cmds: + - | + for f in R/*.R; do + [ -f "$f" ] || continue + echo "Running $f" + Rscript "$f" + done + + julia: + desc: Run Julia scripts in src/ + cmds: + - | + for f in src/*.jl; do + [ -f "$f" ] || continue + echo "Running $f" + julia --project=. "$f" + done + + figures: + desc: Generate all figures (R + Julia) + deps: [r, julia] + + clean: + desc: Clean LaTeX auxiliary files + cmds: + - latexmk -C -outdir={{.OUTDIR}} + - rm -f {{.OUTDIR}}/{{.MAIN}} + + open: + desc: Open the generated PDF + deps: [latex] + cmds: + - open {{.OUTDIR}}/{{.MAIN}} +{% endraw %} diff --git a/src/templates/editorconfig b/src/templates/editorconfig new file mode 100644 index 0000000..8526c4a --- /dev/null +++ b/src/templates/editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 +max_line_length = 80 + +[*.{md,qmd,Rmd,tex}] +max_line_length = off +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/src/templates/example.R b/src/templates/example.R new file mode 100644 index 0000000..68940d8 --- /dev/null +++ b/src/templates/example.R @@ -0,0 +1,13 @@ +# Example R script +# Project: {{ title }} +# Author: {{ author }} +# Date: {{ today }} + +library(tidyverse) +library(data.table) +library(here) +# library(gtsummary) +# library(flextable) +# library(officer) + +message(paste("Project root is:", here::here())) diff --git a/src/templates/example.jl b/src/templates/example.jl new file mode 100644 index 0000000..7cad69b --- /dev/null +++ b/src/templates/example.jl @@ -0,0 +1,8 @@ +# Example Julia script +# Project: {{ title }} +# Author: {{ author }} +# Date: {{ today }} + +using CSV, DataFrames, Plots + +println("Project root: ", @__DIR__) diff --git a/src/templates/example.slurm b/src/templates/example.slurm new file mode 100644 index 0000000..3f9ba74 --- /dev/null +++ b/src/templates/example.slurm @@ -0,0 +1,12 @@ +#!/bin/bash -l +#SBATCH --partition=small +#SBATCH --job-name={{ project_name }} +#SBATCH --mail-type=BEGIN,END,FAIL +#SBATCH --mail-user={{ slurm_email }} +#SBATCH --account={{ slurm_account }} +#SBATCH --nodes=1 +#SBATCH --tasks-per-node=20 +#SBATCH --mem=128gb +#SBATCH --time=00-14:00:00 + +echo "Running {{ title }}" diff --git a/src/templates/fields.toml b/src/templates/fields.toml new file mode 100644 index 0000000..2ab6000 --- /dev/null +++ b/src/templates/fields.toml @@ -0,0 +1,32 @@ + +# Define fields collected via TUI and injected into Tera templates +[[fields]] +name = "title" +prompt = "Project title" +default = "My Analysis" +required = true + +[[fields]] +name = "author" +prompt = "Author name" +default = "" +required = true + +[[fields]] +name = "affiliation" +prompt = "Affiliation (e.g., Institution/Lab)" +default = "" +required = false + + +[[fields]] +name = "main_file" +prompt = "Main LaTeX file name" +default = "main.tex" +required = false + +[[fields]] +name = "latex_engine" +prompt = "LaTeX engine (pdf/xelatex/lualatex)" +default = "pdf" +required = true diff --git a/src/templates/gitignore b/src/templates/gitignore new file mode 100644 index 0000000..8dc2732 --- /dev/null +++ b/src/templates/gitignore @@ -0,0 +1,41 @@ +# R +.Rhistory +.Rdata +.RData +.Rproj.user/ +.Renviron + +# LaTeX +*.aux +*.bbl +*.blg +*.fdb_latexmk +*.fls +*.log +*.out +*.synctex.gz +*.toc +*.nav +*.snm +*.vrb + +# Julia +Manifest.toml + +# Python +__pycache__/ +*.pyc +.venv/ + +# OS +.DS_Store +Thumbs.db + +# Editor +*.swp +*~ +.vscode/ +.idea/ + +# Output +output/ diff --git a/src/templates/logo-wide.png b/src/templates/logo-wide.png new file mode 100644 index 0000000..bf3fbe7 Binary files /dev/null and b/src/templates/logo-wide.png differ diff --git a/src/templates/main.tex b/src/templates/main.tex new file mode 100644 index 0000000..da22d03 --- /dev/null +++ b/src/templates/main.tex @@ -0,0 +1,34 @@ +\documentclass[12pt,letterpaper]{article} +\usepackage{setspace} +% Packages +\usepackage[margin=1in]{geometry} +\usepackage{graphicx} % Required for inserting images +\usepackage{comment} +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{lmodern} +\usepackage{natbib} + +\title{ {{ title }} } +\author{ {{ author }} \\ {{ affiliation }} } +\date{ {{ today }} } + +\begin{document} +\maketitle + +\section{Introduction} +Write your introduction here. + +\section{Methods} +Describe your methods here. + +\section{Results} +Present results here. Figures can be stored in `figures/`. + +\section{Discussion} +Discuss findings here. + +\bibliographystyle{plain} +\bibliography{references} + +\end{document} diff --git a/src/templates/readme.md b/src/templates/readme.md new file mode 100644 index 0000000..7649e57 --- /dev/null +++ b/src/templates/readme.md @@ -0,0 +1,20 @@ +# {{ title }} + +**Author:** {{ author }} +**Affiliation:** {{ affiliation }} +**Date:** {{ today }} + +## Overview +This project was generated on {{ today }} ({{ year }}) via the template copier. + +## Structure +- `src/` — source code (Julia scripts, etc.) +- `figures/` — plots and figures +- `R/` — R scripts +- `output/` — generated outputs/results + +## Notes +- The root `.here` file marks the project directory for the R `{here}` package. +- Julia project managed via `Project.toml`. +- LaTeX main file: `main.tex` +- Run `task --list` to see available development tasks. diff --git a/src/templates/references.bib b/src/templates/references.bib new file mode 100644 index 0000000..d31bb9b --- /dev/null +++ b/src/templates/references.bib @@ -0,0 +1,2 @@ +% Bibliography for {{ title }} +% Add BibTeX entries below diff --git a/src/templates/report.qmd b/src/templates/report.qmd new file mode 100644 index 0000000..3c0fddf --- /dev/null +++ b/src/templates/report.qmd @@ -0,0 +1,19 @@ +--- +title: "{{ title }}" +author: "{{ author }}" +date: today +format: + pdf: + documentclass: article +bibliography: references.bib +--- + +## Introduction + +## Methods + +## Results + +## Discussion + +## References diff --git a/src/templates/reveal-theme.scss b/src/templates/reveal-theme.scss new file mode 100644 index 0000000..5c937e7 --- /dev/null +++ b/src/templates/reveal-theme.scss @@ -0,0 +1,160 @@ +/*-- scss:defaults --*/ + +// Wake Forest brand colors +$wakegold: #8C6D2C; +$goldonblack: #cc9f2e; +$wakegray: #54565A; +$wakeyellow: #FDC314; +$wakegreen: #6BA539; +$waketeal: #008C95; +$wakelightgray: #CFD2D3; +$wakecoolgray: #A7A8A9; +$wakered: #861F41; +$wakenavy: #002B49; +$black: #000000; +$white: #FFFFFF; + +// Atrium colors +$atriumteal: #00818B; +$atriumblue: #004A98; + +// Presentation palette +$body-bg: $white; +$body-color: $wakegray; +$link-color: $wakegold; + +$presentation-heading-color: $black; +$presentation-heading-font: Garamond, Georgia, serif; +$presentation-font-size-root: 32px; +$font-family-sans-serif: "Nunito Sans", Arial, sans-serif; +$font-family-serif: Garamond, Georgia, serif; + +$presentation-h1-font-size: 1.6em; +$presentation-h2-font-size: 1.2em; +$presentation-h3-font-size: 1.0em; + +// Slide layout +$presentation-block-margin: 12px; + +/*-- scss:rules --*/ + +// Headings +.reveal h1, +.reveal h2, +.reveal h3 { + font-family: $presentation-heading-font; + color: $black; + text-transform: none; + letter-spacing: normal; +} + +.reveal h2 { + border-bottom: 2px solid $wakegold; + padding-bottom: 0.2em; + margin-bottom: 0.6em; +} + +// Title slide +.reveal .title-slide, +.reveal #title-slide { + background-color: $black; + color: $white; +} + +.reveal #title-slide .title { + color: $white; + font-family: $font-family-sans-serif; + font-size: 1.4em; + font-weight: 600; +} + +.reveal #title-slide .subtitle { + color: $goldonblack; + font-family: $font-family-serif; + font-size: 0.9em; + font-style: italic; +} + +.reveal #title-slide .author { + color: $wakelightgray; + font-size: 0.7em; + margin-top: 1em; +} + +// Gold accent on slide bullets +.reveal ul > li::marker { + color: $wakegold; +} + +.reveal ol > li::marker { + color: $wakegold; +} + +// Strong / bold text +.reveal strong { + color: $black; +} + +// Code blocks +.reveal pre code { + background-color: #f5f2ee; + border-left: 4px solid $wakegold; + font-size: 0.7em; +} + +// Blockquotes +.reveal blockquote { + border-left: 4px solid $wakegold; + background: rgba(140, 109, 44, 0.08); + padding: 0.5em 1em; + font-style: italic; + color: $wakegray; +} + +// Footer / logo bar +.reveal .slide-logo { + max-height: 45px !important; + bottom: 8px; + right: 12px; +} + +// Slide number +.reveal .slide-number { + color: $wakecoolgray; + font-size: 0.55em; + background-color: transparent; +} + +// Progress bar +.reveal .progress span { + background: $wakegold; +} + +// Title slide logos container +.title-logos { + display: flex; + align-items: center; + justify-content: center; + gap: 2em; + margin-top: 1.5em; +} + +.title-logos img { + height: 50px; + filter: brightness(0) invert(1); +} + +// Custom dark title slide for Outbreak deck +.reveal section.outbreak-title-slide { + background-color: $black !important; + color: $white; +} + +.reveal section.outbreak-title-slide h2 { + color: $white; + border-color: $goldonblack; +} + +.reveal section.outbreak-title-slide strong { + color: $goldonblack; +} diff --git a/src/templates/slides.qmd b/src/templates/slides.qmd new file mode 100644 index 0000000..c9769d7 --- /dev/null +++ b/src/templates/slides.qmd @@ -0,0 +1,22 @@ +--- +title: "{{ title }}" +author: "{{ author }}" +date: today +format: + revealjs: + theme: reveal-theme.scss + slide-number: true + transition: slide + logo: assets/logo-wide.png + progress: true + width: 1280 + height: 720 +--- + +## Overview + +## Methods + +## Results + +## Summary