beads_rust 0.1.44

Agent-first issue tracker (SQLite + JSONL)
Documentation
//! Ready command implementation.
//!
//! Shows issues ready to work on next: open, unblocked, not deferred, not pinned,
//! not ephemeral.

use super::resolve_issue_id;
use crate::cli::{
    OutputFormat, ReadyArgs, SortPolicy, resolve_output_format_basic_with_outer_mode,
};
use crate::config;
use crate::error::Result;
use crate::format::{ReadyIssue, format_priority_badge, terminal_width, truncate_title};
use crate::model::{IssueType, Priority};
use crate::output::{IssueTable, IssueTableColumns, OutputContext, OutputMode};
use crate::storage::{ListFilters, ReadyFilters, ReadySortPolicy, SqliteStorage};
use crate::util::id::{IdResolver, ResolverConfig};
use std::io::IsTerminal;
use std::path::Path;
use std::str::FromStr;
use tracing::{debug, info, trace};
use unicode_width::UnicodeWidthStr;

/// Execute the ready command.
///
/// # Errors
///
/// Returns an error if the database cannot be opened or the query fails.
#[allow(clippy::too_many_lines)]
pub fn execute(
    args: &ReadyArgs,
    _json: bool,
    cli: &config::CliOverrides,
    outer_ctx: &OutputContext,
) -> Result<()> {
    let beads_dir = config::discover_beads_dir_with_cli(cli)?;
    execute_inner(args, cli, outer_ctx, &beads_dir, None, None)
}

/// Execute ready using storage that was already opened by the caller.
///
/// # Errors
///
/// Returns an error if configuration loading or the ready query fails.
pub fn execute_with_storage(
    args: &ReadyArgs,
    cli: &config::CliOverrides,
    outer_ctx: &OutputContext,
    beads_dir: &Path,
    storage: &SqliteStorage,
) -> Result<()> {
    execute_inner(args, cli, outer_ctx, beads_dir, Some(storage), None)
}

/// Execute ready using the caller's preopened storage context.
///
/// # Errors
///
/// Returns an error if configuration loading or the ready query fails.
pub fn execute_with_storage_ctx(
    args: &ReadyArgs,
    cli: &config::CliOverrides,
    outer_ctx: &OutputContext,
    beads_dir: &Path,
    storage_ctx: &config::OpenStorageResult,
) -> Result<()> {
    execute_inner(args, cli, outer_ctx, beads_dir, None, Some(storage_ctx))
}

