1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum PromptAnswer {
21 Confirm(bool),
22 Text(String),
23 Select(String),
24}
25
26pub 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 pub(crate) test_doc_capture: Option<DocCapture>,
55 pub(crate) prompt_queue: Option<Arc<Mutex<VecDeque<PromptAnswer>>>>,
57}
58
59impl Printer {
60 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 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 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 pub fn disable_colors() {
121 console::set_colors_enabled(false);
122 console::set_colors_enabled_stderr(false);
123 }
124
125 pub fn enable_colors() {
131 console::set_colors_enabled(true);
132 console::set_colors_enabled_stderr(true);
133 }
134
135 pub fn heading(&self, text: impl Into<String>) {
138 let depth = self.renderer.enforce_top_level_emit(0);
139 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 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 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 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 #[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 pub fn multi_progress(&self) -> &indicatif::MultiProgress {
274 &self.multi_progress
275 }
276
277 pub fn run(
281 &self,
282 cmd: &mut std::process::Command,
283 label: impl Into<String>,
284 ) -> std::io::Result<super::process::CommandOutput> {
285 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 pub fn flush(&self) {
301 self.renderer.flush_kv_buffer(self.sink_stderr.as_ref());
302 }
303
304 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 pub fn emit(&self, doc: super::doc::Doc) {
315 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 #[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 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 } 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 #[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"); }));
868 assert!(result.is_err(), "expected debug_assert! panic");
869 }
870
871 #[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"); }
882 p.flush();
883 let out = strip_ansi(&buf.lock().unwrap());
884 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}