bzr 0.4.4

A CLI for Bugzilla, inspired by gh
Documentation
//! Saved query management commands.
//!
//! Query operations (save/list/show/delete) are pure local file I/O.
//! Only `run` requires a network client.

use crate::cli::QueryAction;
use crate::config::Config;
use crate::error::{BzrError, Result};
use crate::output::resources::bug::{
    canonical_field_list, validate_json_field_selection, validate_table_columns,
    warn_unknown_fields, write_bugs, ColumnSpec,
};
use crate::output::resources::query::{write_query_detail, write_query_list, write_query_saved};
use crate::output::writers::Writers;
use crate::types::{OutputFormat, QueryKind, SavedQuery};

/// Translate clap's empty-Vec sentinel into `None` so
/// `Overrides`'s `Option<&[String]>` semantics line up: an absent
/// flag keeps the saved value, a non-empty flag replaces it.
fn slice_override(v: &[String]) -> Option<&[String]> {
    if v.is_empty() {
        None
    } else {
        Some(v)
    }
}

pub async fn execute(
    action: &QueryAction,
    server: Option<&str>,
    format: OutputFormat,
    api: Option<crate::types::ApiMode>,
    w: &mut Writers<'_>,
) -> Result<()> {
    match action {
        QueryAction::Save { .. } => handle_save(action, format, w),
        QueryAction::List => handle_list(format, w),
        QueryAction::Show { .. } => handle_show(action, format, w),
        QueryAction::Delete { .. } => handle_delete(action, format, w),
        QueryAction::Run { .. } => handle_run(action, server, format, api, w).await,
    }
}

fn handle_save(action: &QueryAction, format: OutputFormat, w: &mut Writers<'_>) -> Result<()> {
    let QueryAction::Save {
        name,
        from_url,
        search,
        product,
        component,
        status,
        assignee,
        creator,
        priority,
        severity,
        limit,
        fields,
        exclude_fields,
        created_since,
        changed_since,
        whiteboard,
        target_milestone,
        version,
        op_sys,
        platform,
        resolution,
        qa_contact,
        url,
    } = action
    else {
        unreachable!()
    };

    let creation_time =
        crate::validation::parse_optional_date(created_since.as_deref(), "--created-since")?;
    let last_change_time =
        crate::validation::parse_optional_date(changed_since.as_deref(), "--changed-since")?;

    let query = if let Some(url_str) = from_url {
        let config = Config::load()?;
        let parsed = crate::url_parser::parse_bugzilla_url(url_str, &config)?;
        let mut query = parsed.query;
        if let Some(limit) = limit {
            query.limit = Some(*limit);
        }
        if let Some(f) = fields {
            query.fields = Some(f.clone());
        }
        if let Some(ef) = exclude_fields {
            query.exclude_fields = Some(ef.clone());
        }
        if creation_time.is_some() {
            query.creation_time = creation_time;
        }
        if last_change_time.is_some() {
            query.last_change_time = last_change_time;
        }
        query
    } else {
        let kind = if search.is_some() {
            QueryKind::Search
        } else {
            QueryKind::List
        };
        SavedQuery {
            kind,
            product: product.clone(),
            component: component.clone(),
            status: status.clone(),
            assignee: assignee.clone(),
            creator: creator.clone(),
            priority: priority.clone(),
            severity: severity.clone(),
            quicksearch: search.clone(),
            limit: *limit,
            fields: fields.clone(),
            exclude_fields: exclude_fields.clone(),
            creation_time,
            last_change_time,
            whiteboard: whiteboard.clone(),
            target_milestone: target_milestone.clone(),
            version: version.clone(),
            op_sys: op_sys.clone(),
            platform: platform.clone(),
            resolution: resolution.clone(),
            qa_contact: qa_contact.clone(),
            url: url.clone(),
            ..SavedQuery::default()
        }
    };

    if !query.has_filters() {
        return Err(BzrError::InputValidation(
            "query must have at least one filter set".into(),
        ));
    }

    let mut is_update = false;
    Config::update_locked(|config| {
        is_update = config.queries.contains_key(name.as_str());
        config.queries.insert(name.clone(), query);
        Ok(())
    })?;

    let verb = if is_update { "Updated" } else { "Saved" };
    write_query_saved(name, verb, format, w.out);
    Ok(())
}

