use super::{
auto_import_storage_ctx_if_stale, finalize_batched_blocked_cache_refresh,
preserve_blocked_cache_on_error, resolve_issue_id, resolve_issue_ids,
retry_mutation_with_jsonl_recovery, update_issue_with_recovery,
};
use crate::cli::UpdateArgs;
use crate::config;
use crate::error::{BeadsError, Result};
use crate::model::{Issue, Status};
use crate::output::OutputContext;
use crate::storage::{IssueUpdate, SqliteStorage};
use crate::util::id::{IdResolver, ResolverConfig};
use crate::util::time::parse_flexible_timestamp;
use crate::validation::LabelValidator;
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
#[derive(Debug, Serialize)]
struct UpdatedIssueOutput {
id: String,
title: String,
status: String,
priority: i32,
updated_at: DateTime<Utc>,
}
impl From<&Issue> for UpdatedIssueOutput {
fn from(issue: &Issue) -> Self {
Self {
id: issue.id.clone(),
title: issue.title.clone(),
status: issue.status.as_str().to_string(),
priority: issue.priority.0,
updated_at: issue.updated_at,
}
}
}
#[derive(Debug)]
enum UpdateRenderItem {
Summary {
id: String,
title: String,
before: Box<Option<Issue>>,
after: Box<Issue>,
},
NoUpdates {
id: String,
},
}
#[derive(Debug)]
struct UpdateRouteOutput {
updated_issues: Vec<UpdatedIssueOutput>,
render_items: Vec<UpdateRenderItem>,
resolved_ids: Vec<String>,
}
enum ParentUpdatePlan {
Unchanged,
Clear,
Set(String),
}
struct PreparedUpdateRoute {
storage_ctx: config::OpenStorageResult,
actor: String,
resolved_ids: Vec<String>,
update: IssueUpdate,
has_updates: bool,
add_labels: Vec<String>,
remove_labels: Vec<String>,
set_labels: bool,
valid_set_labels: Vec<String>,
resolved_parent: ParentUpdatePlan,
auto_flush_external: bool,
}
pub fn execute(args: &UpdateArgs, cli: &config::CliOverrides, ctx: &OutputContext) -> Result<()> {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
let mut target_inputs = args.ids.clone();
if target_inputs.is_empty() {
let last_touched = crate::util::get_last_touched_id(&beads_dir);
if last_touched.is_empty() {
return Err(BeadsError::validation(
"ids",
"no issue IDs provided and no last-touched issue",
));
}
target_inputs.push(last_touched);
}
let routed_batches = config::routing::group_issue_inputs_by_route(&target_inputs, &beads_dir)?;
let (updated_issues, render_items, ordered_resolved_ids) = if routed_batches
.iter()
.any(|batch| batch.is_external)
{
let normalized_local_beads_dir =
dunce::canonicalize(&beads_dir).unwrap_or_else(|_| beads_dir.clone());
let mut prepared_routes = Vec::new();
let mut routed_updated_issues = Vec::new();
let mut routed_render_items = Vec::new();
let mut routed_resolved_ids = Vec::new();
for batch in routed_batches {
let mut batch_args = args.clone();
batch_args.ids.clone_from(&batch.issue_inputs);
let normalized_batch_beads_dir =
dunce::canonicalize(&batch.beads_dir).unwrap_or_else(|_| batch.beads_dir.clone());
let mut batch_cli = cli.clone();
batch_cli.db = if normalized_batch_beads_dir == normalized_local_beads_dir {
cli.db.clone()
} else {
None
};
prepared_routes.push((
batch.issue_inputs.clone(),
prepare_single_route(&batch_args, &batch_cli, &batch.beads_dir, batch.is_external)?,
));
}
let all_resolved_ids = prepared_routes
.iter()
.flat_map(|(_, route)| route.resolved_ids.iter().cloned())
.collect::<Vec<_>>();
validate_multi_issue_external_ref_update(args.external_ref.as_deref(), &all_resolved_ids)?;
for (issue_inputs, prepared_route) in prepared_routes {
let route_output = execute_prepared_route(prepared_route, ctx)?;
if ctx.is_json() || ctx.is_toon() {
routed_updated_issues.push((issue_inputs.clone(), route_output.updated_issues));
} else if !ctx.is_quiet() {
routed_render_items.push((issue_inputs.clone(), route_output.render_items));
}
routed_resolved_ids.push((issue_inputs, route_output.resolved_ids));
}
let updated_issues = if ctx.is_json() || ctx.is_toon() {
reorder_routed_items_by_requested_inputs(
&target_inputs,
routed_updated_issues,
"update routing",
)?
} else {
Vec::new()
};
let render_items = if !ctx.is_quiet() && !ctx.is_json() && !ctx.is_toon() {
reorder_routed_items_by_requested_inputs(
&target_inputs,
routed_render_items,
"update routing",
)?
} else {
Vec::new()
};
let ordered_resolved_ids = reorder_routed_items_by_requested_inputs(
&target_inputs,
routed_resolved_ids,
"update routing",
)?;
(updated_issues, render_items, ordered_resolved_ids)
} else {
let route_output =
execute_prepared_route(prepare_single_route(args, cli, &beads_dir, false)?, ctx)?;
(
route_output.updated_issues,
route_output.render_items,
route_output.resolved_ids,
)
};
if let Some(last_id) = ordered_resolved_ids.last() {
crate::util::set_last_touched_id(&beads_dir, last_id);
}
if ctx.is_toon() {
ctx.toon(&updated_issues);
} else if ctx.is_json() {
ctx.json_pretty(&updated_issues);
} else if !ctx.is_quiet() {
print_render_items(&render_items);
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn prepare_single_route(
args: &UpdateArgs,
cli: &config::CliOverrides,
beads_dir: &Path,
auto_flush_external: bool,
) -> Result<PreparedUpdateRoute> {
let mut storage_ctx = config::open_storage_with_cli(beads_dir, cli)?;
auto_import_storage_ctx_if_stale(&mut storage_ctx, cli)?;
let config_layer = storage_ctx.load_config(cli)?;
let actor = config::resolve_actor(&config_layer);
let resolver = build_resolver(&config_layer, &storage_ctx.storage);
let resolved_ids = resolve_target_ids(args, beads_dir, &resolver, &storage_ctx.storage)?;
let claim_exclusive = config::claim_exclusive_from_layer(&config_layer);
let update = build_update(args, &actor, claim_exclusive)?;
let has_updates = !update.is_empty()
|| !args.add_label.is_empty()
|| !args.remove_label.is_empty()
|| !args.set_labels.is_empty()
|| args.parent.is_some();
validate_mutable_target_issues(&storage_ctx.storage, &resolved_ids, has_updates)?;
for label in &args.add_label {
LabelValidator::validate(label).map_err(|e| BeadsError::validation("label", e.message))?;
}
let mut valid_set_labels = Vec::new();
if !args.set_labels.is_empty() {
let combined = args.set_labels.join(",");
for label in combined.split(',') {
let label = label.trim();
if !label.is_empty() {
LabelValidator::validate(label)
.map_err(|e| BeadsError::validation("label", e.message))?;
valid_set_labels.push(label.to_string());
}
}
}
let resolved_parent =
resolve_parent_update(args.parent.as_deref(), &resolver, &storage_ctx.storage)?;
validate_parent_updates(&storage_ctx.storage, &resolved_ids, &resolved_parent)?;
validate_transition_to_in_progress(&storage_ctx.storage, &resolved_ids, args)?;
validate_route_runtime_guards(&storage_ctx.storage, &resolved_ids, &update)?;
Ok(PreparedUpdateRoute {
storage_ctx,
actor,
resolved_ids,
update,
has_updates,
add_labels: args.add_label.clone(),
remove_labels: args.remove_label.clone(),
set_labels: !args.set_labels.is_empty(),
valid_set_labels,
resolved_parent,
auto_flush_external,
})
}
#[allow(clippy::too_many_lines)]
fn execute_prepared_route(
mut prepared: PreparedUpdateRoute,
ctx: &OutputContext,
) -> Result<UpdateRouteOutput> {
let mut updated_issues: Vec<UpdatedIssueOutput> = Vec::new();
let mut render_items = Vec::new();
let resolved_ids = prepared.resolved_ids.clone();
let mut route_has_mutated = false;
let mut blocked_cache_dirty = false;
let defer_blocked_cache_rebuild = prepared.update.status.is_some()
|| !matches!(prepared.resolved_parent, ParentUpdatePlan::Unchanged);
let parent_changes_cache = !matches!(prepared.resolved_parent, ParentUpdatePlan::Unchanged);
for id in &prepared.resolved_ids {
let issue_before_result = prepared.storage_ctx.storage.get_issue(id);
let issue_before = preserve_blocked_cache_on_error(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
issue_before_result,
)?;
if !prepared.update.is_empty() {
let mut issue_update = prepared.update.clone();
issue_update.skip_cache_rebuild = defer_blocked_cache_rebuild;
let update_result = update_issue_with_recovery(
&mut prepared.storage_ctx,
!route_has_mutated,
"update",
id,
&issue_update,
&prepared.actor,
);
preserve_blocked_cache_on_error(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
update_result,
)?;
if prepared.update.status.is_some() {
blocked_cache_dirty = true;
}
route_has_mutated = true;
}
for label in &prepared.add_labels {
let add_label_result = retry_mutation_with_jsonl_recovery(
&mut prepared.storage_ctx,
!route_has_mutated,
"update label add",
Some(id.as_str()),
|storage| storage.add_label(id, label, &prepared.actor),
);
preserve_blocked_cache_on_error(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
add_label_result,
)?;
route_has_mutated = true;
}
for label in &prepared.remove_labels {
let remove_label_result = retry_mutation_with_jsonl_recovery(
&mut prepared.storage_ctx,
!route_has_mutated,
"update label remove",
Some(id.as_str()),
|storage| storage.remove_label(id, label, &prepared.actor),
);
preserve_blocked_cache_on_error(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
remove_label_result,
)?;
route_has_mutated = true;
}
if prepared.set_labels {
let set_labels_result = retry_mutation_with_jsonl_recovery(
&mut prepared.storage_ctx,
!route_has_mutated,
"update label set",
Some(id.as_str()),
|storage| storage.set_labels(id, &prepared.valid_set_labels, &prepared.actor),
);
preserve_blocked_cache_on_error(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
set_labels_result,
)?;
route_has_mutated = true;
}
let parent_result = apply_parent_update(
&mut prepared.storage_ctx,
!route_has_mutated,
id,
&prepared.resolved_parent,
&prepared.actor,
defer_blocked_cache_rebuild,
);
preserve_blocked_cache_on_error(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
parent_result,
)?;
if parent_changes_cache {
route_has_mutated = true;
blocked_cache_dirty = true;
}
let issue_after_result = prepared.storage_ctx.storage.get_issue(id);
let issue_after = preserve_blocked_cache_on_error(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
issue_after_result,
)?;
if let Some(issue) = issue_after {
if ctx.is_json() || ctx.is_toon() {
updated_issues.push(UpdatedIssueOutput::from(&issue));
} else if ctx.is_quiet() {
} else if prepared.has_updates {
render_items.push(UpdateRenderItem::Summary {
id: id.clone(),
title: issue.title.clone(),
before: Box::new(issue_before),
after: Box::new(issue),
});
} else {
render_items.push(UpdateRenderItem::NoUpdates { id: id.clone() });
}
}
}
if defer_blocked_cache_rebuild && blocked_cache_dirty {
finalize_batched_blocked_cache_refresh(
&mut prepared.storage_ctx.storage,
blocked_cache_dirty,
"update",
)?;
}
prepared.storage_ctx.flush_no_db_if_dirty()?;
if prepared.auto_flush_external
&& let Err(error) = prepared.storage_ctx.auto_flush_if_enabled()
{
tracing::debug!(
beads_dir = %prepared.storage_ctx.paths.beads_dir.display(),
error = %error,
"Routed auto-flush failed (non-fatal)"
);
}
Ok(UpdateRouteOutput {
updated_issues,
render_items,
resolved_ids,
})
}
fn validate_multi_issue_external_ref_update(
external_ref: Option<&str>,
resolved_ids: &[String],
) -> Result<()> {
let Some(external_ref) = external_ref.filter(|value| !value.is_empty()) else {
return Ok(());
};
let distinct_ids = resolved_ids
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
if distinct_ids.len() > 1 {
return Err(BeadsError::validation(
"external_ref",
format!(
"cannot set external_ref '{external_ref}' on multiple issues in a single update"
),
));
}
Ok(())
}
fn validate_route_runtime_guards(
storage: &SqliteStorage,
resolved_ids: &[String],
update: &IssueUpdate,
) -> Result<()> {
if update.expect_unassigned {
let claim_actor = update.claim_actor.as_deref().unwrap_or("");
for id in resolved_ids {
let issue = storage
.get_issue(id)?
.ok_or_else(|| BeadsError::IssueNotFound { id: id.clone() })?;
let trimmed = issue
.assignee
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match trimmed {
None => {}
Some(current) if !update.claim_exclusive && current == claim_actor => {}
Some(current) => {
return Err(BeadsError::validation(
"claim",
format!("issue {id} already assigned to {current}"),
));
}
}
}
}
validate_multi_issue_external_ref_update(
update
.external_ref
.as_ref()
.and_then(|value| value.as_deref()),
resolved_ids,
)?;
if let Some(Some(external_ref)) = &update.external_ref
&& let Some(existing_issue) = storage.find_by_external_ref(external_ref)?
&& existing_issue.id != resolved_ids.first().map_or("", String::as_str)
{
return Err(BeadsError::Config(format!(
"External reference '{external_ref}' already exists on issue {}",
existing_issue.id
)));
}
Ok(())
}
fn validate_transition_to_in_progress(
storage: &SqliteStorage,
ids: &[String],
args: &UpdateArgs,
) -> Result<()> {
let transitioning_to_in_progress = args.claim
|| args
.status
.as_ref()
.is_some_and(|status| status.eq_ignore_ascii_case("in_progress"));
if !transitioning_to_in_progress || args.force {
return Ok(());
}
for id in ids {
if storage.is_blocked(id)? {
let blockers = storage.get_blockers(id)?;
let blocker_list = if blockers.is_empty() {
"blocking dependencies".to_string()
} else {
blockers.join(", ")
};
return Err(BeadsError::validation(
"claim",
format!("cannot claim blocked issue: {blocker_list}"),
));
}
}
Ok(())
}
fn print_update_summary(id: &str, title: &str, before: Option<&Issue>, after: &Issue) {
println!("Updated {id}: {title}");
if let Some(before) = before {
if before.status != after.status {
println!(
" status: {} → {}",
before.status.as_str(),
after.status.as_str()
);
}
if before.priority != after.priority {
println!(" priority: P{} → P{}", before.priority.0, after.priority.0);
}
if before.issue_type != after.issue_type {
println!(
" type: {} → {}",
before.issue_type.as_str(),
after.issue_type.as_str()
);
}
if before.assignee != after.assignee {
let before_assignee = before.assignee.as_deref().unwrap_or("(none)");
let after_assignee = after.assignee.as_deref().unwrap_or("(none)");
println!(" assignee: {before_assignee} → {after_assignee}");
}
if before.owner != after.owner {
let before_owner = before.owner.as_deref().unwrap_or("(none)");
let after_owner = after.owner.as_deref().unwrap_or("(none)");
println!(" owner: {before_owner} → {after_owner}");
}
}
}
fn print_render_items(render_items: &[UpdateRenderItem]) {
for item in render_items {
match item {
UpdateRenderItem::Summary {
id,
title,
before,
after,
} => print_update_summary(id, title, before.as_ref().as_ref(), after.as_ref()),
UpdateRenderItem::NoUpdates { id } => println!("No updates specified for {id}"),
}
}
}
fn reorder_routed_items_by_requested_inputs<T>(
requested_inputs: &[String],
routed_items: Vec<(Vec<String>, Vec<T>)>,
context: &str,
) -> Result<Vec<T>> {
let mut positions_by_input: HashMap<&str, VecDeque<usize>> = HashMap::new();
for (index, input) in requested_inputs.iter().enumerate() {
positions_by_input
.entry(input.as_str())
.or_default()
.push_back(index);
}
let mut ordered_items: Vec<Option<T>> = (0..requested_inputs.len()).map(|_| None).collect();
for (batch_inputs, batch_items) in routed_items {
if batch_inputs.len() != batch_items.len() {
return Err(BeadsError::Config(format!(
"{context} produced mismatched issue/result counts"
)));
}
for (input, item) in batch_inputs.into_iter().zip(batch_items) {
let Some(index) = positions_by_input
.get_mut(input.as_str())
.and_then(VecDeque::pop_front)
else {
return Err(BeadsError::Config(format!(
"{context} returned unexpected issue input {input}"
)));
};
ordered_items[index] = Some(item);
}
}
ordered_items
.into_iter()
.enumerate()
.map(|(index, item)| {
item.ok_or_else(|| {
BeadsError::Config(format!(
"{context} did not produce a result for {}",
requested_inputs[index]
))
})
})
.collect()
}
fn build_resolver(config_layer: &config::ConfigLayer, _storage: &SqliteStorage) -> IdResolver {
let id_config = config::id_config_from_layer(config_layer);
IdResolver::new(ResolverConfig::with_prefix(id_config.prefix))
}
fn resolve_target_ids(
args: &UpdateArgs,
beads_dir: &std::path::Path,
resolver: &IdResolver,
storage: &SqliteStorage,
) -> Result<Vec<String>> {
let mut ids = args.ids.clone();
if ids.is_empty() {
let last_touched = crate::util::get_last_touched_id(beads_dir);
if last_touched.is_empty() {
return Err(BeadsError::validation(
"ids",
"no issue IDs provided and no last-touched issue",
));
}
ids.push(last_touched);
}
resolve_issue_ids(storage, resolver, &ids)
}
fn validate_mutable_target_issues(
storage: &SqliteStorage,
ids: &[String],
has_updates: bool,
) -> Result<()> {
if !has_updates {
return Ok(());
}
for id in ids {
if storage
.get_issue(id)?
.as_ref()
.is_some_and(|issue| issue.status == Status::Tombstone)
{
return Err(BeadsError::validation(
"issue",
format!("cannot update tombstone issue: {id}"),
));
}
}
Ok(())
}
fn build_update(args: &UpdateArgs, actor: &str, claim_exclusive: bool) -> Result<IssueUpdate> {
let status = if args.claim {
Some(Status::InProgress)
} else {
args.status.as_ref().map(|s| s.parse()).transpose()?
};
let priority = args.priority.as_ref().map(|p| p.parse()).transpose()?;
let issue_type = args.type_.as_ref().map(|t| t.parse()).transpose()?;
let assignee = if args.claim {
Some(Some(actor.to_string()))
} else {
optional_string_field(args.assignee.as_deref())
};
let owner = optional_string_field(args.owner.as_deref());
let due_at = optional_date_field(args.due.as_deref())?;
let defer_until = optional_date_field(args.defer.as_deref())?;
let closed_at = match &status {
Some(Status::Closed) => Some(Some(Utc::now())),
Some(_) => Some(None),
None => None,
};
Ok(IssueUpdate {
title: args.title.clone(),
description: args.description.clone().map(Some),
design: args.design.clone().map(Some),
acceptance_criteria: args.acceptance_criteria.clone().map(Some),
notes: args.notes.clone().map(Some),
status,
priority,
issue_type,
assignee,
owner,
estimated_minutes: args.estimate.map(Some),
due_at,
defer_until,
external_ref: optional_string_field(args.external_ref.as_deref()),
closed_at,
close_reason: None,
closed_by_session: args.session.clone().map(Some),
deleted_at: None,
deleted_by: None,
delete_reason: None,
skip_cache_rebuild: false,
expect_unassigned: args.claim,
claim_exclusive: args.claim && claim_exclusive,
claim_actor: if args.claim {
Some(actor.to_string())
} else {
None
},
})
}
#[allow(clippy::option_option, clippy::single_option_map)]
fn optional_string_field(value: Option<&str>) -> Option<Option<String>> {
value.map(|v| {
if v.is_empty() {
None
} else {
Some(v.to_string())
}
})
}
#[allow(clippy::option_option)]
fn optional_date_field(value: Option<&str>) -> Result<Option<Option<DateTime<Utc>>>> {
value
.map(|v| {
if v.is_empty() {
Ok(None)
} else {
parse_date(v).map(Some)
}
})
.transpose()
}
fn resolve_parent_update(
parent: Option<&str>,
resolver: &IdResolver,
storage: &SqliteStorage,
) -> Result<ParentUpdatePlan> {
match parent {
None => Ok(ParentUpdatePlan::Unchanged),
Some("") => Ok(ParentUpdatePlan::Clear),
Some(parent_value) => {
resolve_issue_id(storage, resolver, parent_value).map(ParentUpdatePlan::Set)
}
}
}
fn apply_parent_update(
storage_ctx: &mut config::OpenStorageResult,
allow_recovery: bool,
issue_id: &str,
parent: &ParentUpdatePlan,
actor: &str,
skip_cache_rebuild: bool,
) -> Result<()> {
match parent {
ParentUpdatePlan::Unchanged => Ok(()),
ParentUpdatePlan::Clear => retry_mutation_with_jsonl_recovery(
storage_ctx,
allow_recovery,
"update parent clear",
Some(issue_id),
|storage| storage.set_parent_with_options(issue_id, None, actor, skip_cache_rebuild),
),
ParentUpdatePlan::Set(parent_id) => retry_mutation_with_jsonl_recovery(
storage_ctx,
allow_recovery,
"update parent set",
Some(issue_id),
|storage| {
storage.set_parent_with_options(
issue_id,
Some(parent_id),
actor,
skip_cache_rebuild,
)
},
),
}
}
fn validate_parent_updates(
storage: &SqliteStorage,
issue_ids: &[String],
parent: &ParentUpdatePlan,
) -> Result<()> {
let ParentUpdatePlan::Set(parent_id) = parent else {
return Ok(());
};
for issue_id in issue_ids {
if issue_id == parent_id {
return Err(BeadsError::SelfDependency {
id: issue_id.clone(),
});
}
if storage.would_create_cycle(issue_id, parent_id, true)? {
return Err(BeadsError::DependencyCycle {
path: format!("Setting parent of {issue_id} to {parent_id} would create a cycle"),
});
}
}
Ok(())
}
fn parse_date(s: &str) -> Result<DateTime<Utc>> {
parse_flexible_timestamp(s, "date")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CliOverrides;
use crate::logging::init_test_logging;
use crate::model::{Issue, IssueType, Priority, Status};
use crate::output::OutputContext;
use crate::storage::SqliteStorage;
use chrono::{Datelike, Timelike};
use std::fs;
use tempfile::TempDir;
use tracing::info;
#[test]
fn test_optional_string_field_with_value() {
init_test_logging();
info!("test_optional_string_field_with_value: starting");
let result = optional_string_field(Some("test"));
assert_eq!(result, Some(Some("test".to_string())));
info!("test_optional_string_field_with_value: assertions passed");
}
#[test]
fn test_optional_string_field_with_empty() {
init_test_logging();
info!("test_optional_string_field_with_empty: starting");
let result = optional_string_field(Some(""));
assert_eq!(result, Some(None));
info!("test_optional_string_field_with_empty: assertions passed");
}
#[test]
fn test_optional_string_field_with_none() {
init_test_logging();
info!("test_optional_string_field_with_none: starting");
let result = optional_string_field(None);
assert_eq!(result, None);
info!("test_optional_string_field_with_none: assertions passed");
}
#[test]
fn test_optional_date_field_with_valid() {
init_test_logging();
info!("test_optional_date_field_with_valid: starting");
let result = optional_date_field(Some("2024-01-15T12:00:00Z")).unwrap();
assert!(result.is_some());
let date = result.unwrap().unwrap();
assert_eq!(date.year(), 2024);
assert_eq!(date.month(), 1);
assert_eq!(date.day(), 15);
info!("test_optional_date_field_with_valid: assertions passed");
}
#[test]
fn test_optional_date_field_with_empty() {
init_test_logging();
info!("test_optional_date_field_with_empty: starting");
let result = optional_date_field(Some("")).unwrap();
assert_eq!(result, Some(None));
info!("test_optional_date_field_with_empty: assertions passed");
}
#[test]
fn test_optional_date_field_with_none() {
init_test_logging();
info!("test_optional_date_field_with_none: starting");
let result = optional_date_field(None).unwrap();
assert_eq!(result, None);
info!("test_optional_date_field_with_none: assertions passed");
}
#[test]
fn test_optional_date_field_invalid_format() {
init_test_logging();
info!("test_optional_date_field_invalid_format: starting");
let result = optional_date_field(Some("not-a-date"));
assert!(result.is_err());
info!("test_optional_date_field_invalid_format: assertions passed");
}
#[test]
fn test_parse_date_valid_rfc3339() {
init_test_logging();
info!("test_parse_date_valid_rfc3339: starting");
let result = parse_date("2024-06-15T10:30:00+00:00").unwrap();
assert_eq!(result.year(), 2024);
assert_eq!(result.month(), 6);
assert_eq!(result.day(), 15);
info!("test_parse_date_valid_rfc3339: assertions passed");
}
#[test]
fn test_parse_date_with_timezone() {
init_test_logging();
info!("test_parse_date_with_timezone: starting");
let result = parse_date("2024-12-25T08:00:00-05:00").unwrap();
assert_eq!(result.year(), 2024);
assert_eq!(result.month(), 12);
assert_eq!(result.day(), 25);
assert_eq!(result.hour(), 13); info!("test_parse_date_with_timezone: assertions passed");
}
#[test]
fn test_parse_date_invalid() {
init_test_logging();
info!("test_parse_date_invalid: starting");
let result = parse_date("invalid");
assert!(result.is_err());
info!("test_parse_date_invalid: assertions passed");
}
#[test]
fn test_parse_date_partial_date() {
init_test_logging();
info!("test_parse_date_partial_date: starting");
let result = parse_date("2024-01-15");
assert!(result.is_ok());
let date = result.unwrap();
assert_eq!(date.year(), 2024);
assert_eq!(date.month(), 1);
assert_eq!(date.day(), 15);
info!("test_parse_date_partial_date: assertions passed");
}
#[test]
fn test_build_update_with_claim() {
init_test_logging();
info!("test_build_update_with_claim: starting");
let args = UpdateArgs {
claim: true,
..Default::default()
};
let update = build_update(&args, "test_actor", false).unwrap();
assert_eq!(update.status, Some(Status::InProgress));
assert_eq!(update.assignee, Some(Some("test_actor".to_string())));
info!("test_build_update_with_claim: assertions passed");
}
#[test]
fn test_build_update_with_status() {
init_test_logging();
info!("test_build_update_with_status: starting");
let args = UpdateArgs {
status: Some("closed".to_string()),
..Default::default()
};
let update = build_update(&args, "test_actor", false).unwrap();
assert_eq!(update.status, Some(Status::Closed));
assert!(update.closed_at.is_some());
let args_blocked = UpdateArgs {
status: Some("blocked".to_string()),
..Default::default()
};
let update_blocked = build_update(&args_blocked, "test_actor", false).unwrap();
assert_eq!(update_blocked.status, Some(Status::Blocked));
assert_eq!(update_blocked.closed_at, Some(None));
info!("test_build_update_with_status: assertions passed");
}
#[test]
fn test_build_update_with_priority() {
init_test_logging();
info!("test_build_update_with_priority: starting");
let args = UpdateArgs {
priority: Some("1".to_string()),
..Default::default()
};
let update = build_update(&args, "test_actor", false).unwrap();
assert_eq!(update.priority, Some(Priority(1)));
info!("test_build_update_with_priority: assertions passed");
}
#[test]
fn test_build_update_empty() {
init_test_logging();
info!("test_build_update_empty: starting");
let args = UpdateArgs::default();
let update = build_update(&args, "test_actor", false).unwrap();
assert!(update.is_empty());
info!("test_build_update_empty: assertions passed");
}
#[test]
fn test_validate_mutable_target_issues_rejects_tombstone() {
init_test_logging();
info!("test_validate_mutable_target_issues_rejects_tombstone: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = Issue {
id: "bd-tombstone".to_string(),
title: "Deleted issue".to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
..Issue::default()
};
storage.create_issue(&issue, "tester").unwrap();
storage
.delete_issue("bd-tombstone", "tester", "delete for update test", None)
.unwrap();
let err = validate_mutable_target_issues(&storage, &["bd-tombstone".to_string()], true)
.unwrap_err();
match err {
BeadsError::Validation { field, reason } => {
assert_eq!(field, "issue");
assert!(reason.contains("cannot update tombstone issue"));
}
other => panic!("unexpected error: {other:?}"),
}
info!("test_validate_mutable_target_issues_rejects_tombstone: assertions passed");
}
#[test]
fn test_validate_mutable_target_issues_allows_open_issue() {
init_test_logging();
info!("test_validate_mutable_target_issues_allows_open_issue: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = Issue {
id: "bd-open".to_string(),
title: "Open issue".to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
..Issue::default()
};
storage.create_issue(&issue, "tester").unwrap();
validate_mutable_target_issues(&storage, &["bd-open".to_string()], true).unwrap();
info!("test_validate_mutable_target_issues_allows_open_issue: assertions passed");
}
#[test]
fn test_validate_route_runtime_guards_rejects_assigned_claim_target() {
init_test_logging();
info!("test_validate_route_runtime_guards_rejects_assigned_claim_target: starting");
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = Issue {
id: "bd-claimed".to_string(),
title: "Claimed issue".to_string(),
assignee: Some("bob".to_string()),
status: Status::InProgress,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
..Issue::default()
};
storage.create_issue(&issue, "tester").unwrap();
let update = IssueUpdate {
expect_unassigned: true,
claim_actor: Some("alice".to_string()),
assignee: Some(Some("alice".to_string())),
status: Some(Status::InProgress),
..IssueUpdate::default()
};
let err = validate_route_runtime_guards(&storage, &["bd-claimed".to_string()], &update)
.unwrap_err();
assert!(err.to_string().contains("already assigned to bob"));
info!(
"test_validate_route_runtime_guards_rejects_assigned_claim_target: assertions passed"
);
}
#[test]
fn test_validate_multi_issue_external_ref_update_rejects_multiple_distinct_ids() {
init_test_logging();
info!(
"test_validate_multi_issue_external_ref_update_rejects_multiple_distinct_ids: starting"
);
let err = validate_multi_issue_external_ref_update(
Some("EXT-123"),
&["bd-1".to_string(), "bd-2".to_string()],
)
.unwrap_err();
assert!(
err.to_string()
.contains("cannot set external_ref 'EXT-123'")
);
info!(
"test_validate_multi_issue_external_ref_update_rejects_multiple_distinct_ids: assertions passed"
);
}
#[test]
fn test_execute_prepared_route_repairs_blocked_cache_after_late_update_error() {
init_test_logging();
info!(
"test_execute_prepared_route_repairs_blocked_cache_after_late_update_error: starting"
);
let temp = TempDir::new().expect("tempdir");
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
let mut storage_ctx =
config::open_storage_with_cli(&beads_dir, &CliOverrides::default()).expect("storage");
let blocker = Issue {
id: "bd-blocker".to_string(),
title: "Blocker".to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
..Issue::default()
};
let dependent = Issue {
id: "bd-blocked".to_string(),
title: "Blocked".to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
..Issue::default()
};
storage_ctx
.storage
.create_issue(&blocker, "tester")
.expect("create blocker");
storage_ctx
.storage
.create_issue(&dependent, "tester")
.expect("create blocked");
storage_ctx
.storage
.add_dependency("bd-blocked", "bd-blocker", "blocks", "tester")
.expect("create dependency");
assert!(
storage_ctx
.storage
.get_blocked_ids()
.expect("blocked ids before update")
.contains("bd-blocked")
);
storage_ctx
.storage
.execute_raw("DROP TABLE labels")
.expect("drop labels table");
let prepared = PreparedUpdateRoute {
storage_ctx,
actor: "tester".to_string(),
resolved_ids: vec!["bd-blocker".to_string()],
update: IssueUpdate {
status: Some(Status::Closed),
..IssueUpdate::default()
},
has_updates: true,
add_labels: vec!["late-runtime-error".to_string()],
remove_labels: Vec::new(),
set_labels: false,
valid_set_labels: Vec::new(),
resolved_parent: ParentUpdatePlan::Unchanged,
auto_flush_external: false,
};
let ctx = OutputContext::from_flags(false, false, true);
let err = execute_prepared_route(prepared, &ctx).expect_err("update should fail");
assert!(
!err.to_string().contains("failed to rebuild blocked cache"),
"late runtime error should not be masked by blocked-cache repair: {err}"
);
let reopened =
config::open_storage_with_cli(&beads_dir, &CliOverrides::default()).expect("reopen");
let blocker_after = reopened
.storage
.get_issue("bd-blocker")
.expect("load blocker")
.expect("blocker should still exist");
assert_eq!(blocker_after.status, Status::Closed);
assert!(
!reopened
.storage
.get_blocked_ids()
.expect("blocked ids after repair")
.contains("bd-blocked"),
"dependent issue should be unblocked after the blocker closed despite the later error"
);
info!(
"test_execute_prepared_route_repairs_blocked_cache_after_late_update_error: assertions passed"
);
}
}