use std::io::Write;
use std::path::PathBuf;
use chrono::{DateTime, NaiveDate, Utc};
use crate::error::{Error, Result};
use crate::library::Registry;
use crate::model::{Priority, Task};
use crate::storage;
pub fn active_library_root() -> Result<PathBuf> {
let registry = Registry::load()?;
let (_, path) = registry.current_library()?;
Ok(path.to_path_buf())
}
pub fn parse_due(due: &str) -> Result<DateTime<Utc>> {
let date: NaiveDate = due
.parse()
.map_err(|_| Error::InvalidTaskFile(format!("invalid due date '{due}', expected YYYY-MM-DD")))?;
Ok(date.and_hms_opt(23, 59, 59).unwrap().and_utc())
}
fn filename_template(root: &std::path::Path) -> Result<String> {
if let Some(f) = storage::LibraryMeta::load(root)?.filename_format {
return Ok(f);
}
if let Some(f) = crate::config::Config::load()?.filename_format {
return Ok(f);
}
Ok(storage::DEFAULT_FILENAME_FORMAT.to_string())
}
pub(crate) fn sync_filename(root: &std::path::Path, st: &storage::StoredTask) -> Result<PathBuf> {
let template = filename_template(root)?;
let new_name = storage::render_filename(&template, &st.task)?;
let new_path = st.path.with_file_name(&new_name);
if new_path != st.path {
std::fs::rename(&st.path, &new_path)?;
println!("renamed: {new_name}");
}
Ok(new_path)
}
pub fn fix_names(dry_run: bool) -> Result<()> {
let root = active_library_root()?;
let template = filename_template(&root)?;
let tasks = storage::load_library(&root)?.tasks;
let mut count = 0usize;
for st in &tasks {
let new_name = storage::render_filename(&template, &st.task)?;
let new_path = st.path.with_file_name(&new_name);
if new_path != st.path {
if dry_run {
println!("would rename: {} -> {new_name}", st.path.file_name().unwrap_or_default().to_string_lossy());
} else {
std::fs::rename(&st.path, &new_path)?;
println!("renamed: {new_name}");
}
count += 1;
}
}
if count == 0 {
println!("all filenames already match the template");
} else if dry_run {
println!("{count} file(s) would be renamed");
} else {
println!("renamed {count} file(s)");
}
Ok(())
}
pub fn new(
title: String,
description: Option<String>,
priority: Option<String>,
tags: Vec<String>,
due: Option<String>,
dir: Option<String>,
template: Option<String>,
) -> Result<()> {
let root = active_library_root()?;
let seq = storage::LibraryMeta::allocate_seq(&root)?;
let mut task = Task::new(seq, title);
task.meta.description = description;
let tpl = template.map(|n| super::template::load(&n)).transpose()?;
let tpl_sets_priority = tpl.as_ref().is_some_and(|t| t.meta.priority.is_some());
if let Some(t) = &tpl {
super::template::apply(t, &mut task, priority.is_some(), !tags.is_empty());
}
if let Some(p) = &priority {
task.meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
} else if !tpl_sets_priority
&& let Some(p) = crate::config::Config::load().ok().and_then(|c| c.default_priority) {
task.meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
}
if !tags.is_empty() {
task.meta.tags = tags;
}
if let Some(d) = due {
task.meta.due_date = Some(parse_due(&d)?);
}
let target_dir = match dir {
Some(d) => root.join(d),
None => root.clone(),
};
let template = filename_template(&root)?;
let path = target_dir.join(storage::render_filename(&template, &task)?);
storage::write_task(&path, &task)?;
println!("created task #{seq} [{}] {}", task.short_id(), task.meta.title);
println!(" {}", path.display());
Ok(())
}
fn load_filtered(
root: &std::path::Path,
args: &crate::cli::FilterArgs,
) -> Result<(Vec<storage::StoredTask>, Vec<PathBuf>)> {
let scope = match &args.dir {
Some(d) => root.join(d),
None => root.to_path_buf(),
};
if !scope.exists() {
return Err(Error::TaskNotFound(format!("directory {}", scope.display())));
}
let filter = build_filter(
args.status.clone(),
args.tag.clone(),
args.priority.clone(),
args.due_before.clone(),
args.due_after.clone(),
None,
)?;
let outcome = storage::load_library(&scope)?;
let tasks: Vec<_> = outcome
.tasks
.into_iter()
.filter(|t| filter.matches(&t.task))
.collect();
Ok((tasks, outcome.skipped))
}
pub fn list(args: crate::cli::FilterArgs, quiet: bool) -> Result<()> {
let root = active_library_root()?;
let (tasks, skipped) = load_filtered(&root, &args)?;
print_task_table(&tasks);
warn_skipped(&skipped, quiet);
Ok(())
}
pub fn batch(
new_status: &str,
args: crate::cli::FilterArgs,
dry_run: bool,
force: bool,
) -> Result<()> {
let status = new_status
.parse::<crate::model::Status>()
.map_err(Error::InvalidTaskFile)?;
if args.is_empty() {
return Err(Error::InvalidTaskFile(
"refusing to batch without filters; pass at least one of --dir/--status/--tag/--priority/--due-before/--due-after".into(),
));
}
let root = active_library_root()?;
let (tasks, _) = load_filtered(&root, &args)?;
if tasks.is_empty() {
println!("no tasks match the filters");
return Ok(());
}
print_task_table(&tasks);
if dry_run {
println!("{} task(s) would be set to {status}", tasks.len());
return Ok(());
}
if !force && !confirm(&format!("set {} task(s) to {status}?", tasks.len()))? {
println!("aborted");
return Ok(());
}
let mut count = 0usize;
for mut st in tasks {
st.task.set_status(status);
storage::write_task(&st.path, &st.task)?;
count += 1;
}
println!("{count} task(s) -> {status}");
Ok(())
}
pub fn archive(args: crate::cli::FilterArgs, dry_run: bool, force: bool) -> Result<()> {
let root = active_library_root()?;
let (mut tasks, _) = load_filtered(&root, &args)?;
if args.is_empty() {
tasks.retain(|t| {
matches!(
t.task.meta.status,
crate::model::Status::Done | crate::model::Status::Cancelled
)
});
}
if tasks.is_empty() {
println!("no tasks to archive");
return Ok(());
}
let plan: Vec<(PathBuf, PathBuf)> = tasks
.iter()
.map(|st| {
let stamp = st.task.meta.completed_at.unwrap_or(st.task.meta.created_at);
let month = stamp.with_timezone(&chrono::Local).format("%Y-%m").to_string();
let name = st.path.file_name().unwrap_or_default().to_os_string();
(st.path.clone(), root.join("archive").join(month).join(name))
})
.collect();
for (from, to) in &plan {
let label = to
.strip_prefix(&root)
.unwrap_or(to.as_path())
.display()
.to_string();
if dry_run {
println!("would archive: {} -> {label}", from.file_name().unwrap_or_default().to_string_lossy());
}
}
if dry_run {
println!("{} task(s) would be archived", plan.len());
return Ok(());
}
if !force && !confirm(&format!("archive {} task(s)?", plan.len()))? {
println!("aborted");
return Ok(());
}
let mut count = 0usize;
for (from, to) in &plan {
if to.exists() {
eprintln!("warning: {} already exists, skipping", to.display());
continue;
}
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(from, to)?;
let label = to.strip_prefix(&root).unwrap_or(to.as_path()).display();
println!("archived: {label}");
count += 1;
}
println!("archived {count} task(s)");
Ok(())
}
fn build_filter(
status: Option<String>,
tag: Option<String>,
priority: Option<String>,
due_before: Option<String>,
due_after: Option<String>,
text: Option<String>,
) -> Result<crate::filter::Filter> {
Ok(crate::filter::Filter {
status: status
.map(|s| s.parse().map_err(Error::InvalidTaskFile))
.transpose()?,
tag,
priority: priority
.map(|p| p.parse().map_err(Error::InvalidTaskFile))
.transpose()?,
due_before: due_before.map(|d| parse_due(&d)).transpose()?,
due_after: due_after
.map(|d| Ok::<_, Error>(parse_due(&d)? - chrono::Duration::seconds(86399)))
.transpose()?,
text,
})
}
fn warn_skipped(skipped: &[PathBuf], quiet: bool) {
if skipped.is_empty() || quiet {
return;
}
if let Ok(config) = crate::config::Config::load()
&& config.warn_invalid_files == Some(false) {
return;
}
eprintln!(
"warning: skipped {} non-task file(s); run 'tasks adopt' to convert, or add them to 'ignore' in .tasks-meta.toml",
skipped.len()
);
}
pub fn search(query: String, status: Option<String>, tag: Option<String>) -> Result<()> {
let root = active_library_root()?;
let filter = build_filter(status, tag, None, None, None, Some(query))?;
let outcome = storage::load_library(&root)?;
let tasks: Vec<_> = outcome
.tasks
.into_iter()
.filter(|t| filter.matches(&t.task))
.collect();
print_task_table(&tasks);
warn_skipped(&outcome.skipped, false);
Ok(())
}
pub fn tag_add(id: &str, tags: Vec<String>) -> Result<()> {
set_fields(id, None, None, None, None, tags, Vec::new())
}
pub fn tag_remove(id: &str, tags: Vec<String>) -> Result<()> {
set_fields(id, None, None, None, None, Vec::new(), tags)
}
pub fn tag_list() -> Result<()> {
let root = active_library_root()?;
let mut counts = std::collections::BTreeMap::<String, usize>::new();
for st in storage::load_library(&root)?.tasks {
for tag in st.task.meta.tags {
*counts.entry(tag).or_default() += 1;
}
}
if counts.is_empty() {
println!("no tags");
}
for (tag, n) in counts {
println!("{tag}\t{n}");
}
Ok(())
}
pub fn print_task_table(tasks: &[storage::StoredTask]) {
if tasks.is_empty() {
println!("no tasks");
return;
}
println!("{:<5} {:<9} {:<12} {:<8} {:<12} {:<20} TITLE", "SEQ", "ID", "STATUS", "PRI", "DUE", "TAGS");
for st in tasks {
let m = &st.task.meta;
let due = m
.due_date
.map(|d| d.date_naive().to_string())
.unwrap_or_else(|| "-".into());
let tags = if m.tags.is_empty() { "-".into() } else { m.tags.join(",") };
println!(
"{:<5} {:<9} {:<12} {:<8} {:<12} {:<20} {}",
format!("#{}", m.seq),
st.task.short_id(),
m.status.to_string(),
m.priority.to_string(),
due,
tags,
m.title
);
}
}
pub fn show(id: &str) -> Result<()> {
let root = active_library_root()?;
let st = storage::resolve_task(&root, id)?;
let m = &st.task.meta;
println!("#{} [{}] {}", m.seq, st.task.short_id(), m.title);
println!(" uuid: {}", m.id);
println!(" status: {}", m.status);
println!(" priority: {}", m.priority);
println!(" tags: {}", if m.tags.is_empty() { "-".into() } else { m.tags.join(", ") });
if let Some(d) = &m.description {
println!(" desc: {d}");
}
println!(" created: {}", m.created_at);
if let Some(t) = m.started_at {
println!(" started: {t}");
}
if let Some(t) = m.due_date {
println!(" due: {t}");
}
if let Some(t) = m.completed_at {
println!(" completed: {t}");
}
println!(" file: {}", st.path.display());
if !st.task.steps.is_empty() {
let done = st.task.steps.iter().filter(|s| s.done).count();
println!(" steps: {done}/{}", st.task.steps.len());
}
if !st.task.body.is_empty() {
println!("\n{}", st.task.body.trim_end());
}
Ok(())
}
pub fn delete(id: &str, force: bool) -> Result<()> {
let root = active_library_root()?;
let st = storage::resolve_task(&root, id)?;
if !force && !confirm(&format!("delete task #{} '{}'?", st.task.meta.seq, st.task.meta.title))? {
println!("aborted");
return Ok(());
}
std::fs::remove_file(&st.path)?;
println!("deleted #{} {}", st.task.meta.seq, st.task.meta.title);
Ok(())
}
pub fn set_status(id: &str, status: crate::model::Status) -> Result<()> {
let root = active_library_root()?;
let mut st = storage::resolve_task(&root, id)?;
st.task.set_status(status);
storage::write_task(&st.path, &st.task)?;
println!("#{} {} -> {}", st.task.meta.seq, st.task.meta.title, status);
Ok(())
}
pub fn set_fields(
id: &str,
title: Option<String>,
description: Option<String>,
priority: Option<String>,
due: Option<String>,
tag_add: Vec<String>,
tag_remove: Vec<String>,
) -> Result<()> {
let root = active_library_root()?;
let mut st = storage::resolve_task(&root, id)?;
let old_title = st.task.meta.title.clone();
if let Some(d) = description {
st.task.meta.description = if d == "none" { None } else { Some(d) };
}
if let Some(t) = title {
st.task.meta.title = t;
}
if let Some(p) = priority {
st.task.meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
}
if let Some(d) = due {
st.task.meta.due_date = if d == "none" { None } else { Some(parse_due(&d)?) };
}
for tag in tag_add {
if !st.task.meta.tags.contains(&tag) {
st.task.meta.tags.push(tag);
}
}
st.task.meta.tags.retain(|t| !tag_remove.contains(t));
storage::write_task(&st.path, &st.task)?;
if st.task.meta.title != old_title {
sync_filename(&root, &st)?;
}
println!("updated #{} {}", st.task.meta.seq, st.task.meta.title);
Ok(())
}
pub fn edit(id: Option<String>, editor_flag: Option<String>, lib: bool) -> Result<()> {
let config = crate::config::Config::load()?;
let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
let root = active_library_root()?;
if lib {
return crate::editor::open(&editor, &root);
}
let id = id.ok_or_else(|| Error::InvalidTaskFile("missing task id (or use --lib)".into()))?;
let st = storage::resolve_task(&root, &id)?;
let original = std::fs::read_to_string(&st.path)?;
crate::editor::open(&editor, &st.path)?;
let edited = std::fs::read_to_string(&st.path)?;
match validate_edit(&st.task, &edited) {
Ok(task) => {
println!("saved #{} {}", task.meta.seq, task.meta.title);
if task.meta.title != st.task.meta.title {
let updated = storage::StoredTask { path: st.path.clone(), task };
sync_filename(&root, &updated)?;
}
Ok(())
}
Err(reason) => {
std::fs::write(&st.path, original)?;
Err(Error::EditRejected(reason))
}
}
}
fn validate_edit(before: &Task, edited_content: &str) -> std::result::Result<Task, String> {
let task = storage::parse_task(edited_content).map_err(|e| e.to_string())?;
if task.meta.id != before.meta.id {
return Err(format!("field 'id' is immutable ({} -> {})", before.meta.id, task.meta.id));
}
if task.meta.seq != before.meta.seq {
return Err(format!("field 'seq' is immutable ({} -> {})", before.meta.seq, task.meta.seq));
}
if task.meta.created_at != before.meta.created_at {
return Err("field 'created_at' is immutable".into());
}
Ok(task)
}
fn confirm(prompt: &str) -> Result<bool> {
print!("{prompt} [y/N] ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(matches!(input.trim(), "y" | "Y" | "yes"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_due_valid() {
let dt = parse_due("2026-08-01").unwrap();
assert_eq!(dt.date_naive().to_string(), "2026-08-01");
}
#[test]
fn parse_due_invalid() {
assert!(parse_due("tomorrow").is_err());
assert!(parse_due("2026-13-01").is_err());
}
#[test]
fn validate_edit_accepts_mutable_changes() {
let task = Task::new(1, "old title");
let mut edited = task.clone();
edited.meta.title = "new title".into();
edited.meta.tags = vec!["x".into()];
edited.body = "new body".into();
let content = storage::render_task(&edited).unwrap();
let got = validate_edit(&task, &content).unwrap();
assert_eq!(got.meta.title, "new title");
}
#[test]
fn validate_edit_rejects_broken_yaml() {
let task = Task::new(1, "t");
assert!(validate_edit(&task, "not a task file").is_err());
assert!(validate_edit(&task, "---\nid: [broken\n---\n").is_err());
}
#[test]
fn validate_edit_rejects_immutable_fields() {
let task = Task::new(1, "t");
let mut changed = task.clone();
changed.meta.id = uuid::Uuid::new_v4();
let err = validate_edit(&task, &storage::render_task(&changed).unwrap()).unwrap_err();
assert!(err.contains("'id'"));
let mut changed = task.clone();
changed.meta.seq = 99;
let err = validate_edit(&task, &storage::render_task(&changed).unwrap()).unwrap_err();
assert!(err.contains("'seq'"));
let mut changed = task.clone();
changed.meta.created_at = Utc::now() + chrono::Duration::days(1);
let err = validate_edit(&task, &storage::render_task(&changed).unwrap()).unwrap_err();
assert!(err.contains("'created_at'"));
}
}