use super::{default_identity, discover};
use balls::error::{BallError, Result};
use balls::participant::Event;
use balls::participant_config::{override_tokens, InvocationOverrides};
use balls::plugin::{self, Rollback};
use balls::store::task_lock;
use balls::task::{Status, Task, TaskType};
use balls::task_io;
pub fn cmd_update(
id: String,
assignments: Vec<String>,
note: Option<String>,
identity: Option<String>,
overrides: InvocationOverrides,
) -> Result<()> {
let store = discover()?;
let ident = identity.unwrap_or_else(default_identity);
let closing = assignments.iter().any(|a| a == "status=closed");
let rejecting = !closing && assignments.iter().any(|a| a == "status=in_progress");
let rb = plugin::state_head(&store)?;
let (task, task_before) = {
let _g = task_lock(&store, &id)?;
let mut task = store.load_task(&id)?;
let task_before = task.clone();
if closing && task.claimed_by.is_some() {
return Err(BallError::InvalidTask(
"use `bl close` for claimed tasks (handles worktree teardown and merge)".into(),
));
}
if closing {
balls::review::enforce_gates(&store, &task)?;
}
for assign in &assignments {
let (field, value) = assign.split_once('=').ok_or_else(|| {
BallError::InvalidTask(format!("expected field=value, got: {assign}"))
})?;
apply_field(&mut task, field, value)?;
}
if closing {
task.closed_at = Some(chrono::Utc::now());
}
task.touch();
if closing {
let msg = match ¬e {
Some(n) => format!("balls: close {} - {}\n\n{}", id, task.title, n),
None => format!("balls: close {} - {}", id, task.title),
};
store.close_and_archive(&task, &msg)?;
} else if rejecting
&& balls::review_deferred::reject_deferred(
&store,
&mut task,
note.as_deref(),
&ident,
)?
{
} else {
store.save_task(&task)?;
if let Some(n) = ¬e {
task_io::append_note_to(&store.task_path(&id)?, &ident, n)?;
}
store.commit_task(&id, &format!("balls: update {} - {}", id, task.title))?;
}
(task, task_before)
};
let event = if closing { Event::Close } else { Event::Update };
let tokens = override_tokens(&overrides, false, false);
plugin::finish(
&store,
Some(&task_before),
&task,
event,
&ident,
&overrides,
&tokens,
Rollback::State(rb.as_deref()),
)?;
if closing {
println!("closed and archived {id}");
} else {
println!("updated {id}");
}
Ok(())
}
fn apply_field(task: &mut Task, field: &str, value: &str) -> Result<()> {
match field {
"title" => task.title = value.to_string(),
"priority" => task.priority = balls::task::parse_priority(value)?,
"status" => task.status = Status::parse(value)?,
"type" => task.task_type = TaskType::parse(value)?,
"parent" => {
task.parent = if value.is_empty() || value == "null" {
None
} else {
Some(value.to_string())
};
}
"description" => task.description = value.to_string(),
_ => {
return Err(BallError::InvalidTask(format!(
"unknown or unwritable field: {field}"
)));
}
}
Ok(())
}