#[allow(clippy::too_many_lines)]
fn execute_inner(
    args: &ReadyArgs,
    cli: &config::CliOverrides,
    outer_ctx: &OutputContext,
    beads_dir: &Path,
    preloaded_storage: Option<&SqliteStorage>,
    preloaded_storage_ctx: Option<&config::OpenStorageResult>,
) -> Result<()> {
    let owned_storage_ctx = if preloaded_storage.is_some() || preloaded_storage_ctx.is_some() {
        None
    } else {
        Some(config::open_storage_with_cli(beads_dir, cli)?)
    };
    let storage = preloaded_storage
        .or_else(|| preloaded_storage_ctx.map(|ctx| &ctx.storage))
        .or_else(|| owned_storage_ctx.as_ref().map(|ctx| &ctx.storage))
        .expect("ready should have an open storage handle");
    let output_format = resolve_output_format_basic_with_outer_mode(
        args.format,
        outer_ctx.inherited_output_mode(),
        args.robot,
    );
    let quiet = cli.quiet.unwrap_or(false);
    let early_ctx = OutputContext::from_output_format(output_format, quiet, true);
    let mut config_layer: Option<config::ConfigLayer> = None;
    let mut load_config_layer = || -> Result<config::ConfigLayer> {
        if let Some(layer) = config_layer.as_ref() {
            return Ok(layer.clone());
        }

        let loaded = if let Some(storage_ctx) = preloaded_storage_ctx.or(owned_storage_ctx.as_ref())
        {
            storage_ctx.load_config(cli)?
        } else {
            config::load_config(beads_dir, Some(storage), cli)?
        };
        config_layer = Some(loaded.clone());
        Ok(loaded)
    };
    let assignee = match args.assignee.as_deref() {
        Some("") => Some(config::resolve_actor(&load_config_layer()?)),
        Some(value) => Some(value.to_string()),
        None => None,
    };

    let resolved_parent = args
        .parent
        .as_deref()
        .map(|parent| {
            let config_layer = load_config_layer()?;
            let id_config = config::id_config_from_layer(&config_layer);
            let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
            resolve_issue_id(storage, &resolver, parent)
        })
        .transpose()?;

    let filters = ReadyFilters {
        assignee,
        unassigned: args.unassigned,
        labels_and: args.label.clone(),
        labels_or: args.label_any.clone(),
        types: parse_types(&args.type_)?,
        priorities: parse_priorities(&args.priority)?,
        include_deferred: args.include_deferred,
        // Fetch all candidates to allow post-filtering of external blockers
        limit: None,
        parent: resolved_parent,
        recursive: args.recursive,
    };

    let sort_policy = match args.sort {
        SortPolicy::Hybrid => ReadySortPolicy::Hybrid,
        SortPolicy::Priority => ReadySortPolicy::Priority,
        SortPolicy::Oldest => ReadySortPolicy::Oldest,
    };

    info!("Fetching ready issues");

    // Optimization: if there are no external dependencies, we can apply the limit
    // directly in the SQL query to avoid loading all candidate issues into memory.
    let has_external = storage.has_external_dependencies(true)?;
    let external_db_paths = if has_external {
        Some(config::external_project_db_paths(
            &load_config_layer()?,
            beads_dir,
        ))
    } else {
        None
    };
    let mut filters = filters;
    if !has_external && args.limit > 0 {
        filters.limit = Some(args.limit);
    }

    debug!(filters = ?filters, sort = ?sort_policy, "Applied ready filters");

    // Get ready issues from storage (blocked cache only) while avoiding
    // heavyweight issue columns that never reach ready output.
    let mut ready_issues = storage.get_ready_issues_for_command_output(&filters, sort_policy)?;

    if has_external {
        let external_statuses = storage.resolve_external_dependency_statuses(
            external_db_paths
                .as_ref()
                .expect("external dependency paths should be loaded"),
            true,
        )?;
        let external_blockers = storage.external_blockers(&external_statuses)?;
        if !external_blockers.is_empty() {
            ready_issues.retain(|issue| !external_blockers.contains_key(&issue.id));
        }

        if args.limit > 0 && ready_issues.len() > args.limit {
            ready_issues.truncate(args.limit);
        }
    }

    info!(count = ready_issues.len(), "Found ready issues");
    for issue in ready_issues.iter().take(5) {
        trace!(id = %issue.id, priority = issue.priority.0, "Ready issue");
    }

    // Output
    if matches!(early_ctx.mode(), OutputMode::Quiet) {
        return Ok(());
    }
    match output_format {
        OutputFormat::Json => {
            let ready_output: Vec<ReadyIssue> =
                ready_issues.into_iter().map(ReadyIssue::from).collect();
            early_ctx.json_pretty(&ready_output);
        }
        OutputFormat::Toon => {
            let ready_output: Vec<ReadyIssue> =
                ready_issues.into_iter().map(ReadyIssue::from).collect();
            early_ctx.toon_with_stats(&ready_output, args.stats);
        }
        OutputFormat::Text | OutputFormat::Csv => {
            let config_layer = load_config_layer()?;
            let use_color = config::should_use_color(&config_layer);
            let max_width = if std::io::stdout().is_terminal() {
                Some(terminal_width())
            } else {
                None
            };
            let ctx = OutputContext::from_output_format(output_format, quiet, !use_color);
            if ready_issues.is_empty() {
                println!("{}", empty_ready_message(storage)?);
            } else if matches!(ctx.mode(), OutputMode::Rich) {
                let columns = IssueTableColumns {
                    id: true,
                    priority: true,
                    status: true,
                    issue_type: true,
                    title: true,
                    ..Default::default()
                };
                let mut table = IssueTable::new(&ready_issues, ctx.theme())
                    .columns(columns)
                    .title(format!(
                        "Ready work ({} issue{} with no blockers)",
                        ready_issues.len(),
                        if ready_issues.len() == 1 { "" } else { "s" }
                    ))
                    .wrap(args.wrap);
                if args.wrap {
                    table = table.width(Some(ctx.width()));
                }
                let table = table.build();
                ctx.render(&table);
            } else {
                // Match bd header format: 📋 Ready work (N issues with no blockers):
                println!(
                    "📋 Ready work ({} issue{} with no blockers):\n",
                    ready_issues.len(),
                    if ready_issues.len() == 1 { "" } else { "s" }
                );
                for (i, issue) in ready_issues.iter().enumerate() {
                    let line = format_ready_line(i + 1, issue, use_color, max_width, args.wrap);
                    println!("{line}");
                }
            }
        }
    }

    Ok(())
}

