Skip to main content

cfgd_core/output/
printer.rs

1//! User-facing handle. Holds the Renderer (single layout authority), the
2//! active OutputFormat, and the writers for stderr (status output) +
3//! stdout (structured/data output). Sinks: `sink_stderr` for status,
4//! `sink_stdout` for `data_line`, `multi_progress` for spinners and progress
5//! bars, `syntax_set` / `theme_set` for `syntax_highlight`. The
6//! `test_doc_capture` and `prompt_queue` fields are populated by test
7//! helpers (gated on the `test-helpers` feature).
8
9use std::collections::VecDeque;
10use std::sync::{Arc, Mutex};
11
12use console::Term;
13
14use super::renderer::{Renderer, StatusFields, Table, Writer};
15use super::{OutputFormat, Role, Theme, Verbosity};
16
17/// One canned prompt response. Used by tests to drive prompt_* past
18/// non-interactive guards.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum PromptAnswer {
21    Confirm(bool),
22    Text(String),
23    Select(String),
24}
25
26/// Captured-output handle returned by `Printer::for_test_doc`. Available with
27/// the `test-helpers` feature.
28pub struct DocCapture {
29    pub(crate) human: Arc<Mutex<String>>,
30    pub(crate) doc_json: Arc<Mutex<Option<serde_json::Value>>>,
31}
32
33impl DocCapture {
34    pub fn human(&self) -> String {
35        self.human.lock().unwrap_or_else(|e| e.into_inner()).clone()
36    }
37    pub fn json(&self) -> Option<serde_json::Value> {
38        self.doc_json
39            .lock()
40            .unwrap_or_else(|e| e.into_inner())
41            .clone()
42    }
43}
44
45pub struct Printer {
46    pub(crate) renderer: Arc<Renderer>,
47    pub(crate) output_format: OutputFormat,
48    pub(crate) sink_stderr: Arc<dyn Writer>,
49    pub(crate) sink_stdout: Arc<dyn Writer>,
50    pub(crate) multi_progress: indicatif::MultiProgress,
51    pub(crate) syntax_set: syntect::parsing::SyntaxSet,
52    pub(crate) theme_set: syntect::highlighting::ThemeSet,
53    /// Set under `test-helpers` when `for_test_doc` is used.
54    pub(crate) test_doc_capture: Option<DocCapture>,
55    /// Set under `test-helpers` when prompt responses are seeded.
56    pub(crate) prompt_queue: Option<Arc<Mutex<VecDeque<PromptAnswer>>>>,
57}
58
59impl Printer {
60    /// Production constructor: stderr/stdout via `console::Term`.
61    pub fn new(verbosity: Verbosity) -> Self {
62        Self::with_format(verbosity, None, OutputFormat::Table)
63    }
64
65    pub fn with_theme_name(verbosity: Verbosity, theme_name: Option<&str>) -> Self {
66        Self::with_format(verbosity, theme_name, OutputFormat::Table)
67    }
68
69    pub fn with_format(
70        verbosity: Verbosity,
71        theme_name: Option<&str>,
72        output_format: OutputFormat,
73    ) -> Self {
74        // Honor NO_COLOR / TERM=dumb at construction. Also disable colors
75        // under structured output (Json / Yaml / Template / Jsonpath / Name)
76        // so a future role-styled emission cannot leak ANSI escapes into
77        // payload string fields — the contract is enforced at construction,
78        // not by every caller remembering to wrap with with_data.
79        if std::env::var_os("NO_COLOR").is_some()
80            || std::env::var_os("TERM").is_some_and(|t| t == "dumb")
81            || output_format.is_structured()
82        {
83            console::set_colors_enabled(false);
84            console::set_colors_enabled_stderr(false);
85        }
86        // Auto-quiet under structured output.
87        let verbosity = if output_format.is_structured() {
88            Verbosity::Quiet
89        } else {
90            verbosity
91        };
92        let theme = theme_name.map(Theme::from_preset).unwrap_or_default();
93        Self {
94            renderer: Arc::new(Renderer::new(theme, verbosity)),
95            output_format,
96            sink_stderr: Arc::new(Term::stderr()),
97            sink_stdout: Arc::new(Term::stdout()),
98            multi_progress: indicatif::MultiProgress::new(),
99            syntax_set: syntect::parsing::SyntaxSet::load_defaults_newlines(),
100            theme_set: syntect::highlighting::ThemeSet::load_defaults(),
101            test_doc_capture: None,
102            prompt_queue: None,
103        }
104    }
105
106    pub fn verbosity(&self) -> Verbosity {
107        self.renderer.verbosity
108    }
109    pub fn output_format(&self) -> &OutputFormat {
110        &self.output_format
111    }
112    pub fn is_structured(&self) -> bool {
113        self.output_format.is_structured()
114    }
115    pub fn is_wide(&self) -> bool {
116        matches!(self.output_format, OutputFormat::Wide)
117    }
118
119    /// Disable color globally (today's `disable_colors`).
120    pub fn disable_colors() {
121        console::set_colors_enabled(false);
122        console::set_colors_enabled_stderr(false);
123    }
124
125    /// Force color globally regardless of TTY detection. Symmetric to
126    /// `disable_colors` so demo / example binaries that pipe their output for
127    /// capture can still emit real ANSI escapes. Production CLI dispatch goes
128    /// through `with_format`, which honors `NO_COLOR` and structured-output
129    /// gating — call this only from non-production entry points.
130    pub fn enable_colors() {
131        console::set_colors_enabled(true);
132        console::set_colors_enabled_stderr(true);
133    }
134
135    // ----- Top-level emit methods (depth 0) -----
136
137    pub fn heading(&self, text: impl Into<String>) {
138        let depth = self.renderer.enforce_top_level_emit(0);
139        // render_heading is hardcoded to depth 0 today; for the runtime-check
140        // re-route path we emit a styled bold line at the section's depth so
141        // the output stays readable despite the shape being wrong.
142        if depth == 0 {
143            self.renderer
144                .render_heading(self.sink_stderr.as_ref(), &text.into());
145        } else {
146            let text = text.into();
147            let styled = self.renderer.theme.header.apply_to(&text).to_string();
148            self.renderer
149                .write_line(self.sink_stderr.as_ref(), depth, &styled);
150        }
151    }
152
153    pub fn kv(&self, key: impl Into<String>, value: impl Into<String>) {
154        // kv buffers; flush will use the renderer's current depth, so the
155        // runtime check is informational here — no depth value to thread
156        // through, but we still want the warn/assert at the call site.
157        let _depth = self.renderer.enforce_top_level_emit(0);
158        self.renderer.render_kv(&key.into(), &value.into());
159    }
160
161    pub fn kv_block<I, K, V>(&self, pairs: I)
162    where
163        I: IntoIterator<Item = (K, V)>,
164        K: Into<String>,
165        V: Into<String>,
166    {
167        let depth = self.renderer.enforce_top_level_emit(0);
168        let pairs: Vec<(String, String)> = pairs
169            .into_iter()
170            .map(|(k, v)| (k.into(), v.into()))
171            .collect();
172        self.renderer
173            .render_kv_block(self.sink_stderr.as_ref(), depth, &pairs);
174    }
175
176    pub fn hint(&self, text: impl Into<String>) {
177        let depth = self.renderer.enforce_top_level_emit(0);
178        self.renderer
179            .render_hint(self.sink_stderr.as_ref(), depth, &text.into());
180    }
181
182    pub fn note(&self, text: impl Into<String>) {
183        let depth = self.renderer.enforce_top_level_emit(0);
184        self.renderer
185            .render_note(self.sink_stderr.as_ref(), depth, &text.into());
186    }
187
188    pub fn table(&self, table: Table) {
189        let depth = self.renderer.enforce_top_level_emit(0);
190        self.renderer
191            .render_table(self.sink_stderr.as_ref(), depth, &table);
192    }
193
194    /// Status with no extra fields. For detail/duration/target, use the builder
195    /// returned by the binding helper `status` (see status_builder.rs).
196    pub fn status_simple(&self, role: Role, subject: impl Into<String>) {
197        let depth = self.renderer.enforce_top_level_emit(0);
198        let subject = subject.into();
199        self.renderer.render_status(
200            self.sink_stderr.as_ref(),
201            depth,
202            &StatusFields {
203                role,
204                subject: &subject,
205                detail: None,
206                duration: None,
207                target: None,
208            },
209        );
210    }
211
212    /// Status builder at depth 0. Commits on Drop.
213    pub fn status(
214        &self,
215        role: Role,
216        subject: impl Into<String>,
217    ) -> super::status_builder::StatusBuilder<'_> {
218        let depth = self.renderer.enforce_top_level_emit(0);
219        super::status_builder::StatusBuilder::new(
220            self.renderer.clone(),
221            self.sink_stderr.clone(),
222            depth,
223            role,
224            subject,
225        )
226    }
227
228    // ----- Spinners / progress (depth 0) -----
229
230    /// Top-level spinner (depth 0). Required for ~14 lib-side call sites
231    /// in cfgd-core that today take `&Printer` and have no section context
232    /// (oci/, upgrade/, sources/, modules/git.rs, reconciler/scripts.rs).
233    #[must_use]
234    pub fn spinner(&self, message: impl Into<String>) -> super::spinner::Spinner<'_> {
235        let message = message.into();
236        let bar = super::spinner::make_spinner_bar(
237            &self.multi_progress,
238            &self.renderer,
239            self.verbosity(),
240            &message,
241        );
242        super::spinner::Spinner {
243            renderer: self.renderer.clone(),
244            sink: self.sink_stderr.clone(),
245            depth: 0,
246            bar,
247            message,
248            finished: false,
249            _phantom: std::marker::PhantomData,
250        }
251    }
252
253    #[must_use]
254    pub fn progress_bar(
255        &self,
256        total: u64,
257        message: impl Into<String>,
258    ) -> super::spinner::ProgressBar<'_> {
259        let bar = super::spinner::make_progress_bar(
260            &self.multi_progress,
261            total,
262            self.verbosity(),
263            &message.into(),
264        );
265        super::spinner::ProgressBar {
266            bar,
267            _phantom: std::marker::PhantomData,
268        }
269    }
270
271    /// Expose the underlying MultiProgress for callers that need fine-grained
272    /// control (kept for API parity with the old Printer).
273    pub fn multi_progress(&self) -> &indicatif::MultiProgress {
274        &self.multi_progress
275    }
276
277    /// Run an external command at top-level (depth 0) with live output.
278    /// TTY+non-quiet → spinner with tailing ring; otherwise → streaming lines.
279    /// Either path captures full stdout/stderr in the returned `CommandOutput`.
280    pub fn run(
281        &self,
282        cmd: &mut std::process::Command,
283        label: impl Into<String>,
284    ) -> std::io::Result<super::process::CommandOutput> {
285        // run is depth-0 only; the clamp would still return 0, so the value is discarded.
286        let _ = self.renderer.enforce_top_level_emit(0);
287        super::process::run_command(
288            &self.renderer,
289            self.sink_stderr.as_ref(),
290            &self.multi_progress,
291            0,
292            cmd,
293            &label.into(),
294        )
295    }
296
297    /// Final flush — call at the end of a streaming command to ensure any
298    /// buffered kvs land. (Drop on Printer would also do this but tests need
299    /// explicit control.)
300    pub fn flush(&self) {
301        self.renderer.flush_kv_buffer(self.sink_stderr.as_ref());
302    }
303
304    /// Force human render of a Doc to stderr, regardless of `output_format`.
305    /// Used by tests; production code should call `emit` (T24) which routes by
306    /// `OutputFormat` and falls back to this for human formats.
307    pub fn render(&self, doc: super::doc::Doc) {
308        super::render_doc::render_doc(&self.renderer, self.sink_stderr.as_ref(), &doc);
309    }
310
311    /// Routed emit: structured formats go to stdout as JSON/YAML/etc.; Table/Wide
312    /// go to stderr as the human render. This is the canonical buffered-output
313    /// entry; production callers use this, not `render`.
314    pub fn emit(&self, doc: super::doc::Doc) {
315        // Capture the Doc's JSON form for tests, regardless of output_format.
316        if let Some(cap) = &self.test_doc_capture {
317            let json = doc.data_or_self_json();
318            *cap.doc_json.lock().unwrap_or_else(|e| e.into_inner()) = Some(json);
319        }
320        let handled = super::structured::emit_structured(
321            self.sink_stdout.as_ref(),
322            &doc,
323            &self.output_format,
324        );
325        if !handled {
326            self.render(doc);
327        }
328    }
329
330    // ----- Section entry points -----
331
332    #[must_use = "section closes when SectionGuard is dropped; bind it"]
333    pub fn section(&self, name: impl Into<String>) -> super::section_guard::SectionGuard<'_> {
334        self.renderer.render_section_open(&name.into(), true);
335        super::section_guard::SectionGuard {
336            printer: self,
337            renderer: self.renderer.clone(),
338            sink: self.sink_stderr.clone(),
339            depth: 1,
340        }
341    }
342
343    #[must_use = "section closes when SectionGuard is dropped; bind it"]
344    pub fn section_or_collapse(
345        &self,
346        name: impl Into<String>,
347    ) -> super::section_guard::SectionGuard<'_> {
348        self.renderer.render_section_open(&name.into(), false);
349        super::section_guard::SectionGuard {
350            printer: self,
351            renderer: self.renderer.clone(),
352            sink: self.sink_stderr.clone(),
353            depth: 1,
354        }
355    }
356}
357
358impl Drop for Printer {
359    fn drop(&mut self) {
360        self.flush();
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    #[cfg(feature = "test-helpers")]
368    use crate::output::strip_ansi;
369    use crate::output::test_support::ColorsEnabledGuard;
370    use crate::test_helpers::EnvVarGuard;
371    use serial_test::serial;
372
373    #[test]
374    #[serial]
375    fn structured_format_auto_quiets() {
376        let p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Json);
377        assert_eq!(p.verbosity(), Verbosity::Quiet);
378    }
379
380    #[test]
381    #[serial]
382    fn table_format_keeps_verbosity() {
383        let p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Table);
384        assert_eq!(p.verbosity(), Verbosity::Normal);
385    }
386
387    #[test]
388    #[serial]
389    fn is_structured_classifies() {
390        let p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Json);
391        assert!(p.is_structured());
392        let p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Table);
393        assert!(!p.is_structured());
394    }
395
396    #[test]
397    #[serial]
398    fn structured_output_disables_colors() {
399        // Ensure NO_COLOR / TERM=dumb are not the ones triggering the gate.
400        let _no_color = EnvVarGuard::unset("NO_COLOR");
401        let _term = EnvVarGuard::set("TERM", "xterm-256color");
402        let _guard = ColorsEnabledGuard::set(true);
403
404        for fmt in [
405            OutputFormat::Json,
406            OutputFormat::Yaml,
407            OutputFormat::Name,
408            OutputFormat::Jsonpath("{.foo}".into()),
409            OutputFormat::Template("{{ . }}".into()),
410        ] {
411            console::set_colors_enabled(true);
412            console::set_colors_enabled_stderr(true);
413            let _p = Printer::with_format(Verbosity::Normal, None, fmt.clone());
414            assert!(
415                !console::colors_enabled(),
416                "stdout colors should be disabled for {fmt:?}"
417            );
418            assert!(
419                !console::colors_enabled_stderr(),
420                "stderr colors should be disabled for {fmt:?}"
421            );
422        }
423    }
424
425    #[test]
426    #[serial]
427    fn table_format_does_not_disable_colors_implicitly() {
428        let _no_color = EnvVarGuard::unset("NO_COLOR");
429        let _term = EnvVarGuard::set("TERM", "xterm-256color");
430        let _guard = ColorsEnabledGuard::set(true);
431        console::set_colors_enabled(true);
432        console::set_colors_enabled_stderr(true);
433
434        let _p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Table);
435        assert!(
436            console::colors_enabled(),
437            "Table format must not implicitly disable colors"
438        );
439        assert!(
440            console::colors_enabled_stderr(),
441            "Table format must not implicitly disable stderr colors"
442        );
443    }
444
445    #[cfg(feature = "test-helpers")]
446    #[test]
447    fn section_with_bullets_renders_indented() {
448        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
449        {
450            let s = p.section("Files");
451            s.bullet("foo.txt");
452            s.bullet("bar.txt");
453        } // section closes
454        p.flush();
455        let out = strip_ansi(&buf.lock().unwrap());
456        assert!(out.contains("Files\n"), "got: {out:?}");
457        assert!(out.contains("\n  - foo.txt\n"), "got: {out:?}");
458        assert!(out.contains("\n  - bar.txt\n"), "got: {out:?}");
459    }
460
461    #[cfg(feature = "test-helpers")]
462    #[test]
463    fn section_or_collapse_with_no_emits_leaves_no_trace() {
464        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
465        {
466            let _s = p.section_or_collapse("Empty");
467        }
468        p.flush();
469        assert!(buf.lock().unwrap().trim().is_empty());
470    }
471
472    #[cfg(feature = "test-helpers")]
473    #[test]
474    fn nested_sections_indent_two_levels() {
475        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
476        {
477            let outer = p.section("Outer");
478            {
479                let inner = outer.section("Inner");
480                inner.bullet("deep");
481            }
482        }
483        p.flush();
484        let out = strip_ansi(&buf.lock().unwrap());
485        assert!(out.contains("Outer\n"));
486        assert!(out.contains("\n  Inner\n"));
487        assert!(out.contains("\n    - deep\n"));
488    }
489
490    #[cfg(feature = "test-helpers")]
491    #[test]
492    fn section_kv_renders_key_value() {
493        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
494        {
495            let s = p.section("Details");
496            s.kv("Name", "cfgd");
497            s.kv("Version", "0.3.5");
498        }
499        p.flush();
500        let out = strip_ansi(&buf.lock().unwrap());
501        assert!(out.contains("Details\n"), "got: {out:?}");
502        assert!(out.contains("Name"), "got: {out:?}");
503        assert!(out.contains("cfgd"), "got: {out:?}");
504    }
505
506    #[cfg(feature = "test-helpers")]
507    #[test]
508    fn section_kv_block_renders_pairs() {
509        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
510        {
511            let s = p.section("Config");
512            s.kv_block([("Profile", "default"), ("Source", "local")]);
513        }
514        p.flush();
515        let out = strip_ansi(&buf.lock().unwrap());
516        assert!(out.contains("Config\n"), "got: {out:?}");
517        assert!(out.contains("Profile"), "got: {out:?}");
518        assert!(out.contains("default"), "got: {out:?}");
519        assert!(out.contains("Source"), "got: {out:?}");
520    }
521
522    #[cfg(feature = "test-helpers")]
523    #[test]
524    fn section_hint_renders() {
525        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
526        {
527            let s = p.section("Setup");
528            s.hint("Run cfgd init first");
529        }
530        p.flush();
531        let out = strip_ansi(&buf.lock().unwrap());
532        assert!(out.contains("Setup\n"), "got: {out:?}");
533        assert!(out.contains("cfgd init"), "got: {out:?}");
534    }
535
536    #[cfg(feature = "test-helpers")]
537    #[test]
538    fn section_note_renders_at_verbose() {
539        let (p, buf) = Printer::for_test_at(Verbosity::Verbose);
540        {
541            let s = p.section("Status");
542            s.note("All modules up to date");
543        }
544        p.flush();
545        let out = strip_ansi(&buf.lock().unwrap());
546        assert!(out.contains("Status\n"), "got: {out:?}");
547        assert!(out.contains("up to date"), "got: {out:?}");
548    }
549
550    #[cfg(feature = "test-helpers")]
551    #[test]
552    fn section_table_renders() {
553        use super::super::renderer::Table;
554        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
555        {
556            let s = p.section("Packages");
557            let table = Table::new(["Name", "Version"]).row(["curl", "8.0"]);
558            s.table(table);
559        }
560        p.flush();
561        let out = strip_ansi(&buf.lock().unwrap());
562        assert!(out.contains("Packages\n"), "got: {out:?}");
563        assert!(out.contains("curl"), "got: {out:?}");
564    }
565
566    #[cfg(feature = "test-helpers")]
567    #[test]
568    fn section_status_simple_renders() {
569        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
570        {
571            let s = p.section("Apply");
572            s.status_simple(Role::Ok, "package installed");
573            s.status_simple(Role::Fail, "file copy failed");
574        }
575        p.flush();
576        let out = strip_ansi(&buf.lock().unwrap());
577        assert!(out.contains("Apply\n"), "got: {out:?}");
578        assert!(out.contains("package installed"), "got: {out:?}");
579        assert!(out.contains("file copy failed"), "got: {out:?}");
580    }
581
582    #[cfg(feature = "test-helpers")]
583    #[test]
584    fn section_status_builder_with_detail() {
585        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
586        {
587            let s = p.section("Apply");
588            s.status(Role::Ok, "brew install curl")
589                .detail("already installed");
590        }
591        p.flush();
592        let out = strip_ansi(&buf.lock().unwrap());
593        assert!(out.contains("brew install curl"), "got: {out:?}");
594        assert!(out.contains("already installed"), "got: {out:?}");
595    }
596
597    #[cfg(feature = "test-helpers")]
598    #[test]
599    fn section_empty_state_overrides_default() {
600        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
601        {
602            let s = p.section("Modules");
603            s.empty_state("no modules configured");
604        }
605        p.flush();
606        let out = strip_ansi(&buf.lock().unwrap());
607        assert!(out.contains("Modules\n"), "got: {out:?}");
608        assert!(out.contains("no modules configured"), "got: {out:?}");
609    }
610
611    #[cfg(feature = "test-helpers")]
612    #[test]
613    fn section_or_collapse_with_child_renders() {
614        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
615        {
616            let s = p.section_or_collapse("Optional");
617            s.bullet("present");
618        }
619        p.flush();
620        let out = strip_ansi(&buf.lock().unwrap());
621        assert!(out.contains("Optional\n"), "got: {out:?}");
622        assert!(out.contains("present"), "got: {out:?}");
623    }
624
625    #[cfg(feature = "test-helpers")]
626    #[test]
627    fn section_close_is_idempotent_via_explicit_close() {
628        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
629        {
630            let s = p.section("Closing");
631            s.bullet("item");
632            s.close();
633        }
634        p.flush();
635        let out = strip_ansi(&buf.lock().unwrap());
636        assert!(out.contains("Closing\n"), "got: {out:?}");
637        assert!(out.contains("item"), "got: {out:?}");
638    }
639
640    #[cfg(feature = "test-helpers")]
641    #[test]
642    fn nested_section_or_collapse_renders_child_content() {
643        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
644        {
645            let outer = p.section("Outer");
646            {
647                let inner = outer.section_or_collapse("Inner");
648                inner.status_simple(Role::Ok, "done");
649            }
650        }
651        p.flush();
652        let out = strip_ansi(&buf.lock().unwrap());
653        assert!(out.contains("Outer\n"), "got: {out:?}");
654        assert!(out.contains("Inner\n"), "got: {out:?}");
655        assert!(out.contains("done"), "got: {out:?}");
656    }
657
658    #[cfg(feature = "test-helpers")]
659    #[test]
660    fn render_doc_with_section_indents_correctly() {
661        use super::super::doc::Doc;
662        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
663        let doc = Doc::new()
664            .heading("Status")
665            .kv("Profile", "dev")
666            .section("Files", |s| s.bullet("foo.txt").bullet("bar.txt"));
667        p.render(doc);
668        p.flush();
669        let out = strip_ansi(&buf.lock().unwrap());
670        assert!(out.contains("Status\n"));
671        assert!(out.contains("Profile  dev"));
672        assert!(out.contains("Files\n"));
673        assert!(out.contains("\n  - foo.txt\n"));
674    }
675
676    #[cfg(feature = "test-helpers")]
677    #[test]
678    fn empty_section_or_collapse_in_doc_leaves_no_trace() {
679        use super::super::doc::Doc;
680        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
681        let doc = Doc::new()
682            .heading("Status")
683            .section_or_collapse::<_>("Empty", |s| s);
684        p.render(doc);
685        p.flush();
686        let out = strip_ansi(&buf.lock().unwrap());
687        assert!(out.contains("Status"));
688        assert!(!out.contains("Empty"), "got: {out:?}");
689    }
690
691    #[cfg(feature = "test-helpers")]
692    #[test]
693    fn emit_json_writes_data_payload_to_stdout() {
694        use super::super::doc::Doc;
695        #[derive(serde::Serialize)]
696        struct P {
697            foo: u32,
698        }
699        let (p, buf) = Printer::for_test_with_format(OutputFormat::Json);
700        let doc = Doc::new().heading("S").with_data(P { foo: 7 });
701        p.emit(doc);
702        let out = buf.lock().unwrap();
703        assert!(out.contains("\"foo\": 7"), "got: {out:?}");
704    }
705
706    #[cfg(feature = "test-helpers")]
707    #[test]
708    fn emit_table_writes_human_render() {
709        use super::super::doc::Doc;
710        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
711        let doc = Doc::new().heading("Title").kv("k", "v");
712        p.emit(doc);
713        p.flush();
714        let out = strip_ansi(&buf.lock().unwrap());
715        assert!(out.contains("Title"));
716        assert!(out.contains("k  v"));
717    }
718
719    #[cfg(feature = "test-helpers")]
720    #[test]
721    fn emit_with_doc_capture_records_both_shapes() {
722        use super::super::doc::Doc;
723        let (p, cap) = Printer::for_test_doc();
724        let doc = Doc::new().heading("S").kv("k", "v");
725        p.emit(doc);
726        p.flush();
727        let human = cap.human();
728        let json = cap.json().unwrap();
729        assert!(human.contains("S"), "got: {human:?}");
730        assert!(human.contains("k"));
731        assert!(json["heading"].as_str() == Some("S"));
732    }
733
734    #[cfg(feature = "test-helpers")]
735    #[test]
736    fn render_doc_with_hint_renders_content() {
737        use super::super::doc::Doc;
738        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
739        let doc = Doc::new()
740            .heading("Setup")
741            .hint("Run cfgd init to get started");
742        p.render(doc);
743        p.flush();
744        let out = strip_ansi(&buf.lock().unwrap());
745        assert!(out.contains("Setup"), "got: {out:?}");
746        assert!(out.contains("cfgd init"), "got: {out:?}");
747    }
748
749    #[cfg(feature = "test-helpers")]
750    #[test]
751    fn render_doc_with_note_renders_at_verbose() {
752        use super::super::doc::Doc;
753        let (p, buf) = Printer::for_test_at(Verbosity::Verbose);
754        let doc = Doc::new().heading("Info").note("This is supplementary");
755        p.render(doc);
756        p.flush();
757        let out = strip_ansi(&buf.lock().unwrap());
758        assert!(out.contains("Info"), "got: {out:?}");
759        assert!(out.contains("supplementary"), "got: {out:?}");
760    }
761
762    #[cfg(feature = "test-helpers")]
763    #[test]
764    fn render_doc_with_status_duration_and_target() {
765        use super::super::doc::Doc;
766        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
767        let doc = Doc::new()
768            .heading("Apply")
769            .status_with(Role::Ok, "brew install curl", |f| {
770                f.detail("already installed")
771                    .duration(std::time::Duration::from_millis(1500))
772                    .target("/usr/local/bin/curl")
773            });
774        p.render(doc);
775        p.flush();
776        let out = strip_ansi(&buf.lock().unwrap());
777        assert!(out.contains("brew install curl"), "got: {out:?}");
778        assert!(out.contains("already installed"), "got: {out:?}");
779    }
780
781    #[cfg(feature = "test-helpers")]
782    #[test]
783    fn render_doc_section_with_empty_state() {
784        use super::super::doc::Doc;
785        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
786        let doc = Doc::new()
787            .heading("Modules")
788            .section("Installed", |s| s.empty_state("no modules found"));
789        p.render(doc);
790        p.flush();
791        let out = strip_ansi(&buf.lock().unwrap());
792        assert!(out.contains("Modules"), "got: {out:?}");
793        assert!(out.contains("Installed"), "got: {out:?}");
794        assert!(out.contains("no modules found"), "got: {out:?}");
795    }
796
797    #[cfg(feature = "test-helpers")]
798    #[test]
799    fn render_doc_with_kv_block() {
800        use super::super::doc::Doc;
801        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
802        let doc = Doc::new()
803            .heading("Config")
804            .kv_block([("Profile", "dev"), ("Source", "local")]);
805        p.render(doc);
806        p.flush();
807        let out = strip_ansi(&buf.lock().unwrap());
808        assert!(out.contains("Config"), "got: {out:?}");
809        assert!(out.contains("Profile"), "got: {out:?}");
810        assert!(out.contains("dev"), "got: {out:?}");
811    }
812
813    #[cfg(feature = "test-helpers")]
814    #[test]
815    fn status_builder_detail_opt_none() {
816        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
817        p.status(Role::Ok, "package check").detail_opt(None);
818        p.flush();
819        let out = strip_ansi(&buf.lock().unwrap());
820        assert!(out.contains("package check"), "got: {out:?}");
821    }
822
823    #[cfg(feature = "test-helpers")]
824    #[test]
825    fn status_builder_detail_opt_some() {
826        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
827        p.status(Role::Ok, "installed").detail_opt(Some("v1.2.3"));
828        p.flush();
829        let out = strip_ansi(&buf.lock().unwrap());
830        assert!(out.contains("installed"), "got: {out:?}");
831        assert!(out.contains("v1.2.3"), "got: {out:?}");
832    }
833
834    #[cfg(feature = "test-helpers")]
835    #[test]
836    fn status_builder_with_target_path() {
837        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
838        p.status(Role::Ok, "file deployed")
839            .target(std::path::Path::new("/home/user/.zshrc"));
840        p.flush();
841        let out = strip_ansi(&buf.lock().unwrap());
842        assert!(out.contains("file deployed"), "got: {out:?}");
843    }
844
845    #[cfg(feature = "test-helpers")]
846    #[test]
847    fn status_builder_with_duration() {
848        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
849        p.status(Role::Ok, "brew install curl")
850            .duration(std::time::Duration::from_secs(3));
851        p.flush();
852        let out = strip_ansi(&buf.lock().unwrap());
853        assert!(out.contains("brew install curl"), "got: {out:?}");
854    }
855
856    /// In debug builds, a top-level emit reached while a section is open
857    /// trips `debug_assert!` in `Renderer::enforce_top_level_emit`. We catch
858    /// the panic to verify the assert fires.
859    #[cfg(feature = "test-helpers")]
860    #[test]
861    #[cfg(debug_assertions)]
862    fn debug_mode_panics_on_top_level_emit_during_section() {
863        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
864            let (p, _buf) = Printer::for_test_at(Verbosity::Normal);
865            let _s = p.section("Outer");
866            p.heading("MidSection"); // debug_assert! fires
867        }));
868        assert!(result.is_err(), "expected debug_assert! panic");
869    }
870
871    /// In release builds, the assert is compiled out; the warn-once fires
872    /// and the emit reroutes to the section's depth instead of column 0.
873    #[cfg(feature = "test-helpers")]
874    #[test]
875    #[cfg(not(debug_assertions))]
876    fn release_mode_reroutes_top_level_emit_during_section() {
877        let (p, buf) = Printer::for_test_at(Verbosity::Normal);
878        {
879            let _s = p.section("Outer");
880            p.heading("MidSection"); // would assert in debug; reroutes in release
881        }
882        p.flush();
883        let out = strip_ansi(&buf.lock().unwrap());
884        // The heading rendered at depth 1 (inside the section), not column 0.
885        assert!(
886            out.contains("\n  MidSection\n"),
887            "expected indented; got: {out:?}"
888        );
889        assert!(
890            !out.contains("\nMidSection\n"),
891            "unindented form leaked through: {out:?}"
892        );
893    }
894}