use crate::cli::TaskCommands;
use crate::db::Database;
use crate::task::TaskManager;
use anyhow::Result;
use colored::Colorize;
use std::path::Path;
const VALID_PRIORITIES: &[&str] = &["high", "medium", "low"];
const VALID_STATUSES: &[&str] = &["pending", "in_progress", "completed", "cancelled"];
fn validate_priority(priority: &str) -> Result<()> {
if VALID_PRIORITIES.contains(&priority) {
Ok(())
} else {
anyhow::bail!(
"invalid priority '{}': must be one of {}",
priority,
VALID_PRIORITIES.join(", ")
)
}
}
fn validate_status(status: &str) -> Result<()> {
if VALID_STATUSES.contains(&status) {
Ok(())
} else {
anyhow::bail!(
"invalid status '{}': must be one of {}",
status,
VALID_STATUSES.join(", ")
)
}
}
fn color_status(status: &str) -> String {
match status {
"pending" => status.yellow().to_string(),
"in_progress" => status.blue().to_string(),
"completed" => status.green().to_string(),
"cancelled" => status.dimmed().to_string(),
other => other.to_string(),
}
}
fn title_slug(title: &str) -> String {
let lower = title.to_lowercase();
let mut slug = String::new();
let mut in_sep = false;
for ch in lower.chars() {
if ch.is_alphanumeric() {
slug.push(ch);
in_sep = false;
} else if !in_sep {
slug.push('-');
in_sep = true;
}
}
slug.trim_matches('-').to_string()
}
fn ensure_gitignore_entry(project_root: &Path) -> Result<()> {
let gitignore = project_root.join(".gitignore");
let entry = "tasks/";
if gitignore.exists() {
let contents = std::fs::read_to_string(&gitignore)?;
let already_present = contents
.lines()
.any(|l| l.trim() == entry.trim_end_matches('/') || l.trim() == entry);
if already_present {
return Ok(());
}
let needs_newline = !contents.is_empty() && !contents.ends_with('\n');
let mut append = String::new();
if needs_newline {
append.push('\n');
}
append.push_str(entry);
append.push('\n');
use std::io::Write;
let mut file = std::fs::OpenOptions::new().append(true).open(&gitignore)?;
file.write_all(append.as_bytes())?;
} else {
std::fs::write(&gitignore, format!("{}\n", entry))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_basic() {
assert_eq!(title_slug("Implement search API"), "implement-search-api");
}
#[test]
fn slug_runs_of_non_alnum_collapsed() {
assert_eq!(title_slug("Fix the bug!"), "fix-the-bug");
}
#[test]
fn slug_leading_trailing_trimmed() {
assert_eq!(title_slug(" Hello World "), "hello-world");
}
#[test]
fn slug_all_punctuation_becomes_single_dash() {
assert_eq!(title_slug("foo---bar"), "foo-bar");
}
#[test]
fn slug_empty_string() {
assert_eq!(title_slug(""), "");
}
#[test]
fn gitignore_created_when_absent() {
let dir = tempfile::tempdir().unwrap();
ensure_gitignore_entry(dir.path()).unwrap();
let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(contents.contains("tasks/"));
}
#[test]
fn gitignore_appended_when_entry_missing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
ensure_gitignore_entry(dir.path()).unwrap();
let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(contents.contains("tasks/"));
assert!(contents.contains("target/"));
}
#[test]
fn gitignore_not_duplicated_when_entry_present() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "target/\ntasks/\n").unwrap();
ensure_gitignore_entry(dir.path()).unwrap();
let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let count = contents.lines().filter(|l| *l == "tasks/").count();
assert_eq!(count, 1, "tasks/ should appear exactly once");
}
#[test]
fn gitignore_appended_with_newline_when_file_lacks_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "target/").unwrap(); ensure_gitignore_entry(dir.path()).unwrap();
let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(contents.contains('\n'));
assert!(contents.contains("tasks/"));
assert!(!contents.contains("target/tasks/"));
}
}
pub fn run(command: TaskCommands, project_root: &Path) -> Result<()> {
let db = Database::open(project_root)?;
let tm = TaskManager::new(&db);
match command {
TaskCommands::Add {
context,
title,
description,
priority,
} => {
validate_priority(&priority)?;
let task = tm.add_task(&context, &title, description.as_deref(), &priority)?;
println!("Created task {:03} in {}", task.seq, context.cyan());
println!(" Title: {}", task.title);
println!(" Priority: {}", task.priority);
println!(" Status: {}", color_status(&task.status));
}
TaskCommands::Update {
context,
seq,
title,
description,
status,
priority,
} => {
if let Some(ref p) = priority {
validate_priority(p)?;
}
if let Some(ref s) = status {
validate_status(s)?;
}
let task = tm.update_task(
&context,
seq,
title.as_deref(),
description.as_deref(),
status.as_deref(),
priority.as_deref(),
)?;
println!("Updated task {:03} in {}", task.seq, context.cyan());
println!(" Title: {}", task.title);
println!(" Priority: {}", task.priority);
println!(" Status: {}", color_status(&task.status));
}
TaskCommands::List { context, json } => {
let tasks = tm.list_tasks(&context)?;
if json {
println!("{}", serde_json::to_string_pretty(&tasks)?);
return Ok(());
}
println!(
"{} ({} task{})",
context.cyan(),
tasks.len(),
if tasks.len() == 1 { "" } else { "s" }
);
for task in &tasks {
println!(
" {:03} {:<13} {:<6} {}",
task.seq,
color_status(&task.status),
task.priority,
task.title
);
}
}
TaskCommands::Show { context, seq, json } => {
let full = tm.get_full_task(&context, seq)?;
if json {
println!("{}", serde_json::to_string_pretty(&full)?);
return Ok(());
}
println!(
"[{:03}] {} ({})",
full.task.seq,
full.task.title.bold(),
context.cyan()
);
println!("Status: {}", color_status(&full.task.status));
println!("Priority: {}", full.task.priority);
if let Some(ref desc) = full.task.description {
println!("Description: {}", desc);
}
if !full.todos.is_empty() {
println!("Todos:");
for todo in &full.todos {
let check = if todo.done { "x" } else { " " };
println!(" [{}] {}. {}", check, todo.seq, todo.text);
}
}
println!("Created: {}", full.task.created_at);
println!("Updated: {}", full.task.updated_at);
}
TaskCommands::Todo {
context,
seq,
text,
done,
} => {
match (text, done) {
(Some(_), Some(_)) => {
anyhow::bail!("provide either a todo text or --done <n>, not both");
}
(None, None) => {
anyhow::bail!("provide a todo text or --done <n>");
}
(Some(text), None) => {
let todo = tm.add_todo(&context, seq, &text)?;
println!(
"Added todo {} to task {:03} in {}",
todo.seq,
seq,
context.cyan()
);
println!(" [ ] {}. {}", todo.seq, todo.text);
let task = tm.get_task(&context, seq)?;
let todos = tm.get_todos(&task.id)?;
println!("Todos:");
for t in &todos {
let check = if t.done { "x" } else { " " };
println!(" [{}] {}. {}", check, t.seq, t.text);
}
}
(None, Some(done_seq)) => {
tm.mark_todo_done(&context, seq, done_seq)?;
let task = tm.get_task(&context, seq)?;
let todos = tm.get_todos(&task.id)?;
let marked = todos
.iter()
.find(|t| t.seq == done_seq)
.ok_or_else(|| anyhow::anyhow!("todo {} not found", done_seq))?;
println!(
"Marked todo {} done on task {:03} in {}",
done_seq,
seq,
context.cyan()
);
println!(" [x] {}. {}", marked.seq, marked.text);
println!("Todos:");
for t in &todos {
let check = if t.done { "x" } else { " " };
println!(" [{}] {}. {}", check, t.seq, t.text);
}
}
}
}
TaskCommands::Export { context, seq } => {
let full = tm.get_full_task(&context, seq)?;
let slug = title_slug(&full.task.title);
let filename = format!("{:03}-{}.md", full.task.seq, slug);
let out_dir = project_root.join("tasks").join(&context);
std::fs::create_dir_all(&out_dir)?;
let out_path = out_dir.join(&filename);
let description = full
.task
.description
.as_deref()
.unwrap_or("*(no description)*");
let priority_display = {
let mut chars = full.task.priority.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
};
let mut md = format!(
"# {:03} — {}\n\n## Context\n\n{}\n\n## Status\n\n**{}** | Priority: {}\n",
full.task.seq, full.task.title, description, full.task.status, priority_display,
);
if !full.todos.is_empty() {
md.push_str("\n## Todo\n\n");
for todo in &full.todos {
let check = if todo.done { "x" } else { " " };
md.push_str(&format!("- [{}] {}. {}\n", check, todo.seq, todo.text));
}
}
std::fs::write(&out_path, &md)?;
ensure_gitignore_entry(project_root)?;
let display_path = format!("tasks/{}/{}", context, filename);
println!("Exported task {:03} to {}", full.task.seq, display_path);
}
}
Ok(())
}