fn empty_ready_message(storage: &SqliteStorage) -> Result<&'static str> {
    let has_non_closed_issues = !storage
        .list_issues(&ListFilters {
            include_deferred: true,
            limit: Some(1),
            ..Default::default()
        })?
        .is_empty();
    Ok(if has_non_closed_issues {
        "✨ No ready issues — all remaining work is blocked, deferred, or in progress"
    } else {
        "✨ All work complete — no issues to work on"
    })
}

fn format_ready_line(
    index: usize,
    issue: &crate::model::Issue,
    use_color: bool,
    max_width: Option<usize>,
    wrap: bool,
) -> String {
    // Match bd format: {index}. [● P{n}] [{type}] {id}: {title}
    let priority_badge_plain = format!("[● {}]", crate::format::format_priority(&issue.priority));
    let type_badge_plain = format!("[{}]", issue.issue_type.as_str());
    let prefix_plain = format!(
        "{index}. {priority_badge_plain} {type_badge_plain} {}: ",
        issue.id
    );
    let title = if wrap {
        issue.title.clone()
    } else {
        max_width.map_or_else(
            || issue.title.clone(),
            |width| {
                let max_title = width.saturating_sub(UnicodeWidthStr::width(prefix_plain.as_str()));
                truncate_title(&issue.title, max_title)
            },
        )
    };

    let priority_badge = format_priority_badge(&issue.priority, use_color);
    let type_badge = crate::format::format_type_badge_colored(&issue.issue_type, use_color);
    format!(
        "{index}. {priority_badge} {type_badge} {}: {title}",
        issue.id
    )
}

/// Parse type filter strings to `IssueType` enums.
fn parse_types(types: &[String]) -> Result<Option<Vec<IssueType>>> {
    if types.is_empty() {
        return Ok(None);
    }

    let parsed = types
        .iter()
        .map(|t| t.parse())
        .collect::<Result<Vec<IssueType>>>()?;

    Ok(Some(parsed))
}

/// Parse priority filter strings to Priority values.
fn parse_priorities(priorities: &[String]) -> Result<Option<Vec<Priority>>> {
    if priorities.is_empty() {
        return Ok(None);
    }

    let mut parsed = Vec::with_capacity(priorities.len());
    for p in priorities {
        parsed.push(Priority::from_str(p)?);
    }

    Ok(Some(parsed))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tracing::info;

    fn init_logging() {
        crate::logging::init_test_logging();
    }

    #[test]
    fn test_parse_types() {
        init_logging();
        info!("test_parse_types: starting");
        let t = parse_types(&["bug".to_string(), "feature".to_string()])
            .expect("parse types")
            .expect("types");
        assert_eq!(t.len(), 2);
        info!("test_parse_types: assertions passed");
    }

    #[test]
    fn test_parse_priorities() {
        init_logging();
        info!("test_parse_priorities: starting");
        let p = parse_priorities(&["0".to_string(), "P1".to_string(), "2".to_string()])
            .expect("parse priorities")
            .unwrap();
        assert_eq!(p.len(), 3);
        assert_eq!(p[0].0, 0);
        assert_eq!(p[1].0, 1);
        assert_eq!(p[2].0, 2);
        info!("test_parse_priorities: assertions passed");
    }
}