use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::model::{Priority, Status, Task};
use super::task::active_library_root;
#[derive(Debug, Default, Deserialize)]
pub struct TemplateMeta {
pub status: Option<Status>,
pub priority: Option<Priority>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug)]
pub struct Template {
pub meta: TemplateMeta,
pub body: String,
}
fn split_front_matter(content: &str) -> (Option<&str>, &str) {
let Some(rest) = content.strip_prefix("---\n") else {
return (None, content);
};
match rest.find("\n---") {
Some(end) => {
let yaml = &rest[..end];
let after = &rest[end + 4..];
(Some(yaml), after.trim_start_matches('\n'))
}
None => (None, content),
}
}
pub fn parse_template(content: &str) -> Result<Template> {
let (yaml, body) = split_front_matter(content);
let meta = match yaml {
Some(y) => serde_yaml::from_str(y)?,
None => TemplateMeta::default(),
};
Ok(Template { meta, body: body.to_string() })
}
pub fn apply(template: &Template, task: &mut Task, cli_priority_set: bool, cli_tags_set: bool) {
if !cli_priority_set
&& let Some(p) = template.meta.priority {
task.meta.priority = p;
}
if !cli_tags_set
&& let Some(tags) = &template.meta.tags {
task.meta.tags = tags.clone();
}
if let Some(status) = template.meta.status {
task.set_status(status);
}
let local = task.meta.created_at.with_timezone(&chrono::Local);
task.body = template
.body
.replace("{{title}}", &task.meta.title)
.replace("{{date}}", &local.format("%Y-%m-%d").to_string());
task.steps = crate::checkbox::parse_checkboxes(&task.body);
}
fn global_dir() -> Result<PathBuf> {
Ok(crate::config::base_dir()?.join("templates"))
}
fn lib_dir(root: &Path) -> PathBuf {
root.join(".templates")
}
pub fn resolve(name: &str) -> Result<PathBuf> {
let file = format!("{name}.md");
if let Ok(root) = active_library_root() {
let p = lib_dir(&root).join(&file);
if p.exists() {
return Ok(p);
}
}
let p = global_dir()?.join(&file);
if p.exists() {
return Ok(p);
}
Err(Error::TaskNotFound(format!("template '{name}'")))
}
pub fn load(name: &str) -> Result<Template> {
parse_template(&std::fs::read_to_string(resolve(name)?)?)
}
pub fn list() -> Result<()> {
let mut found = false;
let mut print_dir = |dir: &Path, origin: &str| -> Result<()> {
if !dir.exists() {
return Ok(());
}
let mut names: Vec<String> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_string()))
.collect();
names.sort();
for n in names {
println!("{n}\t({origin})");
found = true;
}
Ok(())
};
if let Ok(root) = active_library_root() {
print_dir(&lib_dir(&root), "library")?;
}
print_dir(&global_dir()?, "global")?;
if !found {
println!("no templates; create one with 'tasks template new <name>'");
}
Ok(())
}
pub fn show(name: &str) -> Result<()> {
print!("{}", std::fs::read_to_string(resolve(name)?)?);
Ok(())
}
const SKELETON: &str = "---\n\
# All fields are optional defaults; delete what you don't need.\n\
# id / seq / created_at are ignored: they are always generated.\n\
priority: medium\n\
tags: []\n\
# status: todo\n\
---\n\
\n\
- [ ] first step\n\
\n\
## {{title}}\n\
\n\
Created on {{date}}.\n";
pub fn new(name: &str, lib: bool, editor_flag: Option<String>) -> Result<()> {
let dir = if lib { lib_dir(&active_library_root()?) } else { global_dir()? };
let path = dir.join(format!("{name}.md"));
if path.exists() {
return Err(Error::LibraryExists(format!("template '{name}'")));
}
std::fs::create_dir_all(&dir)?;
std::fs::write(&path, SKELETON)?;
let config = crate::config::Config::load()?;
let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
crate::editor::open(&editor, &path)?;
parse_template(&std::fs::read_to_string(&path)?)?;
println!("created template '{name}' at {}", path.display());
Ok(())
}
pub fn edit(name: &str, editor_flag: Option<String>) -> Result<()> {
let path = resolve(name)?;
let config = crate::config::Config::load()?;
let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
crate::editor::open(&editor, &path)?;
parse_template(&std::fs::read_to_string(&path)?)?;
println!("saved template '{name}'");
Ok(())
}
pub fn remove(name: &str) -> Result<()> {
let path = resolve(name)?;
std::fs::remove_file(&path)?;
println!("removed template '{name}' ({})", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const BUG: &str = "---\npriority: high\ntags: [bug]\n---\n\n- [ ] 复现\n\n## {{title}}\n\n报于 {{date}}\n";
#[test]
fn parse_and_apply_defaults() {
let tpl = parse_template(BUG).unwrap();
let mut task = Task::new(1, "登录崩溃");
apply(&tpl, &mut task, false, false);
assert_eq!(task.meta.priority, Priority::High);
assert_eq!(task.meta.tags, vec!["bug"]);
assert_eq!(task.steps.len(), 1);
assert_eq!(task.steps[0].title, "复现");
assert!(task.body.contains("## 登录崩溃"));
assert!(!task.body.contains("{{date}}"));
}
#[test]
fn cli_values_win() {
let tpl = parse_template(BUG).unwrap();
let mut task = Task::new(1, "t");
task.meta.priority = Priority::Urgent;
task.meta.tags = vec!["cli".into()];
apply(&tpl, &mut task, true, true);
assert_eq!(task.meta.priority, Priority::Urgent);
assert_eq!(task.meta.tags, vec!["cli"]);
}
#[test]
fn status_uses_lifecycle_rules() {
let tpl = parse_template("---\nstatus: in_progress\n---\nbody").unwrap();
let mut task = Task::new(1, "t");
apply(&tpl, &mut task, false, false);
assert_eq!(task.meta.status, Status::InProgress);
assert!(task.meta.started_at.is_some());
}
#[test]
fn unknown_fields_ignored_and_no_front_matter_ok() {
let tpl = parse_template("---\nid: whatever\nseq: 99\n---\nbody").unwrap();
assert!(tpl.meta.priority.is_none());
let tpl = parse_template("just a body").unwrap();
assert_eq!(tpl.body, "just a body");
}
}