Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# will have compiled files and executables
debug
target

md-notes.md
# These are backup files generated by rustfmt
**/*.rs.bk

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

[package]
name = "ptmp"
version = "0.1.3"
version = "1.1.2"
edition = "2021"
authors = ["Michael DeWitt <michael.dewitt@wfusm.edu>"]
description = "Copy a template directory and render Tera placeholders via a TUI."
Expand Down
15 changes: 14 additions & 1 deletion README.Md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,24 @@ ptmp new \
--destination ~/projects/WNV-modeling
```

### Set personal defaults

Run once to save your name, email, ORCID, and other fields.
These become the pre-filled defaults whenever ptmp prompts you.

```bash
ptmp config
```

Defaults are stored in `~/.config/ptmp/defaults.toml`.

### Emit a single component

Write one file into the current working directory, prompting only for the fields that file needs.

```bash
ptmp taskfile # Taskfile.yml
ptmp latex # main.tex
ptmp latex # main.tex + references.bib (Nature-style manuscript)
ptmp makefile # Makefile
ptmp readme # README.md
ptmp r # R/example.R
Expand All @@ -48,6 +59,8 @@ ptmp citation # CITATION.cff
ptmp license # LICENSE (MIT)
ptmp quarto # report.qmd
ptmp slides # slides.qmd
ptmp docx # templates/ (pandoc reference docx + CSL)
ptmp justfile # justfile (pandoc rendering tasks)
```

## Template rendering
Expand Down
12 changes: 12 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,16 @@ pub enum Commands {

/// Emit a Quarto revealjs slide deck (slides.qmd) into the current directory
Slides,

/// Emit a review Taskfile.yml for academic peer review
Review,

/// Emit pandoc reference docx and CSL into templates/
Docx,

/// Emit a justfile with pandoc rendering tasks
Justfile,

/// Set personal defaults (author, email, ORCID, etc.)
Config,
}
79 changes: 72 additions & 7 deletions src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,39 @@ pub static KNOWN_FIELDS: &[FieldDef] = &[
FieldDef {
name: "slurm_account",
prompt: "SLURM account/group",
default: "",
default: "kortessisGrp",
required: true,
},
FieldDef {
name: "slurm_email",
prompt: "Email for SLURM notifications",
default: "",
default: "dewime23@wfu.edu",
required: true,
},
FieldDef {
name: "email",
prompt: "Email address",
default: "michael.dewitt@wfusm.edu",
required: false,
},
FieldDef {
name: "orcid",
prompt: "ORCID identifier (e.g., 0000-0001-2345-6789)",
default: "",
prompt: "ORCID identifier (e.g., 0000-0001-8940-1967)",
default: "0000-0001-8940-1967",
required: false,
},
FieldDef {
name: "pdf_path",
prompt: "Path to the PDF to review",
default: "",
required: true,
},
FieldDef {
name: "reviewer_name",
prompt: "Reviewer name",
default: "",
required: true,
},
];

pub struct Component {
Expand All @@ -84,8 +102,8 @@ pub static COMPONENTS: &[Component] = &[
name: "latex",
output_path: "main.tex",
template: templates::MAIN_TEX,
fields: &["title", "author", "affiliation"],
extra_text: &[],
fields: &["title", "author", "affiliation", "email"],
extra_text: &[("references.bib", templates::REFERENCES_BIB)],
extra_binary: &[],
},
Component {
Expand Down Expand Up @@ -184,6 +202,33 @@ pub static COMPONENTS: &[Component] = &[
extra_text: &[("reveal-theme.scss", templates::REVEAL_THEME)],
extra_binary: &[("assets/logo-wide.png", templates::LOGO_WIDE)],
},
Component {
name: "review",
output_path: "Taskfile.yml",
template: templates::REVIEW_TASKFILE,
fields: &["pdf_path", "reviewer_name"],
extra_text: &[],
extra_binary: &[],
},
Component {
name: "docx",
output_path: "templates/nature.csl",
template: templates::NATURE_CSL,
fields: &["title"],
extra_text: &[],
extra_binary: &[(
"templates/pandoc-reference-arial11-0p5in.docx",
templates::PANDOC_REFERENCE_DOCX,
)],
},
Component {
name: "justfile",
output_path: "justfile",
template: templates::JUSTFILE,
fields: &["title"],
extra_text: &[],
extra_binary: &[],
},
];

/// Map a CLI command variant to its registered [`Component`], if applicable.
Expand All @@ -205,21 +250,38 @@ pub fn component_for_command(cmd: &Commands) -> Option<&'static Component> {
Commands::License => "license",
Commands::Quarto => "quarto",
Commands::Slides => "slides",
Commands::Review => "review",
Commands::Docx => "docx",
Commands::Justfile => "justfile",
_ => return None,
};
COMPONENTS.iter().find(|c| c.name == name)
}

/// Prompt only for the fields required by a component.
///
/// User defaults from `~/.config/ptmp/defaults.toml` override
/// hardcoded defaults so the user can just press Enter.
pub fn collect_fields_for_component(component: &Component) -> Result<HashMap<String, String>> {
collect_fields_with_defaults(component, &crate::config::load_user_defaults()?)
}

/// Inner implementation that accepts explicit user defaults
/// (makes unit-testing possible without touching the filesystem).
pub fn collect_fields_with_defaults(
component: &Component,
user_defaults: &HashMap<String, String>,
) -> Result<HashMap<String, String>> {
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() {
if let Some(user_val) = user_defaults.get(needed) {
prompt = prompt.with_default(user_val);
} else if !def.default.is_empty() {
prompt = prompt.with_default(def.default);
}
if def.required {
Expand Down Expand Up @@ -267,6 +329,9 @@ mod tests {
(Commands::License, "license"),
(Commands::Quarto, "quarto"),
(Commands::Slides, "slides"),
(Commands::Review, "review"),
(Commands::Docx, "docx"),
(Commands::Justfile, "justfile"),
];
for (cmd, expected_name) in cases {
let comp = component_for_command(cmd)
Expand Down
115 changes: 115 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

/// Path to the user defaults file.
pub fn config_path() -> PathBuf {
dirs_fallback().join("defaults.toml")
}

/// Best-effort XDG / macOS config directory.
fn dirs_fallback() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("ptmp")
} else if let Some(home) = home_dir() {
home.join(".config").join("ptmp")
} else {
PathBuf::from(".config").join("ptmp")
}
}

fn home_dir() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}

/// Load user defaults from `~/.config/ptmp/defaults.toml`.
///
/// Returns an empty map if the file does not exist.
pub fn load_user_defaults() -> Result<HashMap<String, String>> {
load_defaults_from(&config_path())
}

/// Load defaults from an arbitrary path (used by tests).
pub fn load_defaults_from(path: &std::path::Path) -> Result<HashMap<String, String>> {
if !path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let table: toml::Table = content
.parse()
.with_context(|| format!("Failed to parse config: {}", path.display()))?;
let mut map = HashMap::new();
for (key, val) in table {
if let toml::Value::String(s) = val {
if !s.is_empty() {
map.insert(key, s);
}
}
}
Ok(map)
}

/// Save user defaults to `~/.config/ptmp/defaults.toml`.
pub fn save_user_defaults(values: &HashMap<String, String>) -> Result<()> {
save_defaults_to(values, &config_path())
}

/// Save defaults to an arbitrary path (used by tests).
pub fn save_defaults_to(values: &HashMap<String, String>, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut table = toml::Table::new();
let mut keys: Vec<&String> = values.keys().collect();
keys.sort();
for key in keys {
table.insert(
key.clone(),
toml::Value::String(values.get(key).cloned().unwrap_or_default()),
);
}
let content = toml::to_string_pretty(&table).context("Failed to serialise config")?;
fs::write(path, content)
.with_context(|| format!("Failed to write config: {}", path.display()))?;
println!("Saved config to {}", path.display());
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn load_missing_file_returns_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("nope.toml");
let defaults = load_defaults_from(&path).unwrap();
assert!(defaults.is_empty());
}

#[test]
fn round_trip_save_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("defaults.toml");

let mut vals = HashMap::new();
vals.insert("author".to_string(), "Test Author".to_string());
vals.insert("email".to_string(), "test@example.com".to_string());
save_defaults_to(&vals, &path).unwrap();

assert!(path.exists());

let loaded = load_defaults_from(&path).unwrap();
assert_eq!(
loaded.get("author").map(|s| s.as_str()),
Some("Test Author")
);
assert_eq!(
loaded.get("email").map(|s| s.as_str()),
Some("test@example.com")
);
}
}
36 changes: 36 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use std::collections::HashMap;
mod cli;
mod commands;
mod components;
mod config;
mod manifest;
mod render;
mod templates;
Expand Down Expand Up @@ -102,6 +103,9 @@ fn main() -> Result<()> {
destination.display()
);
}
Commands::Config => {
run_config()?;
}
cmd => {
let component = component_for_command(cmd).expect("Unhandled command variant");
emit_component(component)?;
Expand All @@ -110,3 +114,35 @@ fn main() -> Result<()> {

Ok(())
}

/// Interactive config: prompt for every known field and save
/// to `~/.config/ptmp/defaults.toml`.
fn run_config() -> Result<()> {
use components::KNOWN_FIELDS;
use config::{load_user_defaults, save_user_defaults};
use inquire::Text;

let existing = load_user_defaults()?;
let mut values = HashMap::new();

println!(
"Set your personal defaults (press Enter to keep \
current value).\n"
);

for field in KNOWN_FIELDS {
let current = existing
.get(field.name)
.map(|s| s.as_str())
.unwrap_or(field.default);
let mut prompt = Text::new(field.prompt);
if !current.is_empty() {
prompt = prompt.with_default(current);
}
let val = prompt.prompt().unwrap_or_default();
values.insert(field.name.to_string(), val);
}

save_user_defaults(&values)?;
Ok(())
}
Loading
Loading