mod core;
mod persist;
mod tui;
use crate::core::model::{Category, State};
use crate::core::ops;
use crate::core::store::Store;
use crate::persist::atomic::FileStore;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use chrono::Local;
#[derive(Parser)]
#[command(name = "todomd")]
#[command(about = "A simple markdown-based todo list CLI and TUI", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Tui,
Add {
category: String,
title: String,
#[arg(short, long)]
notes: Option<String>,
},
List {
category: String,
},
Edit {
id: u64,
#[arg(long)]
title: Option<String>,
#[arg(long)]
notes: Option<String>,
},
Move {
id: u64,
category: String,
},
Done {
id: u64,
},
Undo {
id: u64,
category: String,
},
Rm {
id: u64,
},
Sub {
#[command(subcommand)]
cmd: SubCommands,
},
Summary,
Reorder {
id: u64,
#[arg(long)]
up: bool,
#[arg(long)]
down: bool,
},
}
#[derive(Subcommand)]
enum SubCommands {
Add {
parent_id: u64,
title: String,
},
Edit {
parent_id: u64,
sub_id: u64,
#[arg(long)]
title: Option<String>,
#[arg(long)]
done: Option<bool>,
},
Rm {
parent_id: u64,
sub_id: u64,
},
Done {
parent_id: u64,
sub_id: u64,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let path = PathBuf::from("todo.md");
let store = FileStore::new(path.clone());
match cli.command {
Some(Commands::Tui) | None => {
let mut app = tui::app::App::new(path)?;
app.run()?;
}
Some(Commands::Add { category, title, notes }) => {
if let Some(cat) = Category::from_str(&category) {
let id = ops::add_todo(&store, cat, title, notes)?;
println!("Added todo {} to {}", id, cat.as_str());
} else {
println!("Invalid category: {}", category);
}
}
Some(Commands::List { category }) => {
if let Some(cat) = Category::from_str(&category) {
let state = store.load()?;
let todos = state.get_category(cat);
println!("## {}", cat.as_str());
for todo in todos {
println!("- [{}] ({}) {}", if cat == Category::Completed { "x" } else { " " }, todo.id, todo.title);
for sub in &todo.subtasks {
println!(" - [{}] ({}) {}", if sub.done { "x" } else { " " }, sub.id, sub.title);
}
}
} else {
println!("Invalid category: {}", category);
}
}
Some(Commands::Edit { id, title, notes }) => {
ops::edit_todo(&store, id, title, notes)?;
println!("Updated todo {}", id);
}
Some(Commands::Move { id, category }) => {
if let Some(cat) = Category::from_str(&category) {
ops::move_todo(&store, id, cat)?;
println!("Moved todo {} to {}", id, cat.as_str());
} else {
println!("Invalid category: {}", category);
}
}
Some(Commands::Done { id }) => {
ops::toggle_done(&store, id)?;
println!("Marked todo {} as done", id);
}
Some(Commands::Undo { id, category }) => {
if let Some(cat) = Category::from_str(&category) {
ops::move_todo(&store, id, cat)?;
println!("Moved todo {} back to {}", id, cat.as_str());
} else {
println!("Invalid category: {}", category);
}
}
Some(Commands::Rm { id }) => {
ops::remove_todo(&store, id)?;
println!("Removed todo {}", id);
}
Some(Commands::Sub { cmd }) => {
match cmd {
SubCommands::Add { parent_id, title } => {
let sid = ops::add_subtask(&store, parent_id, title, None)?;
println!("Added subtask {} to todo {}", sid, parent_id);
}
SubCommands::Edit { parent_id, sub_id, title, done } => {
ops::edit_subtask(&store, parent_id, sub_id, title, done, None)?;
println!("Updated subtask {}", sub_id);
}
SubCommands::Rm { parent_id, sub_id } => {
ops::remove_subtask(&store, parent_id, sub_id)?;
println!("Removed subtask {}", sub_id);
}
SubCommands::Done { parent_id, sub_id } => {
ops::edit_subtask(&store, parent_id, sub_id, None, Some(true), None)?;
println!("Marked subtask {} as done", sub_id);
}
}
}
Some(Commands::Summary) => {
let state = store.load()?;
let now = Local::now().timestamp();
for cat in State::all_categories() {
let todos = state.get_category(cat);
if todos.is_empty() { continue; }
println!("\n{} ({})", cat.as_str().to_uppercase(), todos.len());
for todo in todos {
let age_c_days = (now - todo.created_at).max(0) / 86400;
let age_u_days = (now - todo.updated_at).max(0) / 86400;
let color_str = todo.color.as_str();
let rec = if cat != Category::Completed {
if age_u_days > 14 { "rec:red" }
else if age_u_days > 7 { "rec:yellow" }
else { "" }
} else { "" };
let rec_str = if !rec.is_empty() { format!(" {}", rec) } else { String::new() };
println!(" {:3} [{:7}] created {:2}d updated {:2}d {}{}",
todo.id, color_str, age_c_days, age_u_days, todo.title, rec_str);
if !todo.subtasks.is_empty() {
let done = todo.subtasks.iter().filter(|s| s.done).count();
println!(" subs: {}/{}", done, todo.subtasks.len());
}
}
}
}
Some(Commands::Reorder { id, up, down }) => {
if up && down {
println!("Error: Cannot move up and down at the same time");
} else if !up && !down {
println!("Error: Specify --up or --down");
} else {
ops::reorder_item(&store, id, up)?;
println!("Reordered item {}", id);
}
}
}
Ok(())
}