headson 0.17.0

Budget‑constrained JSON preview renderer
Documentation
use anyhow::{Result, bail};
use headson::budget::{
    DEFAULT_BYTES_PER_INPUT, EffectiveBudgets, compute_effective_budgets,
};
use headson::{
    ArraySamplerStrategy, Budget, BudgetKind, PriorityConfig, RenderConfig,
};

use crate::Cli;

pub(crate) fn compute_effective(
    cli: &Cli,
    input_count: usize,
) -> EffectiveBudgets {
    let mut per_slot = per_slot_budget(cli);
    let explicit_global = explicit_global_budget(cli);
    if per_slot.is_none() && explicit_global.is_none() {
        per_slot = Some(Budget {
            kind: BudgetKind::Bytes,
            cap: DEFAULT_BYTES_PER_INPUT,
        });
    }
    compute_effective_budgets(
        per_slot,
        explicit_global,
        input_count,
        DEFAULT_BYTES_PER_INPUT,
    )
}

pub(crate) fn validate(cli: &Cli) -> Result<()> {
    let per_slot_flags = [
        cli.bytes.is_some(),
        cli.chars.is_some(),
        cli.lines.is_some(),
    ];
    let per_slot_set = per_slot_flags.iter().filter(|b| **b).count();
    if per_slot_set > 1 {
        bail!(
            "only one per-file budget (--bytes/--chars/--lines) can be set at once"
        );
    }
    let global_flags =
        [cli.global_bytes.is_some(), cli.global_lines.is_some()];
    let global_set = global_flags.iter().filter(|b| **b).count();
    if global_set > 1 {
        bail!(
            "only one global budget (--global-bytes/--global-lines) can be set at once"
        );
    }
    if cli.count_matches
        && cli.grep.is_empty()
        && cli.igrep.is_empty()
        && cli.weak_grep.is_empty()
        && cli.weak_igrep.is_empty()
        && cli.capped_grep.is_empty()
        && cli.capped_igrep.is_empty()
    {
        bail!(
            "--count-matches requires at least one grep flag (--grep, --igrep, --weak-grep, --weak-igrep, --capped-grep, --capped-igrep)"
        );
    }
    let has_grep = !cli.grep.is_empty() || !cli.igrep.is_empty();
    let has_capped =
        !cli.capped_grep.is_empty() || !cli.capped_igrep.is_empty();
    if has_grep && has_capped {
        // GrepConfig.force_strong_inclusion is a single bool shared by all
        // strong-slot patterns. When --grep is present it is set to true,
        // which forces every strong-slot pattern (including --capped-grep ones)
        // past the budget — silently defeating the "capped" semantics.
        // Until the two pattern sets are stored separately, combining them
        // produces surprising output, so we reject it explicitly.
        // Use --weak-grep alongside --grep for soft priority without forcing.
        bail!(
            "--capped-grep/--capped-igrep cannot be combined with --grep/--igrep; \
             use --weak-grep or --weak-igrep for soft priority alongside a hard --grep"
        );
    }
    Ok(())
}

fn per_slot_budget(cli: &Cli) -> Option<Budget> {
    cli.bytes
        .map(|b| Budget {
            kind: BudgetKind::Bytes,
            cap: b,
        })
        .or_else(|| {
            cli.chars.map(|c| Budget {
                kind: BudgetKind::Chars,
                cap: c,
            })
        })
        .or_else(|| {
            cli.lines.map(|l| Budget {
                kind: BudgetKind::Lines,
                cap: l,
            })
        })
}

fn explicit_global_budget(cli: &Cli) -> Option<Budget> {
    cli.global_bytes
        .map(|b| Budget {
            kind: BudgetKind::Bytes,
            cap: b,
        })
        .or_else(|| {
            cli.global_lines.map(|l| Budget {
                kind: BudgetKind::Lines,
                cap: l,
            })
        })
}

// Return a rendering config adjusted for active budget modes (pure; does not mutate caller state).
// In practice this only lifts string trimming when running line-only (lines set, no bytes).
pub(crate) fn render_config_for_budgets(
    cfg: RenderConfig,
    effective: &EffectiveBudgets,
) -> RenderConfig {
    headson::budget::render_config_for_budgets(cfg, effective)
}

pub(crate) fn build_priority_config(
    cli: &Cli,
    effective: &EffectiveBudgets,
) -> PriorityConfig {
    let sampler = if cli.tail {
        ArraySamplerStrategy::Tail
    } else if cli.head {
        ArraySamplerStrategy::Head
    } else {
        ArraySamplerStrategy::Default
    };
    PriorityConfig::for_budget(
        cli.string_cap,
        effective.per_file_for_priority,
        cli.tail,
        sampler,
        effective.line_only,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::args::Cli;
    use clap::Parser;

    fn parse(args: &[&str]) -> Cli {
        let mut full_args = vec!["hson"];
        full_args.extend(args.iter().copied());
        Cli::parse_from(full_args)
    }

    #[test]
    fn default_per_file_budget_is_500_bytes() {
        let cli = parse(&[]);
        let effective = compute_effective(&cli, 2);
        assert_eq!(
            effective.budgets.global,
            Some(Budget {
                kind: BudgetKind::Bytes,
                cap: 1000
            }),
            "default byte budget should scale by input count (500 each)"
        );
        assert_eq!(
            effective.budgets.per_slot,
            Some(Budget {
                kind: BudgetKind::Bytes,
                cap: 500
            }),
            "defaults should still enforce a per-file 500-byte cap so later files cannot be starved"
        );
        assert_eq!(
            effective.per_file_for_priority, 500,
            "priority tuning should still use 500 per file by default"
        );
    }

    #[test]
    fn mixed_level_metrics_are_allowed() {
        let cli = parse(&["-n", "3", "-C", "120"]);
        let effective = compute_effective(&cli, 1);
        assert_eq!(
            effective.budgets.per_slot,
            Some(Budget {
                kind: BudgetKind::Lines,
                cap: 3
            }),
            "per-file line cap should be set when provided"
        );
        assert_eq!(
            effective.budgets.global,
            Some(Budget {
                kind: BudgetKind::Bytes,
                cap: 120
            }),
            "global byte cap should propagate when provided"
        );
    }
}