use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::model::{Task, TaskMeta};
const FRONT_MATTER_DELIM: &str = "---";
pub fn parse_task(content: &str) -> Result<Task> {
let rest = content
.strip_prefix(FRONT_MATTER_DELIM)
.and_then(|r| r.strip_prefix('\n'))
.ok_or_else(|| Error::InvalidTaskFile("missing front matter".into()))?;
let end = rest
.find("\n---")
.ok_or_else(|| Error::InvalidTaskFile("unterminated front matter".into()))?;
let yaml = &rest[..end];
let body_start = rest[end + 1..]
.find('\n')
.map(|i| end + 1 + i + 1)
.unwrap_or(rest.len());
let body = rest[body_start..].trim_start_matches('\n').to_string();
let meta: TaskMeta = serde_yaml::from_str(yaml)?;
Ok(Task { meta, body })
}
pub fn render_task(task: &Task) -> Result<String> {
let yaml = serde_yaml::to_string(&task.meta)?;
let mut out = format!("{FRONT_MATTER_DELIM}\n{yaml}{FRONT_MATTER_DELIM}\n");
if !task.body.is_empty() {
out.push('\n');
out.push_str(&task.body);
if !task.body.ends_with('\n') {
out.push('\n');
}
}
Ok(out)
}
pub fn read_task(path: &Path) -> Result<Task> {
parse_task(&std::fs::read_to_string(path)?)
}
pub fn write_task(path: &Path, task: &Task) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, render_task(task)?)?;
Ok(())
}
pub fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut prev_dash = true;
for c in title.chars() {
if c.is_alphanumeric() {
slug.extend(c.to_lowercase());
prev_dash = false;
} else if !prev_dash {
slug.push('-');
prev_dash = true;
}
}
slug.trim_end_matches('-').to_string()
}
pub const DEFAULT_FILENAME_FORMAT: &str = "{slug}-{short_id}";
pub fn render_filename(template: &str, task: &Task) -> Result<String> {
let mut template = template.to_string();
if !template.contains("{short_id}") {
template.push_str("-{short_id}");
}
let local = task.meta.created_at.with_timezone(&chrono::Local);
let mut out = String::new();
let mut rest = template.as_str();
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let Some(end) = rest[start..].find('}') else {
return Err(Error::BadTemplate(format!("unclosed '{{' in '{template}'")));
};
let var = &rest[start + 1..start + end];
match var {
"date" => out.push_str(&local.format("%Y-%m-%d").to_string()),
"datetime" => out.push_str(&local.format("%Y%m%d-%H%M").to_string()),
"seq" => out.push_str(&task.meta.seq.to_string()),
"slug" => out.push_str(&slugify(&task.meta.title)),
"short_id" => out.push_str(&task.short_id()),
other => {
return Err(Error::BadTemplate(format!("unknown variable '{{{other}}}'")));
}
}
rest = &rest[start + end + 1..];
}
out.push_str(rest);
let name: String = out
.chars()
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
.collect();
let name = name.trim_matches(|c| c == '-' || c == '.' || c == ' ').to_string();
Ok(format!("{name}.md"))
}
#[cfg(test)]
pub fn task_filename(task: &Task) -> String {
render_filename(DEFAULT_FILENAME_FORMAT, task).expect("default template is valid")
}
#[derive(Debug)]
pub struct StoredTask {
pub path: std::path::PathBuf,
pub task: Task,
}
pub fn load_all_tasks(root: &Path) -> Result<Vec<StoredTask>> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "md") {
match read_task(&path) {
Ok(task) => out.push(StoredTask { path, task }),
Err(err) => eprintln!("warning: skipping {}: {err}", path.display()),
}
}
}
}
out.sort_by_key(|t| t.task.meta.seq);
Ok(out)
}
pub fn resolve_task(root: &Path, query: &str) -> Result<StoredTask> {
let mut matches: Vec<StoredTask> = load_all_tasks(root)?
.into_iter()
.filter(|t| t.task.matches_identifier(query))
.collect();
match matches.len() {
0 => Err(Error::TaskNotFound(query.to_string())),
1 => Ok(matches.remove(0)),
_ => Err(Error::AmbiguousId(query.to_string())),
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LibraryMeta {
#[serde(default = "default_next_seq")]
pub next_seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename_format: Option<String>,
}
fn default_next_seq() -> u64 {
1
}
impl LibraryMeta {
pub fn path(library_root: &Path) -> std::path::PathBuf {
library_root.join(".tasks-meta.toml")
}
pub fn default_init(library_root: &Path) -> Result<()> {
if !Self::path(library_root).exists() {
LibraryMeta { next_seq: 1, filename_format: None }.save(library_root)?;
}
Ok(())
}
pub fn load(library_root: &Path) -> Result<Self> {
let path = Self::path(library_root);
if !path.exists() {
return Ok(LibraryMeta { next_seq: 1, filename_format: None });
}
Ok(toml::from_str(&std::fs::read_to_string(path)?)?)
}
pub fn save(&self, library_root: &Path) -> Result<()> {
std::fs::create_dir_all(library_root)?;
let content = toml::to_string(self).map_err(|e| Error::InvalidTaskFile(e.to_string()))?;
std::fs::write(Self::path(library_root), content)?;
Ok(())
}
pub fn allocate_seq(library_root: &Path) -> Result<u64> {
let mut meta = Self::load(library_root)?;
let seq = meta.next_seq;
meta.next_seq += 1;
meta.save(library_root)?;
Ok(seq)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Status, Step};
fn sample_task() -> Task {
let mut task = Task::new(7, "Fix login bug");
task.meta.tags = vec!["backend".into(), "bugfix".into()];
task.meta.steps = vec![Step {
id: "s1".into(),
title: "Reproduce".into(),
status: Status::Done,
due_date: None,
depends_on: vec![],
}];
task.body = "## Notes\n\nSome details here.\n".to_string();
task
}
#[test]
fn round_trip_preserves_everything() {
let task = sample_task();
let rendered = render_task(&task).unwrap();
let parsed = parse_task(&rendered).unwrap();
assert_eq!(parsed, task);
}
#[test]
fn round_trip_empty_body() {
let task = Task::new(1, "no body");
let parsed = parse_task(&render_task(&task).unwrap()).unwrap();
assert_eq!(parsed, task);
}
#[test]
fn parse_rejects_missing_front_matter() {
assert!(parse_task("just some text").is_err());
assert!(parse_task("---\nid: x").is_err());
}
#[test]
fn slugify_handles_english() {
assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
assert_eq!(slugify(" spaces everywhere "), "spaces-everywhere");
}
#[test]
fn slugify_keeps_cjk() {
assert_eq!(slugify("修复登录 bug"), "修复登录-bug");
}
#[test]
fn slugify_special_chars_only_yields_empty() {
assert_eq!(slugify("!!! ???"), "");
let task = Task::new(1, "!!!");
assert_eq!(task_filename(&task), format!("{}.md", task.short_id()));
}
#[test]
fn render_filename_variables() {
let mut task = Task::new(7, "Fix Bug");
task.meta.created_at = chrono::DateTime::parse_from_rfc3339("2026-07-30T12:00:00Z")
.unwrap()
.to_utc();
let sid = task.short_id();
let local_date = task
.meta
.created_at
.with_timezone(&chrono::Local)
.format("%Y-%m-%d")
.to_string();
assert_eq!(
render_filename("{date}-{slug}-{short_id}", &task).unwrap(),
format!("{local_date}-fix-bug-{sid}.md")
);
assert_eq!(
render_filename("{seq}-{slug}", &task).unwrap(),
format!("7-fix-bug-{sid}.md"),
"short_id auto-appended when missing"
);
}
#[test]
fn render_filename_rejects_bad_templates() {
let task = Task::new(1, "t");
assert!(render_filename("{typo}-{short_id}", &task).is_err());
assert!(render_filename("{date-{short_id}", &task).is_err());
}
#[test]
fn render_filename_sanitizes_separators() {
let task = Task::new(1, "t");
let name = render_filename("a/b\\c-{short_id}", &task).unwrap();
assert!(!name.contains('/') && !name.contains('\\'), "{name}");
}
#[test]
fn render_filename_is_idempotent_for_same_task() {
let task = Task::new(3, "stable name");
let a = render_filename("{date}-{slug}-{short_id}", &task).unwrap();
let b = render_filename("{date}-{slug}-{short_id}", &task).unwrap();
assert_eq!(a, b);
}
#[test]
fn seq_allocation_is_monotonic() {
let dir = std::env::temp_dir().join(format!("tasks-test-{}", uuid::Uuid::new_v4()));
assert_eq!(LibraryMeta::allocate_seq(&dir).unwrap(), 1);
assert_eq!(LibraryMeta::allocate_seq(&dir).unwrap(), 2);
assert_eq!(LibraryMeta::allocate_seq(&dir).unwrap(), 3);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn file_write_read_round_trip() {
let dir = std::env::temp_dir().join(format!("tasks-test-{}", uuid::Uuid::new_v4()));
let task = sample_task();
let path = dir.join(task_filename(&task));
write_task(&path, &task).unwrap();
let loaded = read_task(&path).unwrap();
assert_eq!(loaded, task);
std::fs::remove_dir_all(&dir).unwrap();
}
}