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())
}
pub fn new(
title: String,
priority: Option<String>,
tags: Vec<String>,
due: Option<String>,
dir: Option<String>,
) -> Result<()> {
let root = active_library_root()?;
let seq = storage::LibraryMeta::allocate_seq(&root)?;
let mut task = Task::new(seq, title);
if let Some(p) = priority {
task.meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
}
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 path = target_dir.join(storage::task_filename(&task));
storage::write_task(&path, &task)?;
println!("created task #{seq} [{}] {}", task.short_id(), task.meta.title);
println!(" {}", path.display());
Ok(())
}
pub struct ListArgs {
pub dir: Option<String>,
pub status: Option<String>,
pub tag: Option<String>,
pub priority: Option<String>,
pub due_before: Option<String>,
pub due_after: Option<String>,
}
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,
})
}
pub fn list(args: ListArgs) -> Result<()> {
let root = active_library_root()?;
let scope = match &args.dir {
Some(d) => root.join(d),
None => root.clone(),
};
if !scope.exists() {
return Err(Error::TaskNotFound(format!("directory {}", scope.display())));
}
let filter = build_filter(args.status, args.tag, args.priority, args.due_before, args.due_after, None)?;
let tasks: Vec<_> = storage::load_all_tasks(&scope)?
.into_iter()
.filter(|t| filter.matches(&t.task))
.collect();
print_task_table(&tasks);
Ok(())
}
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 tasks: Vec<_> = storage::load_all_tasks(&root)?
.into_iter()
.filter(|t| filter.matches(&t.task))
.collect();
print_task_table(&tasks);
Ok(())
}
pub fn tag_add(id: &str, tags: Vec<String>) -> Result<()> {
set_fields(id, None, None, None, tags, Vec::new())
}
pub fn tag_remove(id: &str, tags: Vec<String>) -> Result<()> {
set_fields(id, 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_all_tasks(&root)? {
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(", ") });
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 !m.steps.is_empty() {
let done = m.steps.iter().filter(|s| s.status == crate::model::Status::Done).count();
println!(" steps: {done}/{}", m.steps.len());
for s in &m.steps {
let mark = match s.status {
crate::model::Status::Done => "x",
crate::model::Status::InProgress => ">",
_ => " ",
};
print!(" [{mark}] {} {}", s.id, s.title);
if !s.depends_on.is_empty() {
print!(" (depends on: {})", s.depends_on.join(", "));
}
println!();
}
}
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>,
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)?;
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)?;
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);
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'"));
}
}