use crate::app_state::ViewMode;
use crate::todo::{self, Item};
use clap::{Subcommand, ValueEnum};
use std::error::Error;
use std::path::Path;
#[derive(Subcommand)]
pub enum ModeAction {
Add {
#[arg(trailing_var_arg = true, num_args = 1..)]
text: Vec<String>,
},
List {
#[arg(long)]
json: bool,
},
Set {
id: String,
#[arg(long, value_enum)]
time: Option<TimeValue>,
#[arg(long, value_enum)]
energy: Option<EnergyValue>,
},
Complete {
id: String,
},
Reopen {
id: String,
},
Promote {
id: String,
#[arg(long, value_enum)]
to: ModeName,
},
}
#[derive(Clone, Copy, ValueEnum)]
pub enum ModeName {
Inbox,
Todo,
Waiting,
Ref,
Someday,
}
impl ModeName {
const fn view_mode(self) -> ViewMode {
match self {
Self::Inbox => ViewMode::Inbox,
Self::Todo => ViewMode::Todo,
Self::Waiting => ViewMode::Waiting,
Self::Ref => ViewMode::Ref,
Self::Someday => ViewMode::Someday,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
pub enum TimeValue {
Short,
Medium,
Long,
}
impl TimeValue {
const fn as_str(self) -> &'static str {
match self {
Self::Short => "short",
Self::Medium => "medium",
Self::Long => "long",
}
}
}
#[derive(Clone, Copy, ValueEnum)]
pub enum EnergyValue {
Low,
High,
}
impl EnergyValue {
const fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::High => "high",
}
}
}
pub fn run(mode: ViewMode, action: &ModeAction, todotxt_dir: &str) -> Result<(), Box<dyn Error>> {
let file = mode_file(mode, todotxt_dir);
match action {
ModeAction::Add { text } => {
let item = todo::add_item(&file, &text.join(" "))?;
println!("{}", todo::item_to_json(&item, todotxt_dir)?);
}
ModeAction::List { json } => print_items(&load_mode_items(&file)?, *json)?,
ModeAction::Set { id, time, energy } => {
if time.is_none() && energy.is_none() {
return Err("specify at least one of --time or --energy".into());
}
require_item(&file, mode, id)?;
if let Some(time) = time {
todo::set_key_value(&file, id, "time", time.as_str())?;
}
if let Some(energy) = energy {
todo::set_key_value(&file, id, "energy", energy.as_str())?;
}
print_item(&file, id, todotxt_dir)?;
}
ModeAction::Complete { id } => {
if !matches!(mode, ViewMode::Todo | ViewMode::Waiting) {
return Err(format!(
"cannot complete from {}; promote it to Todo first",
mode.label()
)
.into());
}
require_item(&file, mode, id)?;
todo::mark_complete(&file, id)?;
print_item(&done_file(todotxt_dir), id, todotxt_dir)?;
}
ModeAction::Reopen { id } => {
let done = done_file(todotxt_dir);
if !todo::reopen_item(&done, &file, id)? {
return Err(format!("no completed item with id:{id} in done.txt").into());
}
print_item(&file, id, todotxt_dir)?;
}
ModeAction::Promote { id, to } => {
let target = to.view_mode();
if target == mode {
return Err(format!("item is already in {}", mode.label()).into());
}
require_item(&file, mode, id)?;
let target_file = mode_file(target, todotxt_dir);
todo::move_to_file(&file, &target_file, id)?;
print_item(&target_file, id, todotxt_dir)?;
}
}
Ok(())
}
#[derive(Subcommand)]
pub enum DoneAction {
List {
#[arg(long)]
json: bool,
},
}
pub fn run_done(action: &DoneAction, todotxt_dir: &str) -> Result<(), Box<dyn Error>> {
match action {
DoneAction::List { json } => print_items(&load_mode_items(&done_file(todotxt_dir))?, *json),
}
}
fn print_items(items: &[Item], json: bool) -> Result<(), Box<dyn Error>> {
if json {
println!("{}", serde_json::to_string_pretty(items)?);
} else {
for item in items {
println!("{}", format_item(item));
}
}
Ok(())
}
fn done_file(todotxt_dir: &str) -> String {
format!("{todotxt_dir}/done.txt")
}
fn require_item(file: &str, mode: ViewMode, id: &str) -> Result<(), Box<dyn Error>> {
if todo::has_todo_with_id(file, id) {
Ok(())
} else {
Err(format!("no item with id:{id} in {}", mode.filename()).into())
}
}
fn print_item(file: &str, id: &str, todotxt_dir: &str) -> Result<(), Box<dyn Error>> {
let items = load_mode_items(file)?;
let item = items
.iter()
.find(|item| item.id.as_deref() == Some(id))
.ok_or_else(|| format!("item id:{id} is gone from {file}"))?;
println!("{}", todo::item_to_json(item, todotxt_dir)?);
Ok(())
}
fn mode_file(mode: ViewMode, todotxt_dir: &str) -> String {
format!("{todotxt_dir}/{}", mode.filename())
}
fn load_mode_items(file: &str) -> Result<Vec<Item>, Box<dyn Error>> {
if Path::new(file).exists() {
todo::load_todos(file)
} else {
Ok(Vec::new())
}
}
fn format_item(item: &Item) -> String {
let mut parts = Vec::new();
if item.completed {
parts.push("x".to_string());
}
if let Some(priority) = item.priority {
parts.push(format!("({priority})"));
}
if let Some(date) = item.completion_date {
parts.push(date.to_string());
}
if let Some(date) = item.creation_date {
parts.push(date.to_string());
}
if !item.description.is_empty() {
parts.push(item.description.clone());
}
for project in &item.projects {
parts.push(format!("+{project}"));
}
for context in &item.contexts {
parts.push(format!("@{context}"));
}
let mut key_values: Vec<(&String, &String)> = item.key_values.iter().collect();
key_values.sort_by(|a, b| a.0.cmp(b.0));
for (key, value) in key_values {
parts.push(format!("{key}:{value}"));
}
if let Some(id) = &item.id {
parts.push(format!("id:{id}"));
}
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_file_joins_dir_and_mode_filename() {
assert_eq!(
mode_file(ViewMode::Waiting, "/tmp/todotxt"),
"/tmp/todotxt/waiting.txt"
);
}
#[test]
fn load_mode_items_on_missing_file_is_empty() {
let items = load_mode_items("/tmp/torudo-does-not-exist/inbox.txt").unwrap();
assert!(items.is_empty());
}
#[test]
fn format_item_renders_a_todo_txt_line() {
let item = Item::parse("(A) Buy milk +grocery @home time:short id:abc", 1);
assert_eq!(
format_item(&item),
"(A) Buy milk +grocery @home time:short id:abc"
);
}
#[test]
fn format_item_keeps_the_creation_date() {
let item = Item::parse("(A) 2026-08-15 Buy milk id:abc", 1);
assert_eq!(format_item(&item), "(A) 2026-08-15 Buy milk id:abc");
}
#[test]
fn format_item_renders_a_completed_line() {
let item = Item::parse("x 2026-08-15 2026-08-01 Buy milk id:abc", 1);
assert_eq!(
format_item(&item),
"x 2026-08-15 2026-08-01 Buy milk id:abc"
);
}
#[test]
fn format_item_sorts_key_values_for_stable_output() {
let item = Item::parse("Task time:short energy:low due:2026-01-01 id:abc", 1);
assert_eq!(
format_item(&item),
"Task due:2026-01-01 energy:low time:short id:abc"
);
}
}