fn handle_list(format: OutputFormat, w: &mut Writers<'_>) -> Result<()> {
    let config = Config::load()?;
    write_query_list(&config.queries, format, w.out);
    Ok(())
}

fn handle_show(action: &QueryAction, format: OutputFormat, w: &mut Writers<'_>) -> Result<()> {
    let QueryAction::Show { name } = action else {
        unreachable!()
    };
    let config = Config::load()?;
    let query = config
        .queries
        .get(name.as_str())
        .ok_or_else(|| BzrError::config(format!("query '{name}' not found")))?;
    write_query_detail(name, query, format, w.out);
    Ok(())
}

fn handle_delete(action: &QueryAction, format: OutputFormat, w: &mut Writers<'_>) -> Result<()> {
    let QueryAction::Delete { name } = action else {
        unreachable!()
    };
    Config::update_locked(|config| {
        if config.queries.remove(name.as_str()).is_none() {
            return Err(BzrError::config(format!("query '{name}' not found")));
        }
        Ok(())
    })?;

    write_query_saved(name, "Deleted", format, w.out);
    Ok(())
}

async fn handle_run(
    action: &QueryAction,
    server: Option<&str>,
    format: OutputFormat,
    api: Option<crate::types::ApiMode>,
    w: &mut Writers<'_>,
) -> Result<()> {
    let QueryAction::Run {
        name,
        limit,
        fields,
        exclude_fields,
        server: server_override,
        created_since,
        changed_since,
        whiteboard,
        target_milestone,
        version,
        op_sys,
        platform,
        resolution,
        qa_contact,
        url,
    } = action
    else {
        unreachable!()
    };

    let creation_time_override =
        crate::validation::parse_optional_date(created_since.as_deref(), "--created-since")?;
    let last_change_time_override =
        crate::validation::parse_optional_date(changed_since.as_deref(), "--changed-since")?;

    let config = Config::load()?;
    let saved = config
        .queries
        .get(name.as_str())
        .ok_or_else(|| BzrError::config(format!("query '{name}' not found")))?;

    let effective_server = server
        .or(server_override.as_deref())
        .or(saved.server.as_deref());

    let mut params = saved.to_search_params();
    params.apply_overrides(crate::types::Overrides {
        limit: *limit,
        fields: fields.as_deref(),
        exclude_fields: exclude_fields.as_deref(),
        creation_time: creation_time_override.as_deref(),
        last_change_time: last_change_time_override.as_deref(),
        whiteboard: slice_override(whiteboard),
        target_milestone: slice_override(target_milestone),
        version: slice_override(version),
        op_sys: slice_override(op_sys),
        platform: slice_override(platform),
        resolution: slice_override(resolution),
        qa_contact: slice_override(qa_contact),
        url: slice_override(url),
    });
    params.include_fields = canonical_field_list(params.include_fields.as_deref());
    params.exclude_fields = canonical_field_list(params.exclude_fields.as_deref());

    // Source columns from the resolved params (saved-query fields + CLI
    // overrides), not the raw flags, so a stored field selection is honored.
    let spec = ColumnSpec {
        include: params.include_fields.as_deref(),
        exclude: params.exclude_fields.as_deref(),
    };
    match format {
        OutputFormat::Table => validate_table_columns(spec)?,
        OutputFormat::Json => {
            validate_json_field_selection(spec)?;
            warn_unknown_fields(spec, w.err);
        }
    }

    let client = super::shared::connect_and_configure(effective_server, api).await?;
    let bugs = client.search_bugs(&params).await?;
    write_bugs(&bugs, spec, format, w.out, w.err);
    Ok(())
}

#[cfg(test)]
#[path = "query_tests.rs"]
mod tests;