use super::{resolve_issue_id, retry_mutation_with_jsonl_recovery};
use crate::cli::CreateArgs;
use crate::config;
use crate::error::{BeadsError, Result};
use crate::model::{Dependency, DependencyType, Issue, IssueType, Priority, Status};
use crate::output::OutputContext;
use crate::storage::SqliteStorage;
use crate::util::id::{IdGenerator, IdResolver, ResolverConfig, child_id};
use crate::util::markdown_import::{parse_dependency, parse_markdown_file};
use crate::util::time::parse_flexible_timestamp;
use crate::validation::{IssueValidator, LabelValidator};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
pub struct CreateConfig {
pub id_config: crate::util::id::IdConfig,
pub default_priority: Priority,
pub default_issue_type: IssueType,
pub actor: String,
}
struct NewIdInput<'a> {
title: &'a str,
description: Option<&'a str>,
creator: Option<&'a str>,
now: DateTime<Utc>,
issue_count: usize,
id_config: &'a crate::util::id::IdConfig,
}
#[allow(clippy::too_many_lines)]
pub fn execute(args: &CreateArgs, cli: &config::CliOverrides, ctx: &OutputContext) -> Result<()> {
execute_with_storage(args, cli, ctx, None)
}
#[allow(clippy::too_many_lines)]
pub fn execute_with_storage(
args: &CreateArgs,
cli: &config::CliOverrides,
ctx: &OutputContext,
pre_opened: Option<config::OpenStorageResult>,
) -> Result<()> {
if let Some(ref file_path) = args.file {
if args.title.is_some() || args.title_flag.is_some() {
return Err(BeadsError::validation(
"file",
"cannot be combined with title arguments",
));
}
if args.external_ref.is_some() {
return Err(BeadsError::validation(
"external_ref",
"--external-ref is not supported with --file",
));
}
if args.dry_run {
return Err(BeadsError::validation(
"dry_run",
"--dry-run is not supported with --file",
));
}
return execute_import(file_path, args, cli, ctx);
}
let mut storage_ctx = if let Some(ctx) = pre_opened {
ctx
} else {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
config::open_storage_with_cli(&beads_dir, cli)?
};
let layer = storage_ctx.load_config(cli)?;
let config = CreateConfig {
id_config: config::id_config_from_layer(&layer),
default_priority: config::default_priority_from_layer(&layer)?,
default_issue_type: config::default_issue_type_from_layer(&layer)?,
actor: config::resolve_actor(&layer),
};
let issue =
retry_mutation_with_jsonl_recovery(&mut storage_ctx, true, "create", None, |storage| {
create_issue_impl(storage, args, &config)
})?;
let created_id = issue.id.clone();
let last_touched_dir = storage_ctx.paths.beads_dir.clone();
let update_last_touched_after_flush = storage_ctx.no_db;
if !args.dry_run && !update_last_touched_after_flush {
crate::util::set_last_touched_id(&last_touched_dir, &created_id);
}
storage_ctx.flush_no_db_if_dirty()?;
if !args.dry_run && update_last_touched_after_flush {
crate::util::set_last_touched_id(&last_touched_dir, &created_id);
}
if args.silent {
println!("{}", issue.id);
} else if ctx.is_toon() {
if args.dry_run {
ctx.toon(&issue);
} else {
let full_issue = storage_ctx
.storage
.get_issue_for_export(&issue.id)?
.ok_or_else(|| BeadsError::IssueNotFound {
id: issue.id.clone(),
})?;
ctx.toon(&full_issue);
}
} else if ctx.is_json() {
if args.dry_run {
ctx.json_pretty(&issue);
} else {
let full_issue = storage_ctx
.storage
.get_issue_for_export(&issue.id)?
.ok_or_else(|| BeadsError::IssueNotFound {
id: issue.id.clone(),
})?;
ctx.json_pretty(&full_issue);
}
} else if args.dry_run {
ctx.info(&format!("Dry run: would create issue {}", issue.id));
ctx.print_line(&format!("Title: {}", issue.title));
ctx.print_line(&format!("Type: {}", issue.issue_type));
ctx.print_line(&format!("Priority: {}", issue.priority));
if !args.labels.is_empty() {
ctx.print_line(&format!("Labels: {}", args.labels.join(", ")));
}
if let Some(parent) = &args.parent {
ctx.print_line(&format!("Parent: {parent}"));
}
if !args.deps.is_empty() {
ctx.print_line(&format!("Dependencies: {}", args.deps.join(", ")));
}
} else {
ctx.success(&format!("Created {}: {}", issue.id, issue.title));
}
Ok(())
}
#[allow(clippy::too_many_lines)]
pub fn create_issue_impl(
storage: &mut SqliteStorage,
args: &CreateArgs,
config: &CreateConfig,
) -> Result<Issue> {
let title = args
.title
.as_ref()
.or(args.title_flag.as_ref())
.ok_or_else(|| BeadsError::validation("title", "cannot be empty"))?;
if title.is_empty() {
return Err(BeadsError::validation("title", "cannot be empty"));
}
let priority = if let Some(p) = &args.priority {
Priority::from_str(p)?
} else {
config.default_priority
};
let issue_type = if let Some(t) = &args.type_ {
IssueType::from_str(t)?
} else {
config.default_issue_type.clone()
};
let due_at = parse_optional_date(args.due.as_deref())?;
let defer_until = parse_optional_date(args.defer.as_deref())?;
let id_resolver = IdResolver::new(ResolverConfig::with_prefix(config.id_config.prefix.clone()));
let resolved_parent = args
.parent
.as_deref()
.map(|parent| resolve_issue_id(storage, &id_resolver, parent))
.transpose()?;
let status = if let Some(s) = &args.status {
Status::from_str(s)?
} else {
Status::Open
};
let count = storage.count_issues()?;
let mut retries = 0;
loop {
let now = Utc::now();
let id_input = NewIdInput {
title,
description: args.description.as_deref(),
creator: Some(&config.actor),
now,
issue_count: count,
id_config: &config.id_config,
};
let id = generate_new_id(storage, resolved_parent.as_deref(), &id_input)?;
let closed_at = if matches!(status, Status::Closed) {
Some(now)
} else {
None
};
let deleted_at = if matches!(status, Status::Tombstone) {
Some(now)
} else {
None
};
let mut issue = Issue {
id: id.clone(),
title: title.clone(),
description: args.description.clone(),
status: status.clone(),
priority,
issue_type: issue_type.clone(),
created_at: now,
updated_at: now,
assignee: args.assignee.clone(),
owner: args.owner.clone(),
estimated_minutes: args.estimate,
due_at,
defer_until,
external_ref: args.external_ref.clone(),
ephemeral: args.ephemeral,
content_hash: None,
design: None,
acceptance_criteria: None,
notes: None,
created_by: Some(config.actor.clone()),
closed_at,
close_reason: None,
closed_by_session: None,
source_system: None,
source_repo: None,
deleted_at,
deleted_by: if deleted_at.is_some() {
Some(config.actor.clone())
} else {
None
},
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
issue.content_hash = Some(issue.compute_content_hash());
IssueValidator::validate(&issue).map_err(BeadsError::from_validation_errors)?;
validate_relations(args, &id)?;
let relation_context = RelationContext {
actor: &config.actor,
now,
resolved_parent: resolved_parent.as_deref(),
storage,
prefix: &config.id_config.prefix,
};
populate_relations(&mut issue, args, &relation_context)?;
if args.dry_run {
return Ok(issue);
}
match storage.create_issue(&issue, &config.actor) {
Ok(()) => return Ok(issue),
Err(BeadsError::IdCollision { .. }) => {
if retries >= 10 {
return Err(BeadsError::IdCollision { id });
}
retries += 1;
std::thread::sleep(std::time::Duration::from_millis(10 * retries));
}
Err(e) => return Err(e),
}
}
}
fn generate_new_id(
storage: &SqliteStorage,
parent_id: Option<&str>,
input: &NewIdInput<'_>,
) -> Result<String> {
if let Some(parent_id) = parent_id {
if !storage.id_exists(parent_id)? {
return Err(BeadsError::IssueNotFound {
id: parent_id.to_string(),
});
}
let next_num = storage.next_child_number(parent_id)?;
let candidate = child_id(parent_id, next_num);
if storage.id_exists(&candidate)? {
let mut num = next_num + 1;
loop {
let alt = child_id(parent_id, num);
if !storage.id_exists(&alt)? {
return Ok(alt);
}
num += 1;
if num > next_num.saturating_add(100) {
return Err(BeadsError::validation(
"parent",
"could not find available child ID",
));
}
}
} else {
Ok(candidate)
}
} else {
let id_gen = IdGenerator::new(input.id_config.clone());
let id_check_err: std::cell::Cell<Option<String>> = std::cell::Cell::new(None);
let generated_id = id_gen.generate(
input.title,
input.description,
input.creator,
input.now,
input.issue_count,
|id| match storage.id_exists(id) {
Ok(exists) => exists,
Err(e) => {
id_check_err.set(Some(format!("Failed to check ID existence: {e}")));
true
}
},
);
if let Some(err_msg) = id_check_err.into_inner() {
return Err(anyhow::anyhow!(err_msg).into());
}
Ok(generated_id)
}
}
fn resolve_dependency_id(
resolver: &IdResolver,
storage: &SqliteStorage,
input: &str,
) -> Result<String> {
if input.starts_with("external:") {
return Ok(input.to_string());
}
resolve_issue_id(storage, resolver, input)
}
fn validate_relations(args: &CreateArgs, issue_id: &str) -> Result<()> {
for label in &args.labels {
let trimmed = label.trim();
if !trimmed.is_empty() {
LabelValidator::validate(trimmed)
.map_err(|e| BeadsError::validation("label", e.message))?;
}
}
if let Some(parent_id) = &args.parent
&& parent_id == issue_id
{
return Err(BeadsError::validation(
"parent",
"cannot be parent of itself",
));
}
for dep_str in &args.deps {
let (type_str, dep_id) = if dep_str.starts_with("external:") {
("blocks", dep_str.as_str())
} else if let Some((t, i)) = dep_str.split_once(':') {
(t, i)
} else {
("blocks", dep_str.as_str())
};
if dep_id == issue_id {
return Err(BeadsError::validation("deps", "cannot depend on itself"));
}
let normalized_type = if type_str.eq_ignore_ascii_case("blocked-by") {
"blocks"
} else {
type_str
};
let dep_type: DependencyType = normalized_type.parse().expect("from_str is infallible");
if let DependencyType::Custom(_) = dep_type {
return Err(BeadsError::Validation {
field: "deps".to_string(),
reason: format!(
"Unknown dependency type: '{type_str}'. \
Allowed types: blocks, blocked-by, parent-child, conditional-blocks, waits-for, \
related, discovered-from, replies-to, relates-to, duplicates, \
supersedes, caused-by"
),
});
}
}
Ok(())
}
struct RelationContext<'a> {
actor: &'a str,
now: DateTime<Utc>,
resolved_parent: Option<&'a str>,
storage: &'a crate::storage::SqliteStorage,
prefix: &'a str,
}
fn populate_relations(
issue: &mut Issue,
args: &CreateArgs,
ctx: &RelationContext<'_>,
) -> Result<()> {
let resolver = IdResolver::new(ResolverConfig::with_prefix(ctx.prefix.to_string()));
for label in &args.labels {
let label = label.trim();
if !label.is_empty() && !issue.labels.iter().any(|existing| existing == label) {
issue.labels.push(label.to_string());
}
}
if let Some(parent_id) = ctx.resolved_parent {
issue.dependencies.push(Dependency {
issue_id: issue.id.clone(),
depends_on_id: parent_id.to_string(),
dep_type: DependencyType::ParentChild,
created_at: ctx.now,
created_by: Some(ctx.actor.to_string()),
metadata: None,
thread_id: None,
});
}
for dep_str in &args.deps {
let (type_str, dep_id) = if dep_str.starts_with("external:") {
("blocks", dep_str.as_str())
} else if let Some((t, i)) = dep_str.split_once(':') {
(t, i)
} else {
("blocks", dep_str.as_str())
};
let normalized_type = if type_str.eq_ignore_ascii_case("blocked-by") {
"blocks"
} else {
type_str
};
let resolved_dep_id = resolve_dependency_id(&resolver, ctx.storage, dep_id)?;
let dep_type: DependencyType = normalized_type.parse().expect("validated above");
issue.dependencies.push(Dependency {
issue_id: issue.id.clone(),
depends_on_id: resolved_dep_id,
dep_type,
created_at: ctx.now,
created_by: Some(ctx.actor.to_string()),
metadata: None,
thread_id: None,
});
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn execute_import(
path: &Path,
args: &CreateArgs,
cli: &config::CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let parsed_issues = parse_markdown_file(path)?;
if parsed_issues.is_empty() {
let empty_issues = Vec::<Issue>::new();
if ctx.is_json() {
ctx.json(&empty_issues);
} else if ctx.is_toon() {
ctx.toon(&empty_issues);
}
return Ok(());
}
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
let mut storage_ctx = config::open_storage_with_cli(&beads_dir, cli)?;
let layer = storage_ctx.load_config(cli)?;
let id_config = config::id_config_from_layer(&layer);
let default_priority = config::default_priority_from_layer(&layer)?;
let default_issue_type = config::default_issue_type_from_layer(&layer)?;
let actor = config::resolve_actor(&layer);
let now = Utc::now();
let _json_mode = cli.json.unwrap_or(false);
let due_at = parse_optional_date(args.due.as_deref())?;
let defer_until = parse_optional_date(args.defer.as_deref())?;
let import_status = if let Some(s) = &args.status {
Status::from_str(s)?
} else {
Status::Open
};
let import_closed_at = if matches!(import_status, Status::Closed) {
Some(now)
} else {
None
};
let import_deleted_at = if matches!(import_status, Status::Tombstone) {
Some(now)
} else {
None
};
let storage = &mut storage_ctx.storage;
let mut count = storage.count_issues()?;
let mut last_created_id: Option<String> = None;
let mut created_ids = Vec::new();
let mut created_issues = Vec::new();
let id_resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix.clone()));
let mut title_to_id: HashMap<String, String> = HashMap::new();
let mut standin_to_id: HashMap<String, String> = HashMap::new();
let mut deferred_deps: Vec<(String, Vec<String>)> = Vec::new();
for parsed in parsed_issues {
let title = parsed.title.trim().to_string();
if title.is_empty() {
eprintln!("✗ Failed to create issue: title cannot be empty");
continue;
}
let description = parsed.description.clone();
let priority_override = parsed.priority.clone();
let issue_type_override = parsed.issue_type.clone();
let assignee = parsed.assignee.or_else(|| args.assignee.clone());
let design = parsed.design.clone();
let acceptance_criteria = parsed.acceptance_criteria.clone();
let parent_candidate = parsed.parent.as_deref().or(args.parent.as_deref());
let resolved_parent = parent_candidate
.map(|p| {
let p_lower = p.to_lowercase();
if let Some(id) = standin_to_id.get(&p_lower) {
Ok(id.clone())
} else if let Some(id) = title_to_id.get(&p_lower) {
Ok(id.clone())
} else {
resolve_issue_id(storage, &id_resolver, p)
}
})
.transpose()?;
let mut retries = 0;
let mut final_id = String::new();
let mut created = false;
loop {
let id_input = NewIdInput {
title: &title,
description: description.as_deref(),
creator: None,
now,
issue_count: count,
id_config: &id_config,
};
let id = match generate_new_id(storage, resolved_parent.as_deref(), &id_input) {
Ok(id) => id,
Err(err) => {
eprintln!("✗ Failed to create {title}: {err}");
break;
}
};
let priority = if let Some(ref p) = priority_override {
match Priority::from_str(p) {
Ok(value) => value,
Err(err) => {
eprintln!("✗ Failed to create {title}: {err}");
break;
}
}
} else {
default_priority
};
let issue_type = if let Some(ref t) = issue_type_override {
match IssueType::from_str(t) {
Ok(value) => value,
Err(err) => {
eprintln!("✗ Failed to create {title}: {err}");
break;
}
}
} else {
default_issue_type.clone()
};
let mut issue = Issue {
id: id.clone(),
title: title.clone(),
description: description.clone(),
status: import_status.clone(),
priority,
issue_type,
created_at: now,
updated_at: now,
assignee: assignee.clone(),
owner: args.owner.clone(),
estimated_minutes: args.estimate,
due_at,
defer_until,
external_ref: args.external_ref.clone(),
ephemeral: args.ephemeral,
design: design.clone(),
acceptance_criteria: acceptance_criteria.clone(),
content_hash: None,
notes: None,
created_by: None,
closed_at: import_closed_at,
close_reason: None,
closed_by_session: None,
source_system: None,
source_repo: None,
deleted_at: import_deleted_at,
deleted_by: if import_deleted_at.is_some() {
Some(actor.clone())
} else {
None
},
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
};
issue.content_hash = Some(issue.compute_content_hash());
if let Err(err) =
IssueValidator::validate(&issue).map_err(BeadsError::from_validation_errors)
{
eprintln!("✗ Failed to create {title}: {err}");
break;
}
let mut labels = parsed.labels.clone();
labels.extend(args.labels.clone());
for label in labels {
let label = label.trim().to_string();
if label.is_empty() {
continue;
}
if let Err(err) = LabelValidator::validate(&label) {
eprintln!(
"warning: skipping invalid label '{label}' for issue {id}: {}",
err.message
);
continue;
}
if !issue.labels.iter().any(|existing| existing == &label) {
issue.labels.push(label);
}
}
if let Some(parent_id) = resolved_parent.as_deref() {
issue.dependencies.push(Dependency {
issue_id: id.clone(),
depends_on_id: parent_id.to_string(),
dep_type: DependencyType::ParentChild,
created_at: now,
created_by: Some(actor.clone()),
metadata: None,
thread_id: None,
});
}
match storage.create_issue(&issue, &actor) {
Ok(()) => {
final_id = id;
created = true;
break;
}
Err(BeadsError::IdCollision { .. }) => {
if retries >= 10 {
eprintln!("✗ Failed to create {title}: ID collision after 10 retries");
break;
}
retries += 1;
std::thread::sleep(std::time::Duration::from_millis(10 * retries));
}
Err(err) => {
eprintln!("✗ Failed to create {title}: {err}");
break;
}
}
}
if !created {
continue;
}
let id = final_id;
let mut deps = parsed.dependencies.clone();
deps.extend(args.deps.clone());
if !deps.is_empty() {
deferred_deps.push((id.clone(), deps));
}
title_to_id.insert(title.to_lowercase(), id.clone());
if let Some(ref sid) = parsed.stand_in_id {
let sid_trimmed = sid.trim().to_string();
if !sid_trimmed.is_empty() {
standin_to_id.insert(sid_trimmed.to_lowercase(), id.clone());
}
}
count += 1;
last_created_id = Some(id.clone());
created_ids.push((id, title));
}
if !deferred_deps.is_empty() && !args.dry_run {
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix.clone()));
for (issue_id, deps) in &deferred_deps {
for dep_str in deps {
let raw_lower = dep_str.to_lowercase();
let (type_str, resolved_dep_id) = if let Some(id) = standin_to_id
.get(&raw_lower)
.or_else(|| title_to_id.get(&raw_lower))
{
("blocks".to_string(), id.clone())
} else {
let (mut t, dep_id, valid) = parse_dependency(dep_str);
if !valid {
eprintln!(
"warning: skipping invalid dependency type '{t}' for issue {issue_id}"
);
continue;
}
if t.eq_ignore_ascii_case("blocked-by") {
t = "blocks".to_string();
}
let resolved = if let Some(id) = standin_to_id.get(&dep_id.to_lowercase()) {
id.clone()
} else if let Some(id) = title_to_id.get(&dep_id.to_lowercase()) {
id.clone()
} else {
match resolve_dependency_id(&resolver, storage, &dep_id) {
Ok(r) => r,
Err(err) => {
eprintln!(
"warning: unresolved dependency '{dep_id}' for issue {issue_id}: {err}"
);
continue;
}
}
};
(t, resolved)
};
if resolved_dep_id == *issue_id {
eprintln!("warning: skipping self-dependency for issue {issue_id}");
continue;
}
if is_marker_only_dependency(&resolved_dep_id) {
eprintln!(
"warning: skipping invalid dependency '{resolved_dep_id}' for issue {issue_id}"
);
continue;
}
if let Err(err) =
storage.add_dependency(issue_id, &resolved_dep_id, &type_str, &actor)
{
eprintln!(
"warning: failed to add dependency {issue_id} → {resolved_dep_id}: {err}"
);
}
}
}
}
if ctx.is_json() || ctx.is_toon() {
if args.dry_run {
} else {
let ids: Vec<String> = created_ids.iter().map(|(id, _)| id.clone()).collect();
match storage.get_issues_for_export(&ids) {
Ok(issues) => {
let mut issues_by_id: std::collections::HashMap<String, crate::model::Issue> =
issues.into_iter().map(|i| (i.id.clone(), i)).collect();
for (id, _) in &created_ids {
if let Some(issue) = issues_by_id.remove(id) {
created_issues.push(issue);
} else {
eprintln!("warning: could not load created issue {id} for JSON output");
}
}
}
Err(e) => {
eprintln!("warning: could not load created issues for JSON output: {e}");
}
}
}
}
let last_touched_dir = storage_ctx.paths.beads_dir.clone();
let update_last_touched_after_flush = storage_ctx.no_db;
if !update_last_touched_after_flush && let Some(last_created_id) = last_created_id.as_deref() {
crate::util::set_last_touched_id(&last_touched_dir, last_created_id);
}
storage_ctx.flush_no_db_if_dirty()?;
if update_last_touched_after_flush && let Some(last_created_id) = last_created_id.as_deref() {
crate::util::set_last_touched_id(&last_touched_dir, last_created_id);
}
if created_ids.is_empty() {
return Err(BeadsError::NothingToDo {
reason: format!("failed to create any issues from {}", path.display()),
});
}
if args.silent {
for (id, _) in created_ids {
println!("{id}");
}
} else if ctx.is_toon() {
ctx.toon(&created_issues);
} else if ctx.is_json() {
ctx.json_pretty(&created_issues);
} else if !created_ids.is_empty() {
if args.dry_run {
ctx.info(&format!(
"Dry run: would create {} issues from {}:",
created_ids.len(),
path.display()
));
} else {
ctx.success(&format!(
"Created {} issues from {}:",
created_ids.len(),
path.display()
));
}
for (id, title) in created_ids {
ctx.print_line(&format!(" {id}: {title}"));
}
}
Ok(())
}
fn is_marker_only_dependency(dep_id: &str) -> bool {
matches!(dep_id.trim(), "-" | "*" | "+")
}
fn parse_optional_date(s: Option<&str>) -> Result<Option<DateTime<Utc>>> {
match s {
Some(s) if !s.trim().is_empty() => parse_flexible_timestamp(s, "date").map(Some),
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logging::init_test_logging;
use crate::util::id::IdConfig;
use chrono::Datelike;
use tracing::info;
fn default_args() -> CreateArgs {
CreateArgs {
title: Some("Test Issue".to_string()),
title_flag: None,
type_: None,
priority: None,
description: None,
assignee: None,
owner: None,
labels: vec![],
parent: None,
deps: vec![],
estimate: None,
due: None,
defer: None,
external_ref: None,
status: None,
ephemeral: false,
dry_run: false,
silent: false,
file: None,
}
}
fn default_config() -> CreateConfig {
CreateConfig {
id_config: IdConfig {
prefix: "bd".to_string(),
min_hash_length: 3,
max_hash_length: 8,
max_collision_prob: 0.25,
},
default_priority: Priority::MEDIUM,
default_issue_type: IssueType::Task,
actor: "test_user".to_string(),
}
}
fn setup_memory_storage() -> SqliteStorage {
SqliteStorage::open_memory().expect("failed to open memory db")
}
#[test]
fn test_create_issue_basic_success() {
init_test_logging();
info!("test_create_issue_basic_success: starting");
let mut storage = setup_memory_storage();
let args = default_args();
let config = default_config();
let issue = create_issue_impl(&mut storage, &args, &config).expect("create failed");
assert_eq!(issue.title, "Test Issue");
assert_eq!(issue.priority, Priority::MEDIUM);
assert_eq!(issue.issue_type, IssueType::Task);
assert!(issue.id.starts_with("bd-"));
let loaded = storage.get_issue(&issue.id).expect("get issue");
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().title, "Test Issue");
info!("test_create_issue_basic_success: assertions passed");
}
#[test]
fn test_create_issue_validation_empty_title() {
init_test_logging();
info!("test_create_issue_validation_empty_title: starting");
let mut storage = setup_memory_storage();
let mut args = default_args();
args.title = None;
let config = default_config();
let err = create_issue_impl(&mut storage, &args, &config).unwrap_err();
assert!(matches!(err, BeadsError::Validation { field, .. } if field == "title"));
info!("test_create_issue_validation_empty_title: assertions passed");
}
#[test]
fn test_create_issue_with_parent_propagates_storage_error() {
init_test_logging();
info!("test_create_issue_with_parent_propagates_storage_error: starting");
let mut storage = setup_memory_storage();
let mut parent = default_args();
parent.title = Some("Parent".to_string());
let config = default_config();
let created_parent =
create_issue_impl(&mut storage, &parent, &config).expect("create parent");
storage
.execute_raw("DROP TABLE issues")
.expect("drop issues");
let mut child = default_args();
child.parent = Some(created_parent.id);
let err = create_issue_impl(&mut storage, &child, &config).unwrap_err();
assert!(
matches!(err, BeadsError::Database(_)),
"expected database error, got: {err:?}"
);
info!("test_create_issue_with_parent_propagates_storage_error: assertions passed");
}
#[test]
fn test_create_issue_dry_run_no_writes() {
init_test_logging();
info!("test_create_issue_dry_run_no_writes: starting");
let mut storage = setup_memory_storage();
let mut args = default_args();
args.dry_run = true;
let config = default_config();
let issue = create_issue_impl(&mut storage, &args, &config).expect("create failed");
assert_eq!(issue.title, "Test Issue");
let loaded = storage.get_issue(&issue.id).expect("get issue");
assert!(loaded.is_none(), "dry run should not persist issue");
info!("test_create_issue_dry_run_no_writes: assertions passed");
}
#[test]
fn test_create_issue_with_overrides() {
init_test_logging();
info!("test_create_issue_with_overrides: starting");
let mut storage = setup_memory_storage();
let mut args = default_args();
args.priority = Some("0".to_string());
args.type_ = Some("bug".to_string());
args.description = Some("Desc".to_string());
let config = default_config();
let issue = create_issue_impl(&mut storage, &args, &config).expect("create failed");
assert_eq!(issue.priority, Priority::CRITICAL);
assert_eq!(issue.issue_type, IssueType::Bug);
assert_eq!(issue.description, Some("Desc".to_string()));
info!("test_create_issue_with_overrides: assertions passed");
}
#[test]
fn test_create_issue_with_labels_and_deps() {
init_test_logging();
info!("test_create_issue_with_labels_and_deps: starting");
let mut storage = setup_memory_storage();
let config = default_config();
let target_args = CreateArgs {
title: Some("Target".to_string()),
..default_args()
};
let target = create_issue_impl(&mut storage, &target_args, &config).expect("create target");
let mut args = default_args();
args.labels = vec!["backend".to_string()];
args.deps = vec![target.id.clone()];
let issue = create_issue_impl(&mut storage, &args, &config).expect("create failed");
let labels = storage.get_labels(&issue.id).expect("get labels");
assert!(labels.contains(&"backend".to_string()));
let deps = storage.get_dependencies(&issue.id).expect("get deps");
assert_eq!(deps.len(), 1);
assert_eq!(deps[0], target.id);
info!("test_create_issue_with_labels_and_deps: assertions passed");
}
#[test]
fn test_create_issue_with_missing_dependency_fails() {
init_test_logging();
info!("test_create_issue_with_missing_dependency_fails: starting");
let mut storage = setup_memory_storage();
let config = default_config();
let mut args = default_args();
args.deps = vec!["bd-missing".to_string()];
let err = create_issue_impl(&mut storage, &args, &config).unwrap_err();
assert!(matches!(err, BeadsError::IssueNotFound { id } if id == "bd-missing"));
info!("test_create_issue_with_missing_dependency_fails: assertions passed");
}
#[test]
fn test_create_parent_dependency() {
init_test_logging();
info!("test_create_parent_dependency: starting");
let mut storage = setup_memory_storage();
let config = default_config();
let parent = create_issue_impl(&mut storage, &default_args(), &config).expect("parent");
let mut args = default_args();
args.parent = Some(parent.id.clone());
let child = create_issue_impl(&mut storage, &args, &config).expect("child");
let deps = storage.get_dependencies(&child.id).expect("get deps");
assert_eq!(deps.len(), 1);
assert_eq!(deps[0], parent.id);
info!("test_create_parent_dependency: assertions passed");
}
#[test]
fn test_create_child_generates_hierarchical_id() {
init_test_logging();
info!("test_create_child_generates_hierarchical_id: starting");
let mut storage = setup_memory_storage();
let config = default_config();
let mut parent_args = default_args();
parent_args.title = Some("Epic Parent".to_string());
let parent = create_issue_impl(&mut storage, &parent_args, &config).expect("parent");
let mut child1_args = default_args();
child1_args.title = Some("First Child".to_string());
child1_args.parent = Some(parent.id.clone());
let child1 = create_issue_impl(&mut storage, &child1_args, &config).expect("child1");
let expected_child1_id = format!("{}.1", parent.id);
assert_eq!(
child1.id, expected_child1_id,
"First child should have ID {expected_child1_id}, got {}",
child1.id
);
let mut child2_args = default_args();
child2_args.title = Some("Second Child".to_string());
child2_args.parent = Some(parent.id.clone());
let child2 = create_issue_impl(&mut storage, &child2_args, &config).expect("child2");
let expected_child2_id = format!("{}.2", parent.id);
assert_eq!(
child2.id, expected_child2_id,
"Second child should have ID {expected_child2_id}, got {}",
child2.id
);
let deps1 = storage.get_dependencies(&child1.id).expect("get deps1");
assert_eq!(deps1.len(), 1);
assert_eq!(deps1[0], parent.id);
let deps2 = storage.get_dependencies(&child2.id).expect("get deps2");
assert_eq!(deps2.len(), 1);
assert_eq!(deps2[0], parent.id);
info!("test_create_child_generates_hierarchical_id: assertions passed");
}
#[test]
fn test_create_child_with_nonexistent_parent_fails() {
init_test_logging();
info!("test_create_child_with_nonexistent_parent_fails: starting");
let mut storage = setup_memory_storage();
let config = default_config();
let mut args = default_args();
args.parent = Some("bd-nonexistent".to_string());
let result = create_issue_impl(&mut storage, &args, &config);
assert!(result.is_err(), "Should fail when parent doesn't exist");
if let Err(BeadsError::IssueNotFound { id }) = result {
assert_eq!(id, "bd-nonexistent");
} else {
unreachable!("Expected IssueNotFound error");
}
info!("test_create_child_with_nonexistent_parent_fails: assertions passed");
}
#[test]
fn test_create_issue_custom_type_accepted() {
init_test_logging();
info!("test_create_issue_custom_type_accepted: starting");
let mut storage = setup_memory_storage();
let mut args = default_args();
args.type_ = Some("custom_type".to_string());
let config = default_config();
let result = create_issue_impl(&mut storage, &args, &config);
assert!(result.is_ok(), "create should succeed with custom type");
let issue = result.unwrap();
assert_eq!(
issue.issue_type,
IssueType::Custom("custom_type".to_string())
);
info!("test_create_issue_custom_type_accepted: assertions passed");
}
#[test]
fn test_parse_optional_date_none() {
init_test_logging();
info!("test_parse_optional_date_none: starting");
let result = parse_optional_date(None);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
info!("test_parse_optional_date_none: assertions passed");
}
#[test]
fn test_parse_optional_date_empty_string() {
init_test_logging();
info!("test_parse_optional_date_empty_string: starting");
let result = parse_optional_date(Some(""));
assert!(result.is_ok());
assert!(result.unwrap().is_none());
info!("test_parse_optional_date_empty_string: assertions passed");
}
#[test]
fn test_parse_optional_date_iso8601() {
init_test_logging();
info!("test_parse_optional_date_iso8601: starting");
let result = parse_optional_date(Some("2026-01-17T10:00:00Z"));
assert!(result.is_ok());
let date = result.unwrap();
assert!(date.is_some());
let dt = date.unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 17);
info!("test_parse_optional_date_iso8601: assertions passed");
}
#[test]
fn test_parse_optional_date_simple_date() {
init_test_logging();
info!("test_parse_optional_date_simple_date: starting");
let result = parse_optional_date(Some("2026-12-31"));
assert!(result.is_ok());
let date = result.unwrap();
assert!(date.is_some());
let dt = date.unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 12);
assert_eq!(dt.day(), 31);
info!("test_parse_optional_date_simple_date: assertions passed");
}
#[test]
fn test_parse_optional_date_with_timezone() {
init_test_logging();
info!("test_parse_optional_date_with_timezone: starting");
let result = parse_optional_date(Some("2026-06-15T14:30:00+05:30"));
assert!(result.is_ok());
info!("test_parse_optional_date_with_timezone: assertions passed");
}
#[test]
fn test_parse_optional_date_invalid_format() {
init_test_logging();
info!("test_parse_optional_date_invalid_format: starting");
let result = parse_optional_date(Some("not-a-date"));
assert!(result.is_err());
info!("test_parse_optional_date_invalid_format: assertions passed");
}
#[test]
fn test_parse_optional_date_partial_date() {
init_test_logging();
info!("test_parse_optional_date_partial_date: starting");
let result = parse_optional_date(Some("2026-01"));
let _ = result;
info!("test_parse_optional_date_partial_date: assertions passed");
}
#[test]
fn test_parse_optional_date_year_boundaries() {
init_test_logging();
info!("test_parse_optional_date_year_boundaries: starting");
let result = parse_optional_date(Some("2099-12-31"));
assert!(result.is_ok());
let result = parse_optional_date(Some("2000-01-01"));
assert!(result.is_ok());
info!("test_parse_optional_date_year_boundaries: assertions passed");
}
#[test]
fn test_parse_optional_date_leap_year() {
init_test_logging();
info!("test_parse_optional_date_leap_year: starting");
let result = parse_optional_date(Some("2024-02-29"));
assert!(result.is_ok());
let date = result.unwrap();
assert!(date.is_some());
let dt = date.unwrap();
assert_eq!(dt.month(), 2);
assert_eq!(dt.day(), 29);
info!("test_parse_optional_date_leap_year: assertions passed");
}
#[test]
fn test_parse_optional_date_end_of_month() {
init_test_logging();
info!("test_parse_optional_date_end_of_month: starting");
let result = parse_optional_date(Some("2026-03-31"));
assert!(result.is_ok());
let result = parse_optional_date(Some("2026-04-30"));
assert!(result.is_ok());
info!("test_parse_optional_date_end_of_month: assertions passed");
}
#[test]
fn test_parse_optional_date_whitespace_only() {
init_test_logging();
info!("test_parse_optional_date_whitespace_only: starting");
let result = parse_optional_date(Some(" "));
assert!(result.is_ok());
assert!(result.unwrap().is_none());
info!("test_parse_optional_date_whitespace_only: assertions passed");
}
#[test]
fn test_create_issue_trims_labels() {
init_test_logging();
info!("test_create_issue_trims_labels: starting");
let mut storage = setup_memory_storage();
let config = default_config();
let mut args = default_args();
args.labels = vec![" trimmed ".to_string()];
let issue = create_issue_impl(&mut storage, &args, &config).expect("create failed");
let labels = storage.get_labels(&issue.id).expect("get labels");
assert_eq!(labels, vec!["trimmed"]);
info!("test_create_issue_trims_labels: assertions passed");
}
#[test]
fn test_create_issue_deduplicates_labels() {
init_test_logging();
info!("test_create_issue_deduplicates_labels: starting");
let mut storage = setup_memory_storage();
let config = default_config();
let mut args = default_args();
args.labels = vec![
"backend".to_string(),
"backend".to_string(),
" backend ".to_string(),
];
let issue = create_issue_impl(&mut storage, &args, &config).expect("create failed");
let labels = storage.get_labels(&issue.id).expect("get labels");
assert_eq!(labels, vec!["backend"]);
info!("test_create_issue_deduplicates_labels: assertions passed");
}
}