qualifier 0.5.1

Deterministic quality annotations for software artifacts
Documentation
use clap::Args as ClapArgs;
use std::path::Path;

use crate::annotation::Kind;
use crate::cli::output;
use crate::cli::span_context;
use crate::compact::filter_superseded;
use crate::qual_file::{self, find_project_root};

#[derive(ClapArgs)]
pub struct Args {
    /// The artifact to show
    pub artifact: String,

    /// Output format (human, json)
    #[arg(long, default_value = "human")]
    pub format: String,

    /// Disable .gitignore and .qualignore filtering
    #[arg(long)]
    pub no_ignore: bool,

    /// Show source context around spans (compiler-diagnostic style)
    #[arg(long)]
    pub pretty: bool,

    /// Show all records, including resolve tombstones
    #[arg(long)]
    pub all: bool,

    /// Filter records by envelope `type` (e.g. `annotation`, `epoch`,
    /// `dependency`, or a custom type URI). Open per spec §3.5.
    #[arg(long = "type", value_name = "TYPE")]
    pub record_type: Option<String>,
}

pub fn run(args: Args) -> crate::Result<()> {
    let root = find_project_root(Path::new("."));
    let discover_root = root.as_deref().unwrap_or(Path::new("."));
    let all_qual_files = qual_file::discover(discover_root, !args.no_ignore)?;

    let records = qual_file::find_records_for(&args.artifact, &all_qual_files);

    if records.is_empty() {
        return Err(crate::Error::Validation(format!(
            "No records found for '{}'",
            args.artifact
        )));
    }

    let owned_records: Vec<crate::annotation::Record> =
        records.iter().map(|r| (*r).clone()).collect();

    // Filter records for display: remove superseded, and unless --all, remove resolve tombstones
    let mut display_records: Vec<crate::annotation::Record> = if args.all {
        owned_records.clone()
    } else {
        let active = filter_superseded(&owned_records);
        active
            .into_iter()
            .filter(|r| r.kind() != Some(&Kind::Resolve))
            .cloned()
            .collect()
    };

    // Apply --type filter (open type set per spec §3.5).
    if let Some(ref type_filter) = args.record_type {
        display_records.retain(|r| r.record_type() == type_filter);
    }

    if args.format == "json" {
        if args.pretty {
            let mut value: serde_json::Value =
                serde_json::from_str(&output::show_json(&args.artifact, &display_records))?;
            if let Some(records_arr) = value["records"].as_array_mut() {
                for rec_val in records_arr.iter_mut() {
                    if let Some(span_val) = rec_val.get("body").and_then(|b| b.get("span"))
                        && !span_val.is_null()
                        && let Some(record) = display_records
                            .iter()
                            .find(|r| rec_val.get("id").and_then(|v| v.as_str()) == Some(r.id()))
                        && let Some(att) = record.as_annotation()
                        && let Some(ref span) = att.body.span
                    {
                        let ctx = span_context::read_span_context(
                            Path::new(&args.artifact),
                            span,
                            span_context::DEFAULT_CONTEXT_LINES,
                        );
                        rec_val["context"] = span_context::to_json(&ctx);
                    }
                }
            }
            println!("{}", serde_json::to_string_pretty(&value)?);
        } else {
            println!("{}", output::show_json(&args.artifact, &display_records));
        }
        return Ok(());
    }

    // Human output
    println!();
    println!("  {}", args.artifact);

    // Build threading: group replies under their parent record
    let display_ids: std::collections::HashSet<&str> =
        display_records.iter().map(|r| r.id()).collect();

    // Map from parent ID -> child records (replies)
    let mut children: std::collections::HashMap<&str, Vec<&crate::annotation::Record>> =
        std::collections::HashMap::new();
    let mut roots: Vec<&crate::annotation::Record> = Vec::new();

    for record in &display_records {
        let parent_id = record
            .as_annotation()
            .and_then(|a| a.body.references.as_deref());
        if let Some(pid) = parent_id
            && display_ids.contains(pid)
        {
            children.entry(pid).or_default().push(record);
        } else {
            roots.push(record);
        }
    }

    println!();
    println!("  Records ({}):", display_records.len());
    for (i, record) in roots.iter().enumerate() {
        if i > 0 {
            println!();
        }
        print_record(record, "    ", "    ", &args, &children);
    }
    println!();

    Ok(())
}

/// Print a single record, then recursively print its replies with tree lines.
///
/// `line_prefix` is printed before this record's line (includes tree chars).
/// `cont_prefix` is printed before continuation lines (pretty context, child tree).
fn print_record(
    record: &crate::annotation::Record,
    line_prefix: &str,
    cont_prefix: &str,
    args: &Args,
    children: &std::collections::HashMap<&str, Vec<&crate::annotation::Record>>,
) {
    if let Some(att) = record.as_annotation() {
        let date = att.created_at.format("%Y-%m-%d");
        let issuer_short = att
            .issuer
            .strip_prefix("mailto:")
            .and_then(|e| e.split('@').next())
            .unwrap_or(&att.issuer);
        let id_short = &att.id[..8.min(att.id.len())];
        println!(
            "{line_prefix}{}  {:?}  {}  {}  {}",
            att.body.kind, att.body.summary, issuer_short, date, id_short,
        );
        if args.pretty
            && let Some(ref span) = att.body.span
        {
            let ctx = span_context::read_span_context(
                Path::new(&args.artifact),
                span,
                span_context::DEFAULT_CONTEXT_LINES,
            );
            if let Some(ref warning) = ctx.warning {
                println!("{cont_prefix}  note: {warning}");
            }
            let formatted = span_context::format_human(&ctx);
            if !formatted.is_empty() {
                for line in formatted.lines() {
                    println!("{cont_prefix}{line}");
                }
            }
        }
    } else if let Some(epoch) = record.as_epoch() {
        let date = epoch.created_at.format("%Y-%m-%d");
        let id_short = &epoch.id[..8.min(epoch.id.len())];
        println!(
            "{line_prefix}epoch  {:?}  {}  {}  {}",
            epoch.body.summary, epoch.issuer, date, id_short,
        );
    } else if matches!(record, crate::annotation::Record::Dependency(_)) {
        // Dependency records are graph metadata, not quality signals.
        // `qualifier show <subject>` is for surfacing quality signals
        // (annotations, epochs); skip dependencies in human output.
        // They remain visible in `--format json` and via `qualifier graph`.
        return;
    } else {
        // Fallback for unknown / extension record types — preserve substrate
        // visibility per spec §2.5 without trying to interpret the body.
        let id = record.id();
        let id_short = &id[..8.min(id.len())];
        let type_str = record.record_type();
        let type_display = if type_str.is_empty() {
            "<unknown>"
        } else {
            type_str
        };
        println!("{line_prefix}{type_display}  {id_short}");
    }

    // Print threaded replies with tree-drawing characters
    if let Some(replies) = children.get(record.id()) {
        for (i, reply) in replies.iter().enumerate() {
            let is_last = i == replies.len() - 1;
            let branch = if is_last {
                "\u{2514}\u{2500} "
            } else {
                "\u{251c}\u{2500} "
            };
            let continuation = if is_last { "   " } else { "\u{2502}  " };
            let child_line = format!("{cont_prefix}{branch}");
            let child_cont = format!("{cont_prefix}{continuation}");
            print_record(reply, &child_line, &child_cont, args, children);
        }
    }
}