use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand};
use crate::archive::collect_all_events;
use crate::context::SpoolContext;
use crate::state::{load_or_materialize_state, Task, TaskStatus};
use crate::writer::{
assign_task as write_assign, complete_task as write_complete,
create_stream as write_create_stream, create_task as write_create,
delete_stream as write_delete_stream, get_current_branch, get_current_user,
reopen_task as write_reopen, set_stream as write_stream, update_stream as write_update_stream,
update_task as write_update, CreateTaskParams,
};
#[derive(Parser)]
#[command(name = "spool")]
#[command(about = "Git-native task management system")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Init,
Add {
title: String,
#[arg(short, long)]
description: Option<String>,
#[arg(short, long)]
priority: Option<String>,
#[arg(short, long)]
assignee: Option<String>,
#[arg(short, long)]
tag: Vec<String>,
#[arg(long)]
stream: Option<String>,
},
List {
#[arg(short, long, default_value = "open")]
status: String,
#[arg(short, long)]
assignee: Option<String>,
#[arg(short, long)]
tag: Option<String>,
#[arg(short, long)]
priority: Option<String>,
#[arg(long, conflicts_with = "stream_name")]
stream: Option<String>,
#[arg(long, conflicts_with = "stream")]
stream_name: Option<String>,
#[arg(long, conflicts_with_all = ["stream", "stream_name"])]
no_stream: bool,
#[arg(short, long, default_value = "table")]
format: String,
},
Show {
id: String,
#[arg(long)]
events: bool,
},
Rebuild,
Archive {
#[arg(short, long, default_value = "30")]
days: u32,
#[arg(long)]
dry_run: bool,
},
Validate {
#[arg(long)]
strict: bool,
},
Complete {
id: String,
#[arg(short, long, default_value = "done")]
resolution: String,
},
Reopen {
id: String,
},
Update {
id: String,
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
description: Option<String>,
#[arg(short, long)]
priority: Option<String>,
#[arg(long)]
stream: Option<String>,
},
Assign {
id: String,
assignee: String,
},
Claim {
id: String,
},
Stream {
#[command(subcommand)]
command: StreamCommands,
},
Free {
id: String,
},
}
#[derive(Subcommand)]
pub enum StreamCommands {
Add {
name: String,
#[arg(short, long)]
description: Option<String>,
},
List {
#[arg(short, long, default_value = "table")]
format: String,
},
Show {
id: Option<String>,
#[arg(short, long)]
name: Option<String>,
},
Update {
id: String,
#[arg(short, long)]
name: Option<String>,
#[arg(short, long)]
description: Option<String>,
},
Delete {
id: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OutputFormat {
Table,
Json,
Ids,
}
impl OutputFormat {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s {
"json" => OutputFormat::Json,
"ids" => OutputFormat::Ids,
_ => OutputFormat::Table,
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn list_tasks(
ctx: &SpoolContext,
status_filter: Option<&str>,
assignee: Option<&str>,
tag: Option<&str>,
priority: Option<&str>,
stream: Option<&str>,
stream_name: Option<&str>,
no_stream: bool,
format: OutputFormat,
) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
let stream_id_from_name: Option<String> = stream_name.and_then(|name| {
state
.streams
.iter()
.find(|(_, s)| s.name.eq_ignore_ascii_case(name))
.map(|(id, _)| id.clone())
});
let effective_stream = stream.map(String::from).or(stream_id_from_name);
let mut tasks: Vec<&Task> = state
.tasks
.values()
.filter(|t| {
let status_match = match status_filter {
Some("open") => t.status == TaskStatus::Open,
Some("complete") => t.status == TaskStatus::Complete,
Some("all") | None => true,
_ => true,
};
let assignee_match = assignee
.map(|a| t.assignee.as_deref() == Some(a))
.unwrap_or(true);
let tag_match = tag.map(|tg| t.tags.iter().any(|t| t == tg)).unwrap_or(true);
let priority_match = priority
.map(|p| t.priority.as_deref() == Some(p))
.unwrap_or(true);
let stream_match = if no_stream {
t.stream.is_none()
} else {
effective_stream
.as_ref()
.map(|s| t.stream.as_deref() == Some(s.as_str()))
.unwrap_or(true)
};
status_match && assignee_match && tag_match && priority_match && stream_match
})
.collect();
tasks.sort_by_key(|t| t.created);
match format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&tasks)?;
println!("{}", json);
}
OutputFormat::Ids => {
for task in &tasks {
println!("{}", task.id);
}
}
OutputFormat::Table => {
if tasks.is_empty() {
println!("No tasks found.");
return Ok(());
}
println!("{:<15} {:<10} {:<12} TITLE", "ID", "PRIORITY", "ASSIGNEE");
for task in &tasks {
let priority = task.priority.as_deref().unwrap_or("-");
let assignee = task.assignee.as_deref().unwrap_or("-");
let title = if task.title.len() > 50 {
format!("{}...", &task.title[..47])
} else {
task.title.clone()
};
println!(
"{:<15} {:<10} {:<12} {}",
task.id, priority, assignee, title
);
}
}
}
Ok(())
}
pub fn show_task(ctx: &SpoolContext, id: &str, show_events: bool) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
let task = state
.tasks
.get(id)
.ok_or_else(|| anyhow!("Task not found: {}", id))?;
println!("ID: {}", task.id);
println!("Title: {}", task.title);
println!("Status: {:?}", task.status);
if let Some(s) = &task.stream {
println!("Stream: {}", s);
}
if let Some(p) = &task.priority {
println!("Priority: {}", p);
}
if let Some(a) = &task.assignee {
println!("Assignee: {}", a);
}
if !task.tags.is_empty() {
println!("Tags: {}", task.tags.join(", "));
}
if let Some(d) = &task.description {
println!("Description:\n {}", d.replace('\n', "\n "));
}
println!(
"Created: {} by {} on {}",
task.created, task.created_by, task.created_branch
);
println!("Updated: {}", task.updated);
if let Some(c) = task.completed {
println!(
"Completed: {} ({})",
c,
task.resolution.as_deref().unwrap_or("done")
);
}
if let Some(a) = &task.archived {
println!("Archived: {}", a);
}
if let Some(p) = &task.parent {
println!("Parent: {}", p);
}
if !task.blocks.is_empty() {
println!("Blocks: {}", task.blocks.join(", "));
}
if !task.blocked_by.is_empty() {
println!("Blocked by: {}", task.blocked_by.join(", "));
}
if !task.comments.is_empty() {
println!("\nComments:");
for comment in &task.comments {
println!(" [{} - {}]", comment.ts, comment.by);
println!(" {}", comment.body.replace('\n', "\n "));
if let Some(r) = &comment.r#ref {
println!(" ref: {}", r);
}
println!();
}
}
if show_events {
println!("\nEvent History:");
let all_events = collect_all_events(ctx)?;
if let Some(events) = all_events.get(id) {
for event in events {
println!(
" {} {} by {} on {}",
event.ts, event.op, event.by, event.branch
);
}
}
}
Ok(())
}
pub fn complete_task(ctx: &SpoolContext, id: &str, resolution: Option<&str>) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
let task = state
.tasks
.get(id)
.ok_or_else(|| anyhow!("Task not found: {}", id))?;
if task.status == TaskStatus::Complete {
return Err(anyhow!("Task is already complete: {}", id));
}
let user = get_current_user()?;
let branch = get_current_branch()?;
write_complete(ctx, id, resolution, &user, &branch)?;
println!("Completed task: {} ({})", id, resolution.unwrap_or("done"));
Ok(())
}
pub fn reopen_task(ctx: &SpoolContext, id: &str) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
let task = state
.tasks
.get(id)
.ok_or_else(|| anyhow!("Task not found: {}", id))?;
if task.status == TaskStatus::Open {
return Err(anyhow!("Task is already open: {}", id));
}
let user = get_current_user()?;
let branch = get_current_branch()?;
write_reopen(ctx, id, &user, &branch)?;
println!("Reopened task: {}", id);
Ok(())
}
pub fn update_task(
ctx: &SpoolContext,
id: &str,
title: Option<&str>,
description: Option<&str>,
priority: Option<&str>,
stream: Option<&str>,
) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
state
.tasks
.get(id)
.ok_or_else(|| anyhow!("Task not found: {}", id))?;
if let Some(s) = stream {
if !s.is_empty() && !state.streams.contains_key(s) {
return Err(anyhow!(
"Stream not found: {}. Use 'spool stream add' to create it first.",
s
));
}
}
let user = get_current_user()?;
let branch = get_current_branch()?;
if title.is_some() || description.is_some() || priority.is_some() {
write_update(ctx, id, title, description, priority, &user, &branch)?;
}
if let Some(s) = stream {
let stream_value = if s.is_empty() { None } else { Some(s) };
write_stream(ctx, id, stream_value, &user, &branch)?;
}
let mut updates = Vec::new();
if title.is_some() {
updates.push("title");
}
if description.is_some() {
updates.push("description");
}
if priority.is_some() {
updates.push("priority");
}
if stream.is_some() {
updates.push("stream");
}
println!("Updated task {}: {}", id, updates.join(", "));
Ok(())
}
pub fn add_task(
ctx: &SpoolContext,
title: &str,
description: Option<&str>,
priority: Option<&str>,
assignee: Option<&str>,
tags: Vec<String>,
stream: Option<&str>,
) -> Result<()> {
if let Some(s) = stream {
let state = load_or_materialize_state(ctx)?;
if !state.streams.contains_key(s) {
return Err(anyhow!(
"Stream not found: {}. Use 'spool stream add' to create it first.",
s
));
}
}
let user = get_current_user()?;
let branch = get_current_branch()?;
let id = write_create(
ctx,
CreateTaskParams {
title,
description,
priority,
assignee,
tags,
stream,
},
&user,
&branch,
)?;
println!("Created task: {}", id);
Ok(())
}
pub fn assign_task(ctx: &SpoolContext, id: &str, assignee: &str) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
state
.tasks
.get(id)
.ok_or_else(|| anyhow!("Task not found: {}", id))?;
let user = get_current_user()?;
let branch = get_current_branch()?;
write_assign(ctx, id, Some(assignee), &user, &branch)?;
println!("Assigned task {} to {}", id, assignee);
Ok(())
}
pub fn claim_task(ctx: &SpoolContext, id: &str) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
state
.tasks
.get(id)
.ok_or_else(|| anyhow!("Task not found: {}", id))?;
let user = get_current_user()?;
let branch = get_current_branch()?;
write_assign(ctx, id, Some(&user), &user, &branch)?;
println!("Claimed task {} (assigned to {})", id, user);
Ok(())
}
pub fn free_task(ctx: &SpoolContext, id: &str) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
state
.tasks
.get(id)
.ok_or_else(|| anyhow!("Task not found: {}", id))?;
let user = get_current_user()?;
let branch = get_current_branch()?;
write_assign(ctx, id, None, &user, &branch)?;
println!("Freed task {} (unassigned)", id);
Ok(())
}
pub fn add_stream(ctx: &SpoolContext, name: &str, description: Option<&str>) -> Result<()> {
let user = get_current_user()?;
let branch = get_current_branch()?;
let id = write_create_stream(ctx, name, description, &user, &branch)?;
println!("Created stream: {} ({})", name, id);
Ok(())
}
pub fn list_streams(ctx: &SpoolContext, format: OutputFormat) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
let mut streams: Vec<_> = state.streams.values().collect();
streams.sort_by_key(|s| &s.created);
let mut task_counts: std::collections::HashMap<&str, (usize, usize)> =
std::collections::HashMap::new();
for task in state.tasks.values() {
if let Some(stream_id) = &task.stream {
let entry = task_counts.entry(stream_id.as_str()).or_insert((0, 0));
if task.status == TaskStatus::Open {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
}
match format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&streams)?;
println!("{}", json);
}
OutputFormat::Ids => {
for stream in &streams {
println!("{}", stream.id);
}
}
OutputFormat::Table => {
if streams.is_empty() {
println!("No streams found.");
return Ok(());
}
println!(
"{:<15} {:<20} {:<10} {:<10}",
"ID", "NAME", "OPEN", "COMPLETE"
);
for stream in &streams {
let (open, complete) = task_counts
.get(stream.id.as_str())
.copied()
.unwrap_or((0, 0));
let name = if stream.name.len() > 18 {
format!("{}...", &stream.name[..15])
} else {
stream.name.clone()
};
println!(
"{:<15} {:<20} {:<10} {:<10}",
stream.id, name, open, complete
);
}
}
}
Ok(())
}
pub fn show_stream(ctx: &SpoolContext, id: Option<&str>, name: Option<&str>) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
let stream = match (id, name) {
(Some(id), _) => state
.streams
.get(id)
.ok_or_else(|| anyhow!("Stream not found: {}", id))?,
(None, Some(name)) => state
.streams
.values()
.find(|s| s.name == name)
.ok_or_else(|| anyhow!("Stream not found with name: {}", name))?,
(None, None) => return Err(anyhow!("Either stream ID or --name must be provided")),
};
let stream_id = &stream.id;
println!("ID: {}", stream.id);
println!("Name: {}", stream.name);
if let Some(d) = &stream.description {
println!("Description: {}", d);
}
println!("Created: {} by {}", stream.created, stream.created_by);
let mut tasks: Vec<&Task> = state
.tasks
.values()
.filter(|t| t.stream.as_deref() == Some(stream_id.as_str()))
.collect();
tasks.sort_by_key(|t| t.created);
let open_count = tasks
.iter()
.filter(|t| t.status == TaskStatus::Open)
.count();
let complete_count = tasks.len() - open_count;
println!("\nTasks: {} open, {} complete", open_count, complete_count);
if !tasks.is_empty() {
println!("\n{:<15} {:<10} {:<10} TITLE", "ID", "STATUS", "PRIORITY");
for task in &tasks {
let status = match task.status {
TaskStatus::Open => "open",
TaskStatus::Complete => "complete",
};
let priority = task.priority.as_deref().unwrap_or("-");
let title = if task.title.len() > 40 {
format!("{}...", &task.title[..37])
} else {
task.title.clone()
};
println!("{:<15} {:<10} {:<10} {}", task.id, status, priority, title);
}
}
Ok(())
}
pub fn update_stream_cmd(
ctx: &SpoolContext,
id: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
state
.streams
.get(id)
.ok_or_else(|| anyhow!("Stream not found: {}", id))?;
let user = get_current_user()?;
let branch = get_current_branch()?;
write_update_stream(ctx, id, name, description, &user, &branch)?;
let mut updates = Vec::new();
if name.is_some() {
updates.push("name");
}
if description.is_some() {
updates.push("description");
}
println!("Updated stream {}: {}", id, updates.join(", "));
Ok(())
}
pub fn delete_stream(ctx: &SpoolContext, id: &str) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
let stream = state
.streams
.get(id)
.ok_or_else(|| anyhow!("Stream not found: {}", id))?;
let task_count = state
.tasks
.values()
.filter(|t| t.stream.as_deref() == Some(id))
.count();
if task_count > 0 {
return Err(anyhow!(
"Cannot delete stream '{}': {} tasks are still assigned. Move or remove tasks first.",
stream.name,
task_count
));
}
let user = get_current_user()?;
let branch = get_current_branch()?;
write_delete_stream(ctx, id, &user, &branch)?;
println!("Deleted stream: {} ({})", stream.name, id);
Ok(())
}
pub fn set_task_stream(ctx: &SpoolContext, task_id: &str, stream_id: Option<&str>) -> Result<()> {
let state = load_or_materialize_state(ctx)?;
state
.tasks
.get(task_id)
.ok_or_else(|| anyhow!("Task not found: {}", task_id))?;
if let Some(sid) = stream_id {
state
.streams
.get(sid)
.ok_or_else(|| anyhow!("Stream not found: {}", sid))?;
}
let user = get_current_user()?;
let branch = get_current_branch()?;
write_stream(ctx, task_id, stream_id, &user, &branch)?;
match stream_id {
Some(s) => println!("Moved task {} to stream {}", task_id, s),
None => println!("Removed task {} from stream", task_id),
}
Ok(())
}