use crate::cli::{ListArgs, OutputFormat, resolve_output_format_with_outer_mode};
use crate::config;
use crate::error::{BeadsError, Result};
use crate::format::csv;
use crate::format::{
IssueWithCounts, ListPage, TextFormatOptions, format_issue_line_with, format_issue_long_with,
format_issue_pretty_with, terminal_width,
};
use crate::model::{IssueType, Priority, Status};
use crate::output::{IssueTable, IssueTableColumns, OutputContext, OutputMode};
use crate::storage::ListFilters;
use chrono::Utc;
use std::collections::HashSet;
use std::io::IsTerminal;
#[allow(clippy::too_many_lines)]
pub fn execute(
args: &ListArgs,
_json: bool,
cli: &config::CliOverrides,
outer_ctx: &OutputContext,
) -> Result<()> {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
let storage_ctx = config::open_storage_with_cli(&beads_dir, cli)?;
execute_inner(args, cli, outer_ctx, &storage_ctx)
}
pub fn execute_with_storage(
args: &ListArgs,
cli: &config::CliOverrides,
outer_ctx: &OutputContext,
storage_ctx: &config::OpenStorageResult,
) -> Result<()> {
execute_inner(args, cli, outer_ctx, storage_ctx)
}
#[allow(clippy::too_many_lines)]
fn execute_inner(
args: &ListArgs,
cli: &config::CliOverrides,
outer_ctx: &OutputContext,
storage_ctx: &config::OpenStorageResult,
) -> Result<()> {
let storage = &storage_ctx.storage;
let mut filters = build_filters(args)?;
let client_filters = needs_client_filters(args);
let output_format = resolve_output_format_with_outer_mode(
args.format,
outer_ctx.inherited_output_mode(),
false,
);
let is_json_output = matches!(output_format, OutputFormat::Json | OutputFormat::Toon);
let user_limit = args.limit.unwrap_or(50);
let user_offset = args.offset.unwrap_or(0);
let sql_total: Option<usize> = if is_json_output && !client_filters {
Some(storage.count_issues_with_filters(&filters)?)
} else {
None
};
let limit_for_truncation = if client_filters {
filters.limit.take();
filters.offset.take();
Some(user_limit)
} else {
let ul = filters.limit;
if !is_json_output
&& let Some(lim) = filters.limit
&& lim > 0
{
filters.limit = Some(lim + 1);
}
ul
};
validate_sort_key(args.sort.as_deref())?;
let mut issues = storage.list_issues(&filters)?;
if client_filters {
issues = apply_client_filters(issues, args)?;
}
let json_total: usize = if is_json_output {
sql_total.unwrap_or(issues.len())
} else {
0 };
if client_filters && user_offset > 0 {
if user_offset >= issues.len() {
issues.clear();
} else {
issues = issues.split_off(user_offset);
}
}
let total_before = issues.len();
let truncated = if let Some(limit) = limit_for_truncation
&& limit > 0
&& issues.len() > limit
{
issues.truncate(limit);
true
} else {
false
};
let quiet = cli.quiet.unwrap_or(false);
let early_ctx = OutputContext::from_output_format(output_format, quiet, true);
if truncated && !quiet && !matches!(output_format, OutputFormat::Json | OutputFormat::Toon) {
if client_filters {
eprintln!(
"[note] Showing {} of {} issues. Use --limit 0 for all results.",
issues.len(),
total_before,
);
} else {
eprintln!(
"[note] Output truncated to {} issues. Use --limit 0 for all results.",
issues.len(),
);
}
}
if matches!(early_ctx.mode(), OutputMode::Quiet) {
return Ok(());
}
match output_format {
OutputFormat::Json | OutputFormat::Toon => {
let ctx = OutputContext::from_output_format(output_format, quiet, true);
let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
let mut labels_map = storage.get_labels_for_issues(&issue_ids)?;
let (dependency_counts, dependent_counts) =
storage.count_relation_counts_for_issues(&issue_ids)?;
let issues_with_counts: Vec<IssueWithCounts> = issues
.into_iter()
.map(|mut issue| {
if let Some(labels) = labels_map.remove(&issue.id) {
issue.labels = labels;
}
let dependency_count = *dependency_counts.get(&issue.id).unwrap_or(&0);
let dependent_count = *dependent_counts.get(&issue.id).unwrap_or(&0);
IssueWithCounts {
issue,
dependency_count,
dependent_count,
}
})
.collect();
let has_more = if user_limit == 0 {
false
} else {
json_total > user_offset.saturating_add(user_limit)
};
let page = ListPage {
issues: issues_with_counts,
total: json_total,
limit: user_limit,
offset: user_offset,
has_more,
};
if matches!(output_format, OutputFormat::Toon) {
ctx.toon_with_stats(&page, args.stats);
} else {
ctx.json_pretty(&page);
}
}
OutputFormat::Csv => {
let fields = csv::parse_fields(args.fields.as_deref());
let csv_output = csv::format_csv(&issues, &fields);
print!("{csv_output}");
}
OutputFormat::Text => {
let config_layer = storage_ctx.load_config(cli)?;
let use_color = config::should_use_color(&config_layer);
let max_width = if std::io::stdout().is_terminal() {
Some(terminal_width())
} else {
None
};
let format_options = TextFormatOptions {
use_color,
max_width,
wrap: args.wrap,
};
let ctx = OutputContext::from_output_format(output_format, quiet, !use_color);
if args.pretty {
render_pretty_text_issues(&ctx, &issues, format_options, args.long);
} else if matches!(ctx.mode(), OutputMode::Rich) {
let columns = if args.long {
IssueTableColumns {
id: true,
priority: true,
status: true,
issue_type: true,
title: true,
assignee: true,
created: true,
updated: true,
..Default::default()
}
} else {
IssueTableColumns {
id: true,
priority: true,
status: true,
issue_type: true,
title: true,
..Default::default()
}
};
let mut table = IssueTable::new(&issues, ctx.theme())
.columns(columns)
.title(format!("Issues ({})", issues.len()))
.wrap(args.wrap);
if args.wrap {
table = table.width(Some(ctx.width()));
}
let table = table.build();
ctx.render(&table);
} else if args.long {
render_long_text_issues(&ctx, &issues, format_options);
} else {
for issue in &issues {
let line = format_issue_line_with(issue, format_options);
println!("{line}");
}
}
}
}
Ok(())
}
fn build_filters(args: &ListArgs) -> Result<ListFilters> {
let statuses = if args.status.is_empty() {
None
} else {
Some(
args.status
.iter()
.map(|s| s.parse())
.collect::<Result<Vec<Status>>>()?,
)
};
let types = if args.type_.is_empty() {
None
} else {
Some(
args.type_
.iter()
.map(|t| t.parse())
.collect::<Result<Vec<IssueType>>>()?,
)
};
let priorities = if args.priority.is_empty() {
None
} else {
Some(
args.priority
.iter()
.map(|p| p.parse())
.collect::<Result<Vec<Priority>>>()?,
)
};
let include_closed = args.all
|| statuses
.as_ref()
.is_some_and(|parsed| parsed.iter().any(Status::is_terminal));
let include_deferred = args.deferred
|| args.all
|| statuses.is_none()
|| statuses
.as_ref()
.is_some_and(|parsed| parsed.contains(&Status::Deferred));
Ok(ListFilters {
statuses,
types,
priorities,
assignee: args.assignee.clone(),
unassigned: args.unassigned,
include_closed,
include_deferred,
include_templates: false,
title_contains: args.title_contains.clone(),
limit: args.limit,
offset: args.offset,
sort: args.sort.clone(),
reverse: args.reverse,
labels: if args.label.is_empty() {
None
} else {
Some(args.label.clone())
},
labels_or: if args.label_any.is_empty() {
None
} else {
Some(args.label_any.clone())
},
updated_before: None,
updated_after: None,
})
}
pub(crate) fn validate_list_args(args: &ListArgs) -> Result<()> {
let _ = build_filters(args)?;
validate_sort_key(args.sort.as_deref())?;
validate_priority_bounds(args.priority_min, args.priority_max)?;
Ok(())
}
fn needs_client_filters(args: &ListArgs) -> bool {
!args.id.is_empty()
|| args.priority_min.is_some()
|| args.priority_max.is_some()
|| args.desc_contains.is_some()
|| args.notes_contains.is_some()
|| args.deferred
|| args.overdue
}
fn apply_client_filters(
issues: Vec<crate::model::Issue>,
args: &ListArgs,
) -> Result<Vec<crate::model::Issue>> {
let id_filter: Option<HashSet<&str>> = if args.id.is_empty() {
None
} else {
Some(args.id.iter().map(String::as_str).collect())
};
let mut filtered = Vec::new();
let now = Utc::now();
let min_priority = args.priority_min.map(i32::from);
let max_priority = args.priority_max.map(i32::from);
let desc_needle = args.desc_contains.as_deref().map(str::to_lowercase);
let notes_needle = args.notes_contains.as_deref().map(str::to_lowercase);
let include_deferred = args.deferred
|| (!args.overdue && args.status.is_empty())
|| args
.status
.iter()
.any(|status| status.eq_ignore_ascii_case("deferred"));
validate_priority_bounds(args.priority_min, args.priority_max)?;
for issue in issues {
if let Some(ids) = &id_filter
&& !ids.contains(issue.id.as_str())
{
continue;
}
if let Some(min) = min_priority
&& issue.priority.0 < min
{
continue;
}
if let Some(max) = max_priority
&& issue.priority.0 > max
{
continue;
}
if let Some(ref needle) = desc_needle {
let haystack = issue.description.as_deref().unwrap_or("").to_lowercase();
if !haystack.contains(needle) {
continue;
}
}
if let Some(ref needle) = notes_needle {
let haystack = issue.notes.as_deref().unwrap_or("").to_lowercase();
if !haystack.contains(needle) {
continue;
}
}
if !include_deferred && matches!(issue.status, Status::Deferred) {
continue;
}
if args.overdue {
let overdue = issue.due_at.is_some_and(|due| due < now) && !issue.status.is_terminal();
if !overdue {
continue;
}
}
filtered.push(issue);
}
Ok(filtered)
}
fn render_long_text_issues(
ctx: &OutputContext,
issues: &[crate::model::Issue],
format_options: TextFormatOptions,
) {
for (index, issue) in issues.iter().enumerate() {
ctx.print_line(&format_issue_long_with(issue, format_options));
if index + 1 != issues.len() {
ctx.print_line("");
}
}
}
fn render_pretty_text_issues(
ctx: &OutputContext,
issues: &[crate::model::Issue],
format_options: TextFormatOptions,
include_extended: bool,
) {
for (index, issue) in issues.iter().enumerate() {
ctx.print_line(&format_issue_pretty_with(
issue,
format_options,
include_extended,
));
if index + 1 != issues.len() {
ctx.print_line("");
}
}
}
fn validate_sort_key(sort: Option<&str>) -> Result<()> {
let Some(sort_key) = sort else {
return Ok(());
};
match sort_key {
"priority" | "created_at" | "updated_at" | "title" | "created" | "updated" => Ok(()),
_ => Err(BeadsError::Validation {
field: "sort".to_string(),
reason: format!("invalid sort field '{sort_key}'"),
}),
}
}
fn validate_priority_bounds(priority_min: Option<u8>, priority_max: Option<u8>) -> Result<()> {
if let Some(min) = priority_min.map(i32::from)
&& !(0..=4).contains(&min)
{
return Err(BeadsError::InvalidPriority {
priority: min.to_string(),
});
}
if let Some(max) = priority_max.map(i32::from)
&& !(0..=4).contains(&max)
{
return Err(BeadsError::InvalidPriority {
priority: max.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli;
use crate::model::Issue;
use chrono::Duration;
use tracing::info;
fn init_logging() {
crate::logging::init_test_logging();
}
#[test]
fn test_build_filters_includes_closed_for_terminal_status() {
init_logging();
info!("test_build_filters_includes_closed_for_terminal_status: starting");
let args = cli::ListArgs {
status: vec!["closed".to_string()],
..Default::default()
};
let filters = build_filters(&args).expect("build filters");
assert!(filters.include_closed);
assert!(
filters
.statuses
.as_ref()
.expect("statuses")
.contains(&Status::Closed)
);
info!("test_build_filters_includes_closed_for_terminal_status: assertions passed");
}
#[test]
fn test_build_filters_parses_priorities() {
init_logging();
info!("test_build_filters_parses_priorities: starting");
let args = cli::ListArgs {
priority: vec!["0".to_string(), "2".to_string()],
..Default::default()
};
let filters = build_filters(&args).expect("build filters");
let priorities = filters.priorities.expect("priorities");
let values: Vec<i32> = priorities.iter().map(|p| p.0).collect();
assert_eq!(values, vec![0, 2]);
info!("test_build_filters_parses_priorities: assertions passed");
}
#[test]
fn test_needs_client_filters_detects_fields() {
init_logging();
info!("test_needs_client_filters_detects_fields: starting");
let args = ListArgs::default();
assert!(!needs_client_filters(&args));
let args = cli::ListArgs {
label: vec!["backend".to_string()],
..Default::default()
};
assert!(!needs_client_filters(&args));
let args = cli::ListArgs {
desc_contains: Some("needle".to_string()),
..Default::default()
};
assert!(needs_client_filters(&args));
let args = cli::ListArgs {
label: vec!["backend".to_string()],
desc_contains: Some("needle".to_string()),
..Default::default()
};
assert!(needs_client_filters(&args));
info!("test_needs_client_filters_detects_fields: assertions passed");
}
fn issue_with_id(id: &str, title: &str) -> Issue {
Issue {
id: id.to_string(),
title: title.to_string(),
..Issue::default()
}
}
#[test]
fn test_apply_client_filters_honors_id_priority_and_text_filters() {
init_logging();
let mut matching = issue_with_id("bd-2", "matching issue");
matching.priority = Priority(2);
matching.description = Some("Contains a unique NEEDLE".to_string());
matching.notes = Some("Tracker note with token".to_string());
let mut wrong_id = issue_with_id("bd-1", "wrong id");
wrong_id.priority = Priority(2);
wrong_id.description = Some("Contains a unique needle".to_string());
wrong_id.notes = Some("Tracker note with token".to_string());
let mut wrong_priority = issue_with_id("bd-3", "wrong priority");
wrong_priority.priority = Priority(4);
wrong_priority.description = Some("Contains a unique needle".to_string());
wrong_priority.notes = Some("Tracker note with token".to_string());
let args = ListArgs {
id: vec!["bd-2".to_string()],
priority_min: Some(2),
priority_max: Some(2),
desc_contains: Some("needle".to_string()),
notes_contains: Some("token".to_string()),
..Default::default()
};
let filtered = apply_client_filters(vec![wrong_id, wrong_priority, matching], &args)
.expect("apply client filters");
let ids: Vec<_> = filtered.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(ids, vec!["bd-2"]);
}
#[test]
fn test_apply_client_filters_excludes_deferred_from_overdue_unless_requested() {
init_logging();
let now = Utc::now();
let mut overdue_open = issue_with_id("bd-1", "overdue open");
overdue_open.due_at = Some(now - Duration::days(1));
let mut overdue_deferred = issue_with_id("bd-2", "overdue deferred");
overdue_deferred.status = Status::Deferred;
overdue_deferred.due_at = Some(now - Duration::days(1));
let mut future_open = issue_with_id("bd-3", "future open");
future_open.due_at = Some(now + Duration::days(1));
let mut overdue_closed = issue_with_id("bd-4", "overdue closed");
overdue_closed.status = Status::Closed;
overdue_closed.due_at = Some(now - Duration::days(1));
let overdue_only = apply_client_filters(
vec![
overdue_open.clone(),
overdue_deferred.clone(),
future_open,
overdue_closed,
],
&ListArgs {
overdue: true,
..Default::default()
},
)
.expect("overdue filter");
let overdue_only_ids: Vec<_> = overdue_only.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(overdue_only_ids, vec!["bd-1"]);
let overdue_with_deferred = apply_client_filters(
vec![overdue_open, overdue_deferred],
&ListArgs {
overdue: true,
deferred: true,
..Default::default()
},
)
.expect("overdue with deferred filter");
let overdue_with_deferred_ids: Vec<_> = overdue_with_deferred
.iter()
.map(|issue| issue.id.as_str())
.collect();
assert_eq!(overdue_with_deferred_ids, vec!["bd-1", "bd-2"]);
}
#[test]
fn test_validate_list_args_rejects_invalid_sort() {
init_logging();
let err = validate_list_args(&ListArgs {
sort: Some("nonsense".to_string()),
..Default::default()
})
.expect_err("invalid sort should fail");
assert!(matches!(err, BeadsError::Validation { field, .. } if field == "sort"));
}
#[test]
fn test_validate_list_args_rejects_invalid_priority_bounds() {
init_logging();
let err = validate_list_args(&ListArgs {
priority_min: Some(7),
..Default::default()
})
.expect_err("invalid priority should fail");
assert!(matches!(
err,
BeadsError::InvalidPriority { ref priority } if priority == "7"
));
}
}