Skip to main content

headson/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(
3    clippy::unwrap_used,
4    clippy::expect_used,
5    clippy::print_stdout,
6    clippy::print_stderr
7)]
8#![allow(
9    clippy::multiple_crate_versions,
10    reason = "Dependency graph pulls distinct versions (e.g., yaml-rust2)."
11)]
12#![cfg_attr(
13    test,
14    allow(
15        clippy::unwrap_used,
16        clippy::expect_used,
17        reason = "tests may use unwrap/expect for brevity"
18    )
19)]
20
21use anyhow::Result;
22
23pub mod budget;
24mod debug;
25mod grep;
26mod ingest;
27mod order;
28mod pruner;
29mod serialization;
30mod utils;
31pub use grep::{
32    GrepConfig, GrepPatterns, GrepShow, build_grep_config,
33    build_grep_config_from_patterns, combine_patterns,
34};
35pub use ingest::fileset::{FilesetInput, FilesetInputKind};
36pub use ingest::format::Format;
37pub use order::types::{ArrayBias, ArraySamplerStrategy};
38pub use order::{
39    DEFAULT_SAFETY_CAP, NodeId, NodeKind, PriorityConfig, PriorityOrder,
40    RankedNode, build_order,
41};
42pub use utils::extensions;
43pub use utils::templates::map_json_template_for_style;
44
45pub use pruner::budget::find_largest_render_under_budgets;
46pub use prunist::{Budget, BudgetKind, Budgets};
47pub use serialization::color::resolve_color_enabled;
48pub use serialization::types::{
49    ColorMode, ColorStrategy, OutputTemplate, RenderConfig, Style,
50};
51
52#[derive(Debug, Clone, Copy)]
53pub struct MatchSummary {
54    pub shown: usize,
55    pub hidden: usize,
56}
57
58#[derive(Debug)]
59pub struct RenderOutput {
60    pub text: String,
61    pub warnings: Vec<String>,
62    pub match_summary: Option<MatchSummary>,
63}
64
65#[derive(Copy, Clone, Debug)]
66pub enum TextMode {
67    Plain,
68    CodeLike,
69}
70
71pub enum InputKind {
72    Json(Vec<u8>),
73    Jsonl(Vec<u8>),
74    Yaml(Vec<u8>),
75    Text { bytes: Vec<u8>, mode: TextMode },
76    Fileset(Vec<FilesetInput>),
77}
78
79pub fn headson(
80    input: InputKind,
81    config: &RenderConfig,
82    priority_cfg: &PriorityConfig,
83    grep: &GrepConfig,
84    budgets: Budgets,
85) -> Result<RenderOutput> {
86    let crate::ingest::IngestOutput {
87        arena,
88        mut warnings,
89    } = crate::ingest::ingest_into_arena(input, priority_cfg, grep)?;
90    let mut order_build = order::build_order(&arena, priority_cfg)?;
91    if order_build.safety_cap_hit {
92        warnings.push(format!(
93            "warning: input truncated (exceeded {} node safety cap)",
94            priority_cfg.safety_cap
95        ));
96    }
97    let (text, match_summary) = find_largest_render_under_budgets(
98        &mut order_build,
99        config,
100        grep,
101        budgets,
102    );
103    Ok(RenderOutput {
104        text,
105        warnings,
106        match_summary,
107    })
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn test_render_config() -> RenderConfig {
115        RenderConfig {
116            template: OutputTemplate::Pseudo,
117            indent_unit: "  ".to_string(),
118            space: " ".to_string(),
119            newline: "\n".to_string(),
120            color_mode: ColorMode::Off,
121            color_enabled: false,
122            style: serialization::types::Style::Default,
123            prefer_tail_arrays: false,
124            string_free_prefix_graphemes: None,
125            debug: false,
126            primary_source_name: None,
127            show_fileset_headers: false,
128            fileset_tree: false,
129            count_fileset_headers_in_budgets: false,
130            grep_highlight: None,
131        }
132    }
133
134    #[test]
135    fn safety_cap_warning_emitted_when_exceeded() {
136        // Use a tiny safety cap so we can trigger it with minimal input.
137        // An array [1,2,3,4,5] generates: 1 root array + 5 children = 6 nodes.
138        // With safety_cap=5, we should hit the cap.
139        let mut priority_cfg = PriorityConfig::new(usize::MAX, usize::MAX);
140        priority_cfg.safety_cap = 5;
141
142        let result = headson(
143            InputKind::Json(b"[1,2,3,4,5]".to_vec()),
144            &test_render_config(),
145            &priority_cfg,
146            &GrepConfig::default(),
147            Budgets::default(),
148        )
149        .expect("headson should succeed");
150
151        assert!(
152            result.warnings.iter().any(|w| w.contains("safety cap")),
153            "expected safety cap warning, got: {:?}",
154            result.warnings
155        );
156    }
157
158    #[test]
159    fn no_safety_cap_warning_when_not_exceeded() {
160        // With default (2M) cap, a small input should not trigger warning.
161        let priority_cfg = PriorityConfig::new(usize::MAX, usize::MAX);
162
163        let result = headson(
164            InputKind::Json(b"[1,2,3]".to_vec()),
165            &test_render_config(),
166            &priority_cfg,
167            &GrepConfig::default(),
168            Budgets::default(),
169        )
170        .expect("headson should succeed");
171
172        assert!(
173            !result.warnings.iter().any(|w| w.contains("safety cap")),
174            "unexpected safety cap warning: {:?}",
175            result.warnings
176        );
177    }
178
179    #[test]
180    fn strong_grep_match_summary_hidden_zero_under_tight_budget() {
181        // JSON object with 3 keys: two contain "needle", one does not.
182        // A 1-line global budget would normally suppress most output, but strong
183        // grep must override it and include all matching nodes.
184        let input = br#"{"alpha": "needle one", "beta": "no match here", "gamma": "needle two"}"#;
185        let grep_cfg = build_grep_config(
186            Some("needle"),
187            None,
188            GrepShow::Matching,
189            false,
190            true,
191        )
192        .expect("valid grep pattern");
193        let priority_cfg = PriorityConfig::new(usize::MAX, usize::MAX);
194        // Tight global budget: 1 line — far too small to render all 4 nodes
195        // (root object + 3 values) without grep forcing matches in.
196        let budgets = Budgets {
197            global: Some(Budget {
198                kind: BudgetKind::Lines,
199                cap: 1,
200            }),
201            per_slot: None,
202        };
203
204        let result = headson(
205            InputKind::Json(input.to_vec()),
206            &test_render_config(),
207            &priority_cfg,
208            &grep_cfg,
209            budgets,
210        )
211        .expect("headson should succeed");
212
213        let summary = result
214            .match_summary
215            .expect("match_summary must be Some when grep is active");
216        assert_eq!(
217            summary.hidden, 0,
218            "strong grep must force all matches into output; hidden should be 0, got {:?}",
219            result.match_summary
220        );
221        assert_eq!(
222            summary.shown, 2,
223            "exactly 2 values match 'needle'; shown should be 2, got {:?}",
224            result.match_summary
225        );
226    }
227
228    #[test]
229    fn strong_grep_match_summary_zero_matches() {
230        let input = br#"{"alpha": "apple", "beta": "banana"}"#;
231        let grep_cfg = build_grep_config(
232            Some("zzznomatch"),
233            None,
234            GrepShow::Matching,
235            false,
236            true,
237        )
238        .expect("valid grep pattern");
239        let priority_cfg = PriorityConfig::new(usize::MAX, usize::MAX);
240
241        let result = headson(
242            InputKind::Json(input.to_vec()),
243            &test_render_config(),
244            &priority_cfg,
245            &grep_cfg,
246            Budgets::default(),
247        )
248        .expect("headson should succeed");
249
250        let summary = result
251            .match_summary
252            .expect("match_summary must be Some when grep is active");
253        assert_eq!(
254            summary.shown, 0,
255            "pattern matches nothing; shown should be 0, got {:?}",
256            result.match_summary
257        );
258        assert_eq!(
259            summary.hidden, 0,
260            "pattern matches nothing; hidden should be 0, got {:?}",
261            result.match_summary
262        );
263    }
264
265    #[test]
266    fn weak_grep_match_summary_has_hidden_under_tight_budget() {
267        // JSON object with 5 keys, all values contain "target".
268        // Pseudo rendering (one key-value per line plus { and }) needs 7 lines
269        // for the full object. A 4-line global budget fits only a subset.
270        let input = br#"{
271            "a": "target one",
272            "b": "target two",
273            "c": "target three",
274            "d": "target four",
275            "e": "target five"
276        }"#;
277        let total_matches: usize = 5;
278        let grep_cfg = build_grep_config(
279            None,
280            Some("target"),
281            GrepShow::Matching,
282            false,
283            false,
284        )
285        .expect("valid weak grep pattern");
286        let priority_cfg = PriorityConfig::new(usize::MAX, usize::MAX);
287        // 4-line budget: enough to show the object braces plus ~2 values,
288        // not enough to show all 5 matches.
289        let budgets = Budgets {
290            global: Some(Budget {
291                kind: BudgetKind::Lines,
292                cap: 4,
293            }),
294            per_slot: None,
295        };
296
297        let result = headson(
298            InputKind::Json(input.to_vec()),
299            &test_render_config(),
300            &priority_cfg,
301            &grep_cfg,
302            budgets,
303        )
304        .expect("headson should succeed");
305
306        let summary = result
307            .match_summary
308            .expect("match_summary must be Some when grep is active");
309        assert_eq!(
310            summary.shown + summary.hidden,
311            total_matches,
312            "shown + hidden must equal total direct matches ({}); got shown={} hidden={}",
313            total_matches,
314            summary.shown,
315            summary.hidden,
316        );
317        assert!(
318            summary.hidden > 0,
319            "tight budget must cause some weak-grep matches to be hidden; \
320             got shown={} hidden={}",
321            summary.shown,
322            summary.hidden,
323        );
324    }
325
326    #[test]
327    fn weak_grep_match_summary_all_shown_under_loose_budget() {
328        // Same 5-value object as the tight-budget test; with default (no) budget
329        // all matches should appear in the output.
330        let input = br#"{
331            "a": "target one",
332            "b": "target two",
333            "c": "target three",
334            "d": "target four",
335            "e": "target five"
336        }"#;
337        let total_matches: usize = 5;
338        let grep_cfg = build_grep_config(
339            None,
340            Some("target"),
341            GrepShow::Matching,
342            false,
343            false,
344        )
345        .expect("valid weak grep pattern");
346        let priority_cfg = PriorityConfig::new(usize::MAX, usize::MAX);
347
348        let result = headson(
349            InputKind::Json(input.to_vec()),
350            &test_render_config(),
351            &priority_cfg,
352            &grep_cfg,
353            Budgets::default(),
354        )
355        .expect("headson should succeed");
356
357        let summary = result
358            .match_summary
359            .expect("match_summary must be Some when grep is active");
360        assert_eq!(
361            summary.shown, total_matches,
362            "loose budget must show all {} matches; got shown={} hidden={}",
363            total_matches, summary.shown, summary.hidden,
364        );
365        assert_eq!(
366            summary.hidden, 0,
367            "no matches should be hidden under a loose budget; \
368             got shown={} hidden={}",
369            summary.shown, summary.hidden,
370        );
371    }
372}