1#![warn(clippy::return_self_not_must_use)]
36
37use std::io::{BufRead, IsTerminal, Read, Write};
38use std::path::{Path, PathBuf};
39use std::process::ExitCode;
40
41mod animation;
42#[cfg(feature = "interact")]
43mod approve_jump;
44mod authoring;
45#[cfg(feature = "art")]
46mod banner;
47mod batch;
48#[cfg_attr(target_os = "wasi", path = "batch_output_wasi.rs")]
49mod batch_output;
50mod batch_paths;
51#[cfg(test)]
52mod batch_races;
53mod chart;
54mod cli_spec;
55mod config;
56mod console;
57mod controls;
58mod demo;
59mod doctor;
60mod documents;
61mod host;
62mod inspect;
63#[cfg(feature = "interact")]
64mod interactive;
65mod logs;
66mod marketplace;
67#[cfg(feature = "art")]
68mod micro;
69mod plugins;
70mod profile;
71#[cfg(feature = "record")]
72mod record;
73mod redact_preview;
74mod render_target;
75#[cfg(feature = "serve")]
76mod serve;
77mod shell;
78mod sources;
79mod spin;
80mod structured_log;
81mod style_rules;
82mod tools;
83mod viewers;
84mod watch;
85use batch::run_batch;
86use config::{ConfigRoots, config_args};
87use marketplace::spec as marketplace_spec;
88
89use rich::r#box::{Box as BoxSet, DOUBLE, HEAVY_HEAD, SQUARE};
90use rich::cells::cell_len;
91use rich::markdown::Markdown;
92use rich::measure::Measurement;
93use rich::protocol::{LineRenderable, OwnedTableRows};
94use rich::text::Text;
95use rich::{
96 Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain, Control,
97 DEFAULT_TERMINAL_THEME, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify,
98 Layout, Live, LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar,
99 ProgressColumn, Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table,
100 Traceback, Tree, filesize,
101};
102use rich_data::csv::{Dialect, has_header, read_rows, sniff};
103use rich_data::infer::{InferredType, Inferrer};
104use rich_ext::ConsoleExt;
105use rich_ext::cli::CliExtensions;
106use rich_ext::encoding::{Encoding, has_utf16_bom};
107use rich_ext::sanitize_terminal_controls;
108
109fn text(content: &str) -> Box<dyn Renderable> {
111 Box::new(Text::new(content))
112}
113
114pub(crate) fn console_builder() -> rich::console::ConsoleBuilder {
126 Console::builder()
127 .force_terminal(std::io::stdout().is_terminal())
128 .on_broken_pipe(|console| console.set_quiet(true))
129}
130
131const VERSION: &str = env!("CARGO_PKG_VERSION");
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135enum Mode {
136 Auto,
138 Print,
140 Markdown,
142 Json,
144 Syntax,
146 Csv,
148 Ipynb,
150 Rst,
152 Org,
154 AsciiDoc,
156 Man,
159 Gif,
161 Diff,
163 Image,
165 Rule,
167 JsonLines,
169 Log,
171 Inspect,
173 AnsiExplain,
175 View,
178 Hex,
180 Unicode,
182 Env,
184 Capture,
186 Mermaid,
188 Dot,
190 Deps,
192 Schema,
195 Chart,
197 Profile,
199}
200
201impl Mode {
202 fn streams_records(self) -> bool {
203 matches!(self, Self::JsonLines | Self::Log)
204 }
205
206 fn draws_directly(self) -> bool {
207 matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
208 }
209
210 fn allows_extensions(self) -> bool {
211 !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
212 && !self.streams_records()
213 }
214
215 fn accepts_multiple_resources(self) -> bool {
216 matches!(
217 self,
218 Self::Gif | Self::Diff | Self::Env | Self::Capture | Self::Schema
219 )
220 }
221}
222
223struct ModeSpec {
224 mode: Mode,
225 primary: &'static str,
226 aliases: &'static [&'static str],
227}
228
229const MODE_SPECS: &[ModeSpec] = &[
230 ModeSpec {
231 mode: Mode::Auto,
232 primary: "auto",
233 aliases: &[],
234 },
235 ModeSpec {
236 mode: Mode::Print,
237 primary: "print",
238 aliases: &["print"],
239 },
240 ModeSpec {
241 mode: Mode::Markdown,
242 primary: "markdown",
243 aliases: &["markdown", "md"],
244 },
245 ModeSpec {
246 mode: Mode::Json,
247 primary: "json",
248 aliases: &["json"],
249 },
250 ModeSpec {
251 mode: Mode::Syntax,
252 primary: "syntax",
253 aliases: &["syntax", "code"],
254 },
255 ModeSpec {
256 mode: Mode::Csv,
257 primary: "csv",
258 aliases: &["csv", "tsv"],
259 },
260 ModeSpec {
261 mode: Mode::Ipynb,
262 primary: "ipynb",
263 aliases: &["ipynb", "notebook"],
264 },
265 ModeSpec {
266 mode: Mode::Rst,
267 primary: "rst",
268 aliases: &["rst"],
269 },
270 ModeSpec {
271 mode: Mode::Org,
272 primary: "org",
273 aliases: &["org"],
274 },
275 ModeSpec {
276 mode: Mode::AsciiDoc,
277 primary: "asciidoc",
278 aliases: &["asciidoc", "adoc"],
279 },
280 ModeSpec {
281 mode: Mode::Man,
282 primary: "man",
283 aliases: &["man", "roff"],
284 },
285 ModeSpec {
286 mode: Mode::Gif,
287 primary: "gif",
288 aliases: &["gif"],
289 },
290 ModeSpec {
291 mode: Mode::Diff,
292 primary: "diff",
293 aliases: &["diff"],
294 },
295 ModeSpec {
296 mode: Mode::Image,
297 primary: "image",
298 aliases: &["image"],
299 },
300 ModeSpec {
301 mode: Mode::Rule,
302 primary: "rule",
303 aliases: &["rule"],
304 },
305 ModeSpec {
306 mode: Mode::JsonLines,
307 primary: "jsonl",
308 aliases: &["jsonl", "ndjson"],
309 },
310 ModeSpec {
311 mode: Mode::Log,
312 primary: "log",
313 aliases: &["log", "logs"],
314 },
315 ModeSpec {
316 mode: Mode::Inspect,
317 primary: "inspect",
318 aliases: &["inspect"],
319 },
320 ModeSpec {
321 mode: Mode::AnsiExplain,
322 primary: "ansi",
323 aliases: &["ansi", "ansi-explain"],
324 },
325 ModeSpec {
326 mode: Mode::View,
327 primary: "view",
328 aliases: &["view"],
329 },
330 ModeSpec {
331 mode: Mode::Hex,
332 primary: "hex",
333 aliases: &["hex", "hexdump"],
334 },
335 ModeSpec {
336 mode: Mode::Unicode,
337 primary: "unicode",
338 aliases: &["unicode"],
339 },
340 ModeSpec {
341 mode: Mode::Env,
342 primary: "env",
343 aliases: &["env"],
344 },
345 ModeSpec {
346 mode: Mode::Capture,
347 primary: "capture",
348 aliases: &["capture"],
349 },
350 ModeSpec {
351 mode: Mode::Mermaid,
352 primary: "mermaid",
353 aliases: &["mermaid", "mmd"],
354 },
355 ModeSpec {
356 mode: Mode::Dot,
357 primary: "dot",
358 aliases: &["dot", "graphviz"],
359 },
360 ModeSpec {
361 mode: Mode::Deps,
362 primary: "deps",
363 aliases: &["deps"],
364 },
365 ModeSpec {
366 mode: Mode::Schema,
367 primary: "schema",
368 aliases: &["schema"],
369 },
370 ModeSpec {
371 mode: Mode::Chart,
372 primary: "chart",
373 aliases: &["chart"],
374 },
375 ModeSpec {
376 mode: Mode::Profile,
377 primary: "profile",
378 aliases: &["profile"],
379 },
380];
381
382const RENDER_MODE_FLAGS: &str = "--print/--markdown/--rst/--org/--asciidoc/--man/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385enum ReportFormat {
386 Human,
387 Json,
388}
389
390impl ReportFormat {
391 fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
392 match option {
393 "--report" => {
394 *self = match value.ok_or("--report requires one of: human, json")? {
395 "human" => Self::Human,
396 "json" => Self::Json,
397 other => return Err(format!("unknown report format {other:?} (human, json)")),
398 };
399 }
400 "--machine-json" => *self = Self::Json,
401 _ => {}
402 }
403 Ok(())
404 }
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408enum ExitClass {
409 Success,
410 Usage,
411 Input,
412 Data,
413 #[cfg_attr(not(feature = "art"), allow(dead_code))]
414 Gate,
415}
416
417impl ExitClass {
418 fn code(self) -> u8 {
419 match self {
420 Self::Success => 0,
421 Self::Usage => 2,
422 Self::Input => 3,
423 Self::Data => 4,
424 Self::Gate => 5,
425 }
426 }
427
428 fn name(self) -> &'static str {
429 match self {
430 Self::Success => "success",
431 Self::Usage => "usage",
432 Self::Input => "input",
433 Self::Data => "data",
434 Self::Gate => "gate",
435 }
436 }
437
438 fn exit_code(self) -> ExitCode {
439 ExitCode::from(self.code())
440 }
441}
442
443#[derive(Clone, Copy, PartialEq, Eq, Debug)]
450enum ImageMode {
451 Auto,
453 Sixel,
455 Blocks,
457 Braille,
459 Quadrants,
461 Ascii,
463 None,
465}
466
467impl std::str::FromStr for ImageMode {
468 type Err = String;
469
470 fn from_str(value: &str) -> Result<Self, Self::Err> {
471 match value.to_ascii_lowercase().as_str() {
472 "auto" => Ok(Self::Auto),
473 "sixel" => Ok(Self::Sixel),
474 "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
475 "braille" => Ok(Self::Braille),
476 "quadrants" | "quadrant" => Ok(Self::Quadrants),
477 "ascii" | "art" => Ok(Self::Ascii),
478 "none" | "off" => Ok(Self::None),
479 other => Err(format!(
480 "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
481 )),
482 }
483 }
484}
485
486#[derive(Clone, Debug, Default, PartialEq)]
490struct SourceOptions {
491 head: Option<usize>,
493 tail: Option<usize>,
494 line_numbers: bool,
496 guides: bool,
498 lexer: Option<String>,
500 no_wrap: bool,
502 infer: bool,
506}
507
508impl SourceOptions {
509 fn wants_syntax(&self) -> bool {
511 self.head.is_some()
512 || self.tail.is_some()
513 || self.line_numbers
514 || self.guides
515 || self.lexer.is_some()
516 || self.no_wrap
517 }
518
519 fn line_range(&self, code: &str) -> Result<Option<(i64, i64)>, String> {
524 let lines = python_line_count(code) as i64;
525 match (self.head, self.tail) {
526 (Some(_), Some(_)) => Err("cannot specify both head and tail".into()),
527 (Some(head), None) => Ok(Some((1, head as i64))),
528 (None, Some(tail)) => Ok(Some((lines - tail as i64 + 2, lines + 1))),
529 (None, None) => Ok(None),
530 }
531 }
532
533 fn syntax(&self, code: &str, language: &str) -> Result<Syntax, String> {
535 let mut syntax = Syntax::new(code, self.lexer.as_deref().unwrap_or(language))
536 .line_numbers(self.line_numbers)
537 .indent_guides(self.guides)
538 .word_wrap(!self.no_wrap);
539 if let Some((start, end)) = self.line_range(code)? {
540 syntax = syntax.line_range(Some(start), Some(end));
541 }
542 Ok(syntax)
543 }
544}
545
546fn python_line_count(text: &str) -> usize {
550 let mut count = 0;
551 let mut chars = text.chars().peekable();
552 let mut open = false;
553 while let Some(c) = chars.next() {
554 match c {
555 '\r' => {
556 if chars.peek() == Some(&'\n') {
557 chars.next();
558 }
559 count += 1;
560 open = false;
561 }
562 '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}'
563 | '\u{2029}' => {
564 count += 1;
565 open = false;
566 }
567 _ => open = true,
568 }
569 }
570 count + usize::from(open)
571}
572
573#[derive(Clone)]
575struct Cli {
576 mode: Mode,
577 resource: Option<String>,
578 #[cfg_attr(not(feature = "art"), allow(dead_code))]
582 resources: Vec<String>,
583 #[cfg_attr(not(feature = "art"), allow(dead_code))]
585 loops: Option<usize>,
586 #[cfg_attr(not(feature = "art"), allow(dead_code))]
588 animation: animation::AnimationControls,
589 #[cfg_attr(not(feature = "art"), allow(dead_code))]
592 diff_threshold: Option<f32>,
593 #[cfg_attr(not(feature = "art"), allow(dead_code))]
595 image_mode: ImageMode,
596 #[cfg_attr(not(feature = "art"), allow(dead_code))]
597 image_fit: Option<String>,
598 #[cfg_attr(not(feature = "art"), allow(dead_code))]
599 image_anchor: Option<String>,
600 #[cfg_attr(not(feature = "art"), allow(dead_code))]
601 image_background: Option<ImageBackdrop>,
602 #[cfg_attr(not(feature = "art"), allow(dead_code))]
603 image_color: Option<String>,
604 #[cfg_attr(not(feature = "art"), allow(dead_code))]
605 image_dither: Option<String>,
606 #[cfg_attr(not(feature = "art"), allow(dead_code))]
607 image_color_distance: Option<String>,
608 #[cfg_attr(not(feature = "art"), allow(dead_code))]
609 image_rotate: Option<u16>,
610 #[cfg_attr(not(feature = "art"), allow(dead_code))]
611 image_flip_horizontal: bool,
612 #[cfg_attr(not(feature = "art"), allow(dead_code))]
613 image_flip_vertical: bool,
614 #[cfg_attr(not(feature = "art"), allow(dead_code))]
615 image_grayscale: bool,
616 #[cfg_attr(not(feature = "art"), allow(dead_code))]
617 image_max_width: Option<usize>,
618 #[cfg_attr(not(feature = "art"), allow(dead_code))]
619 image_max_height: Option<usize>,
620 #[cfg_attr(not(feature = "art"), allow(dead_code))]
622 image_brightness: Option<f32>,
623 #[cfg_attr(not(feature = "art"), allow(dead_code))]
624 image_contrast: Option<f32>,
625 #[cfg_attr(not(feature = "art"), allow(dead_code))]
626 image_gamma: Option<f32>,
627 log_presentation: String,
628 mermaid_backend: Option<MermaidBackend>,
631 dot_backend: Option<sources::DotBackend>,
634 graph_sources: sources::GraphSourceOptions,
636 chart: chart::ChartOptions,
638 schema: sources::SchemaOptions,
640 profile: profile::ProfileOptions,
642 rules: style_rules::RuleOptions,
645 highlighter: Option<String>,
647 code_theme: Option<String>,
649 plugins: Vec<String>,
652 #[cfg_attr(not(feature = "art"), allow(dead_code))]
655 micro_project: bool,
656 theme_styles: std::collections::BTreeMap<String, Style>,
657 theme_file_styles: std::collections::BTreeMap<String, Style>,
660 #[cfg_attr(not(feature = "art"), allow(dead_code))]
663 height: Option<usize>,
664 extensions: CliExtensions,
665 width: Option<usize>,
666 justify: Option<Justify>,
667 text_justify: Option<Justify>,
671 emoji: bool,
674 soft: bool,
676 max_width: Option<usize>,
678 rule_style: Option<Style>,
680 rule_char: Option<String>,
681 force_terminal: bool,
683 source: SourceOptions,
685 no_color: bool,
686 export_html: Option<String>,
689 export_svg: Option<String>,
691 export_png: Option<String>,
694 panel: Option<BoxSet>,
698 padding: Option<(usize, usize, usize, usize)>,
700 title: Option<String>,
702 caption: Option<String>,
704 panel_style: Option<Style>,
706 style: Option<Style>,
709 expand: bool,
712 pager: bool,
714 auto_pager: bool,
715 hyperlinks: bool,
719 sanitize: bool,
721 report_format: ReportFormat,
724 watch: bool,
726 watch_interval: f64,
728 watch_cache: bool,
730 watch_debounce: f64,
733 watch_poll: bool,
735 watch_exit_on_error: bool,
737 batch: bool,
738 progress: bool,
739 continue_on_error: bool,
740 jobs: usize,
741 dry_run: bool,
742 batch_paths: batch_paths::BatchPathOptions,
743 worker_args: Vec<String>,
744 overwrite: bool,
745 collision: CollisionPolicy,
746 data: inspect::DataOptions,
747 tools: tools::ToolOptions,
748 viewers: viewers::ViewerOptions,
749 logs: logs::LogOptions,
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755enum MermaidBackend {
756 Text,
759 Mmdc,
762 Off,
764}
765
766impl MermaidBackend {
767 fn parse(value: &str) -> Result<Self, String> {
768 match value {
769 "text" => Ok(Self::Text),
770 "off" => Ok(Self::Off),
771 "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
772 "mmdc" => Err(
773 "--mermaid-backend mmdc needs a build with the mmdc feature \
774 (cargo install rs-rich-cli --features mmdc)"
775 .into(),
776 ),
777 other => Err(format!(
778 "unknown Mermaid backend {other:?} (text, mmdc, off)"
779 )),
780 }
781 }
782}
783
784#[cfg(feature = "mermaid")]
786fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
787 rich_mermaid::MermaidOptions {
788 backend: match backend {
789 MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
790 MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
791 },
792 ..rich_mermaid::MermaidOptions::default()
793 }
794}
795
796fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
799 plugin_registry_with(mermaid, true)
800}
801
802fn plugin_registry_with(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
805 let mut registry = builtin_registry(mermaid, dot);
806 plugins::add_to(&mut registry);
807 registry
808}
809
810fn builtin_registry(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
813 let mut registry = rich_ext::ExtensionRegistry::with_defaults();
814 if dot {
815 registry
816 .add_plugin(&rich_diagram::plugin::DotPlugin::default())
817 .expect("the DOT plugin registers cleanly");
818 }
819 #[cfg(feature = "mermaid")]
820 if mermaid != MermaidBackend::Off {
821 registry
822 .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
823 .expect("the Mermaid plugin registers cleanly");
824 }
825 #[cfg(not(feature = "mermaid"))]
826 let _ = mermaid;
827 #[cfg(feature = "lumis")]
828 registry
829 .add_plugin(&rich_lumis::LumisPlugin)
830 .expect("the lumis plugin registers cleanly");
831 registry
832}
833
834fn code_highlighting(
838 highlighter: Option<&str>,
839 theme: Option<&str>,
840) -> Result<Option<rich::CodeHighlighting>, String> {
841 if highlighter.is_none() && theme.is_none() {
842 return Ok(None);
843 }
844 let mut registry = plugin_registry(MermaidBackend::Off);
845 let name = highlighter.unwrap_or("syntect");
846 if let Err(error) = registry.set_default_code_highlighter(name, theme) {
847 return Err(match error {
848 rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
849 if name == "lumis" && !cfg!(feature = "lumis") =>
850 {
851 format!(
852 "{error} (lumis needs a build with the lumis feature: cargo install \
853 rs-rich-cli --features lumis)"
854 )
855 }
856 rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
857 let themes = registry
858 .code_highlighter(name)
859 .map(|engine| engine.themes())
860 .unwrap_or_default();
861 if themes.len() <= 40 {
862 format!("{error}; its themes: {}", themes.join(", "))
863 } else {
864 format!(
865 "{error}; it has {} themes, such as {}; `rich doctor --report json` \
866 lists them all",
867 themes.len(),
868 themes[..12].join(", ")
869 )
870 }
871 }
872 other => other.to_string(),
873 });
874 }
875 Ok(registry.code_highlighting())
876}
877
878fn install_render_defaults(console: &mut Console) {
888 use rich::ConsoleCodeHighlighting;
889 if let Some(highlighting) = rich_ext::ExtensionRegistry::with_defaults().code_highlighting() {
890 console.set_code_highlighting(Some(highlighting));
891 }
892}
893
894fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
896 use rich::ConsoleCodeHighlighting;
897 if let Ok(Some(highlighting)) =
898 code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
899 {
900 console.set_code_highlighting(Some(highlighting));
901 }
902}
903
904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905enum CollisionPolicy {
906 Error,
907 Overwrite,
908 Suffix,
909}
910
911fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
923 Markdown::new(source).hyperlinks(hyperlinks)
924}
925
926pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
941 dispatch(remember_raw_args(args))
942}
943
944static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
948 std::sync::Mutex::new(Vec::new());
949
950fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
959 let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
960 let args = args
961 .into_iter()
962 .map(|arg| match arg.into_string() {
963 Ok(arg) => arg,
964 Err(arg) => {
965 let spelling = escaped_path(&arg);
966 match raw.iter_mut().find(|(known, _)| *known == spelling) {
967 Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
968 Some(_) => {}
969 None => raw.push((spelling.clone(), Some(arg))),
970 }
971 spelling
972 }
973 })
974 .collect();
975 if let Ok(mut slot) = RAW_ARGS.lock() {
976 *slot = raw;
977 }
978 args
979}
980
981pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
985 RAW_ARGS
986 .lock()
987 .ok()
988 .and_then(|raw| {
989 raw.iter()
990 .find(|(spelling, _)| spelling == arg)
991 .and_then(|(_, original)| original.clone())
992 })
993 .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
994}
995
996pub(crate) fn text_arg(arg: &str) -> String {
1000 if !arg.starts_with('\0') {
1001 return arg.to_string();
1002 }
1003 RAW_ARGS
1004 .lock()
1005 .ok()
1006 .and_then(|raw| {
1007 raw.iter()
1008 .find(|(spelling, _)| spelling == arg)
1009 .and_then(|(_, original)| original.clone())
1010 })
1011 .map_or_else(
1012 || arg.to_string(),
1013 |original| original.to_string_lossy().into_owned(),
1014 )
1015}
1016
1017pub(crate) fn path_arg(path: &Path) -> String {
1025 if let Some(text) = path.to_str() {
1026 return text.to_string();
1027 }
1028 let spelling = escaped_path(path.as_os_str());
1029 if let Ok(mut raw) = RAW_ARGS.lock() {
1030 let original = path.as_os_str().to_owned();
1031 match raw.iter_mut().find(|(known, _)| *known == spelling) {
1032 Some((_, known)) if known.as_ref() != Some(&original) => *known = None,
1033 Some(_) => {}
1034 None => raw.push((spelling.clone(), Some(original))),
1035 }
1036 }
1037 spelling
1038}
1039
1040#[cfg(unix)]
1043fn escaped_path(path: &std::ffi::OsStr) -> String {
1044 use std::os::unix::ffi::OsStrExt;
1045 let mut out = String::from('\0');
1046 for chunk in path.as_bytes().utf8_chunks() {
1047 out.push_str(&chunk.valid().replace('\\', "\\x5C"));
1048 for byte in chunk.invalid() {
1049 out.push_str(&format!("\\x{byte:02X}"));
1050 }
1051 }
1052 out
1053}
1054
1055#[cfg(not(unix))]
1056fn escaped_path(path: &std::ffi::OsStr) -> String {
1057 format!("\0{}", path.to_string_lossy())
1058}
1059
1060pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
1070 let _turn = EMBEDDED_RUN.lock().unwrap_or_else(|e| e.into_inner());
1073 if let Ok(mut slot) = SELF_PROGRAM.lock() {
1074 *slot = program;
1075 }
1076 IN_HOST.store(true, std::sync::atomic::Ordering::SeqCst);
1077 let code = run(args);
1078 IN_HOST.store(false, std::sync::atomic::Ordering::SeqCst);
1079 code
1080}
1081
1082static EMBEDDED_RUN: std::sync::Mutex<()> = std::sync::Mutex::new(());
1084
1085static IN_HOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1087
1088pub(crate) fn in_host() -> bool {
1091 IN_HOST.load(std::sync::atomic::Ordering::SeqCst)
1092}
1093
1094static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
1096
1097fn dispatch(args: Vec<String>) -> ExitCode {
1098 plugins::install(Vec::new());
1101 if demo::requested(&args) {
1102 return demo::dispatch(&args);
1103 }
1104 if doctor::requested(&args) {
1105 return doctor::dispatch(&args);
1106 }
1107 if plugins::requested(&args) {
1108 return plugins::dispatch(&args);
1109 }
1110 if marketplace::requested(&args) {
1111 return marketplace::dispatch(&args);
1112 }
1113 #[cfg(feature = "art")]
1114 if banner::requested(&args) {
1115 return banner::dispatch(&args);
1116 }
1117 if shell::requested(&args) {
1118 return shell::dispatch(&args);
1119 }
1120 if spin::requested(&args) {
1121 return spin::dispatch(&args);
1122 }
1123 if console::requested(&args) {
1124 return console::dispatch(&args);
1125 }
1126 #[cfg(feature = "art")]
1127 if micro::requested(&args) {
1128 return micro::dispatch(&args);
1129 }
1130 #[cfg(feature = "interact")]
1131 if let Some(command) = interactive::requested(&args) {
1132 return interactive::dispatch(command, &args);
1133 }
1134 #[cfg(feature = "interact")]
1135 if let Some(command) = approve_jump::requested(&args) {
1136 return approve_jump::dispatch(command, &args);
1137 }
1138 #[cfg(feature = "record")]
1139 if record::requested(&args) {
1140 return record::dispatch(&args);
1141 }
1142 #[cfg(feature = "serve")]
1143 if serve::requested(&args) {
1144 return serve::dispatch(&args);
1145 }
1146 match parse(&args) {
1147 Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
1149 if let Err(message) = plugins::check_linked() {
1150 return fail(&cli, ExitClass::Usage, message);
1151 }
1152 if !cli.plugins.is_empty() || marketplace::has_installed() {
1154 match plugins::load(&cli.plugins) {
1155 Ok(loaded) => plugins::install(loaded),
1156 Err((class, message)) => return fail(&cli, class, message),
1157 }
1158 }
1159 if let Some(message) = cli
1161 .data
1162 .unknown_transform(&plugin_registry(MermaidBackend::Text))
1163 {
1164 return fail(&cli, ExitClass::Usage, message);
1165 }
1166 if cli.batch && cfg!(target_os = "wasi") {
1167 return fail(&cli, ExitClass::Usage, host::unavailable("--batch"));
1168 }
1169 if cli.batch
1170 && let Err(error) = batch::install_interrupt_handler()
1171 {
1172 return fail(
1173 &cli,
1174 ExitClass::Input,
1175 format!("cannot install batch interruption handler: {error}"),
1176 );
1177 }
1178 run_cli(cli)
1179 }
1180 Err(message) => emit_error(
1181 wants_json_report(&args),
1182 ExitClass::Usage,
1183 &format!("{message} (try --help)"),
1184 ),
1185 }
1186}
1187
1188fn self_command() -> std::io::Result<std::process::Command> {
1192 let program = SELF_PROGRAM
1193 .lock()
1194 .map(|program| program.clone())
1195 .unwrap_or_default();
1196 Ok(match program.split_first() {
1197 Some((executable, rest)) => {
1198 let mut command = std::process::Command::new(executable);
1199 command.args(rest);
1200 command
1201 }
1202 None => std::process::Command::new(std::env::current_exe()?),
1203 })
1204}
1205
1206const VALUE_OPTIONS: &[&str] = &[
1209 "--batch-input-root",
1210 "--batch-name-template",
1211 "--log-presentation",
1212 "--level",
1213 "--where",
1214 "--parser",
1215 "--time",
1216 "--output",
1217 "--mermaid-backend",
1218 "--dot-backend",
1219 "--metadata",
1220 "--why",
1221 "--package",
1222 "--timings",
1223 "--audit",
1224 "--build-profile",
1225 "--baseline",
1226 "--save",
1227 "--policy",
1228 "--sbom",
1229 "--depth",
1230 "--kind",
1231 "--x",
1232 "--y",
1233 "--sample",
1234 "--columns",
1235 "--top",
1236 "--max-paths",
1237 "--style-rule",
1238 "--rules",
1239 "--highlighter",
1240 "--code-theme",
1241 "--plugin",
1242 "-w",
1243 "--width",
1244 "-o",
1245 "--export-html",
1246 "--export-svg",
1247 "--export-png",
1248 "-a",
1249 "--panel",
1250 "-d",
1251 "--padding",
1252 "-h",
1253 "--head",
1254 "-t",
1255 "--tail",
1256 "--lexer",
1257 "-W",
1258 "--max-width",
1259 "--rule-style",
1260 "--rule-char",
1261 "--title",
1262 "--caption",
1263 "-s",
1264 "--style",
1265 "-S",
1266 "--panel-style",
1267 "--report",
1268 "--image-mode",
1269 "--image-fit",
1270 "--image-anchor",
1271 "--image-background",
1272 "--height",
1273 "--theme",
1274 "--theme-style",
1275 "--theme-file",
1276 "--image-color",
1277 "--image-dither",
1278 "--image-color-distance",
1279 "--image-rotate",
1280 "--image-max-width",
1281 "--image-max-height",
1282 "--image-brightness",
1283 "--image-contrast",
1284 "--image-gamma",
1285 "--demo-section",
1286 "--demo-delay",
1287 "--watch-interval",
1288 "--interval",
1289 "--watch-debounce",
1290 "--gif-mode",
1291 "--encoding",
1292 "--threshold",
1293 "--loop",
1294 "--speed",
1295 "--fps",
1296 "--max-frames",
1297 "--max-duration",
1298 "--jobs",
1299 "--collision",
1300 "--format",
1301 "--select",
1302 "--filter",
1303 "--highlight",
1304 "--transform",
1305 "--find",
1306 "--max-depth",
1307 "--max-length",
1308 "--compare",
1309 "--context",
1310 "--language",
1311 "--search",
1312 "--offset",
1313 "--length",
1314 "--bytes-per-line",
1315 "--group",
1316 "--limit",
1317 "--cast",
1318 "--redact-pattern",
1319 "--config",
1320 "--profile",
1321];
1322
1323fn selects_mode_explicitly(args: &[String]) -> bool {
1327 explicit_mode(args).is_some()
1328}
1329
1330fn explicit_mode(args: &[String]) -> Option<Mode> {
1332 let mut iter = args.iter();
1333 let mut seen_positional = false;
1334 while let Some(arg) = iter.next() {
1335 if arg == "--" {
1336 break;
1337 }
1338 if VALUE_OPTIONS.contains(&arg.as_str()) {
1339 iter.next();
1340 continue;
1341 }
1342 if arg.starts_with('-') && arg.len() > 1 {
1343 if let Some(flag) = mode_flag_alias(arg) {
1344 return command_mode(&flag[2..]);
1345 }
1346 continue;
1347 }
1348 if !seen_positional {
1349 seen_positional = true;
1350 if let Some(mode) = command_mode(arg) {
1351 return Some(mode);
1352 }
1353 }
1354 }
1355 None
1356}
1357
1358fn command_word(args: &[String]) -> Option<&str> {
1361 let mut iter = args.iter();
1362 while let Some(arg) = iter.next() {
1363 if arg == "--" {
1364 return None;
1365 }
1366 if VALUE_OPTIONS.contains(&arg.as_str()) {
1367 iter.next();
1368 } else if !arg.starts_with('-') || arg == "-" {
1369 return command_mode(arg).map(|_| arg.as_str());
1370 }
1371 }
1372 None
1373}
1374
1375pub(crate) fn subcommand_word(args: &[String]) -> Option<&str> {
1381 let mut iter = args.iter();
1382 while let Some(arg) = iter.next() {
1383 if arg == "--" || mode_flag_alias(arg).is_some() {
1384 return None;
1385 }
1386 if VALUE_OPTIONS.contains(&arg.as_str()) {
1387 iter.next();
1388 } else if !arg.starts_with('-') || arg == "-" {
1389 return Some(arg);
1390 }
1391 }
1392 None
1393}
1394
1395fn mode_flag_alias(arg: &str) -> Option<&'static str> {
1397 Some(match arg {
1398 "--print" | "-p" => "--print",
1399 "--markdown" | "-m" => "--markdown",
1400 "--json" | "-j" | "-J" => "--json",
1401 "--syntax" | "-x" => "--syntax",
1402 "--csv" => "--csv",
1403 "--ipynb" => "--ipynb",
1404 "--rst" => "--rst",
1405 "--org" => "--org",
1406 "--asciidoc" | "--adoc" => "--asciidoc",
1407 "--man" | "--roff" => "--man",
1408 "--jsonl" | "--ndjson" => "--jsonl",
1409 "--log" => "--log",
1410 "--rule" | "-u" => "--rule",
1411 "--image" => "--image",
1412 "--gif" => "--gif",
1413 "--diff" => "--diff",
1414 "--inspect" => "--inspect",
1415 "--ansi-explain" => "--ansi-explain",
1416 _ => return None,
1417 })
1418}
1419
1420fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
1421 let message = &controls::shown(message);
1424 if json {
1425 eprintln!(
1426 "{}",
1427 serde_json::json!({
1428 "ok": false,
1429 "code": class.name(),
1430 "exit_code": class.code(),
1431 "message": message,
1432 "error": {
1433 "message": message,
1434 },
1435 })
1436 );
1437 } else {
1438 eprintln!("rich: {message}");
1439 }
1440 class.exit_code()
1441}
1442
1443fn emit_success_report(format: ReportFormat) {
1444 if format == ReportFormat::Json {
1445 eprintln!(
1446 "{}",
1447 serde_json::json!({
1448 "ok": true,
1449 "code": ExitClass::Success.name(),
1450 "exit_code": ExitClass::Success.code(),
1451 "result": {},
1452 })
1453 );
1454 }
1455}
1456
1457fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
1458 let message = controls::shown(message.as_ref());
1459 if watch::capture_error(&message) {
1461 return class.exit_code();
1462 }
1463 emit_error(cli.report_format == ReportFormat::Json, class, &message)
1464}
1465
1466fn success(cli: &Cli) -> ExitCode {
1467 if !watch::capturing() {
1468 emit_success_report(cli.report_format);
1469 }
1470 ExitClass::Success.exit_code()
1471}
1472
1473#[cfg(test)]
1480fn glob_match(pattern: &str, text: &str) -> bool {
1481 let pattern: Vec<GlobUnit> = pattern.chars().map(GlobUnit::Char).collect();
1482 let text: Vec<GlobUnit> = text.chars().map(GlobUnit::Char).collect();
1483 glob_match_units(&pattern, &text)
1484}
1485
1486#[derive(Clone, Copy, PartialEq, Eq)]
1489enum GlobUnit {
1490 Char(char),
1491 #[cfg_attr(not(unix), allow(dead_code))]
1493 Byte(u8),
1494}
1495
1496fn glob_units(name: &std::ffi::OsStr) -> Vec<GlobUnit> {
1499 #[cfg(unix)]
1500 {
1501 use std::os::unix::ffi::OsStrExt;
1502 let mut units = Vec::new();
1503 for chunk in name.as_bytes().utf8_chunks() {
1504 units.extend(chunk.valid().chars().map(GlobUnit::Char));
1505 units.extend(chunk.invalid().iter().map(|&byte| GlobUnit::Byte(byte)));
1506 }
1507 units
1508 }
1509 #[cfg(not(unix))]
1510 {
1511 name.to_string_lossy().chars().map(GlobUnit::Char).collect()
1512 }
1513}
1514
1515fn glob_match_units(pattern: &[GlobUnit], text: &[GlobUnit]) -> bool {
1516 const ANY: GlobUnit = GlobUnit::Char('?');
1517 const STAR: GlobUnit = GlobUnit::Char('*');
1518 let (mut p, mut t) = (0usize, 0usize);
1519 let (mut star, mut retry) = (None, 0usize);
1520 while t < text.len() {
1521 if p < pattern.len() && (pattern[p] == ANY || pattern[p] == text[t]) {
1522 p += 1;
1523 t += 1;
1524 } else if p < pattern.len() && pattern[p] == STAR {
1525 star = Some(p);
1526 retry = t;
1527 p += 1;
1528 } else if let Some(open) = star {
1529 p = open + 1;
1530 retry += 1;
1531 t = retry;
1532 } else {
1533 return false;
1534 }
1535 }
1536 while p < pattern.len() && pattern[p] == STAR {
1537 p += 1;
1538 }
1539 p == pattern.len()
1540}
1541
1542fn has_glob_meta(value: &str) -> bool {
1543 value.contains('*') || value.contains('?')
1544}
1545
1546fn split_glob(resource: &str) -> (PathBuf, String) {
1548 let meta = resource.find(['*', '?']).unwrap_or(0);
1549 match resource[..meta].rfind(['/', '\\']) {
1550 Some(cut) => (
1551 PathBuf::from(&resource[..cut]),
1552 resource[cut + 1..].to_string(),
1553 ),
1554 None => (PathBuf::from("."), resource.to_string()),
1555 }
1556}
1557
1558fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1566 let mut stack = vec![dir.to_path_buf()];
1567 while let Some(current) = stack.pop() {
1568 batch::check_interrupted()?;
1569 let entries = std::fs::read_dir(¤t)
1570 .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1571 for entry in entries {
1572 batch::check_interrupted()?;
1573 let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1574 let path = entry.path();
1575 let kind = std::fs::symlink_metadata(&path)
1576 .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1577 .file_type();
1578 if kind.is_symlink() {
1579 continue;
1580 }
1581 if kind.is_dir() {
1582 stack.push(path);
1583 } else if kind.is_file() {
1584 out.push(path_arg(&path));
1585 }
1586 }
1587 }
1588 Ok(())
1589}
1590
1591fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1592 let mut out = Vec::new();
1593 for resource in resources {
1594 batch::check_interrupted()?;
1595 if resource == "-" || is_url(resource) {
1596 return Err("batch resources must be local files or directories".into());
1597 }
1598 if has_glob_meta(resource) {
1599 let raw = fs_path(resource);
1603 let escaped = raw.as_os_str() != std::ffi::OsStr::new(resource);
1604 let (dir, pattern) = if escaped {
1605 let parent = raw.parent().unwrap_or(Path::new(""));
1606 if has_glob_meta(&parent.to_string_lossy()) {
1607 return Err(format!(
1608 "batch glob {} may only use * and ? in the final path segment",
1609 controls::shown(resource)
1610 ));
1611 }
1612 let dir = if parent.as_os_str().is_empty() {
1613 PathBuf::from(".")
1614 } else {
1615 parent.to_path_buf()
1616 };
1617 (dir, glob_units(raw.file_name().unwrap_or_default()))
1618 } else {
1619 let (dir, pattern) = split_glob(resource);
1620 if pattern.contains('/') || pattern.contains('\\') {
1621 return Err(format!(
1622 "batch glob {resource:?} may only use * and ? in the final path segment"
1623 ));
1624 }
1625 (dir, pattern.chars().map(GlobUnit::Char).collect())
1626 };
1627 let entries = std::fs::read_dir(&dir)
1628 .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1629 for entry in entries {
1630 batch::check_interrupted()?;
1631 let entry =
1632 entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1633 let path = entry.path();
1634 if !path.is_file() {
1635 continue;
1636 }
1637 if glob_match_units(&pattern, &glob_units(&entry.file_name())) {
1638 out.push(path_arg(&path));
1639 }
1640 }
1641 continue;
1642 }
1643 let path = fs_path(resource);
1644 if path.is_file() {
1645 out.push(resource.clone());
1646 } else if path.is_dir() {
1647 collect_files(&path, &mut out)?;
1648 } else {
1649 return Err(format!("batch resource does not exist: {resource}"));
1650 }
1651 }
1652 batch::check_interrupted()?;
1653 out.sort();
1654 out.dedup();
1655 batch::check_interrupted()?;
1656 if out.is_empty() {
1657 return Err("batch plan contains no files".into());
1658 }
1659 Ok(out)
1660}
1661
1662#[derive(Default, Clone)]
1668struct BatchOutputs {
1669 html: Option<String>,
1670 svg: Option<String>,
1671}
1672
1673fn batch_export_path(
1674 path: Option<&str>,
1675 input: &str,
1676 index: usize,
1677 total: usize,
1678) -> Option<String> {
1679 let path = path?;
1680 if total == 1 {
1681 return Some(path.into());
1682 }
1683 let p = fs_path(path);
1684 let input = fs_path(input);
1686 let stem = input
1687 .file_stem()
1688 .unwrap_or_else(|| std::ffi::OsStr::new("output"));
1689 let mut name = stem.to_owned();
1690 match p.extension() {
1691 Some(ext) if !ext.is_empty() => {
1692 name.push(".");
1693 name.push(ext);
1694 }
1695 _ => name.push(format!("-{index}")),
1696 }
1697 Some(path_arg(&if p.is_dir() {
1698 p.join(name)
1699 } else {
1700 p.with_file_name(name)
1701 }))
1702}
1703
1704fn suffixed_path(path: &Path, suffix: usize) -> String {
1706 let mut name = path
1707 .file_stem()
1708 .unwrap_or_else(|| std::ffi::OsStr::new("output"))
1709 .to_owned();
1710 name.push(format!("-{suffix}"));
1711 if let Some(ext) = path.extension() {
1712 name.push(".");
1713 name.push(ext);
1714 }
1715 path_arg(&path.with_file_name(name))
1716}
1717
1718fn resolve_destination(
1724 cli: &Cli,
1725 candidate: String,
1726 taken: &mut std::collections::BTreeSet<String>,
1727) -> Result<String, (ExitClass, String)> {
1728 let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1729 taken.contains(path) || (!cli.overwrite && fs_path(path).exists())
1730 };
1731 match cli.collision {
1732 CollisionPolicy::Overwrite => {
1733 taken.insert(candidate.clone());
1734 Ok(candidate)
1735 }
1736 CollisionPolicy::Suffix => {
1737 let mut chosen = candidate.clone();
1738 let mut suffix = 2;
1739 while occupied(&chosen, taken) {
1740 batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1741 chosen = suffixed_path(&fs_path(&candidate), suffix);
1742 suffix += 1;
1743 }
1744 taken.insert(chosen.clone());
1745 Ok(chosen)
1746 }
1747 CollisionPolicy::Error => {
1748 if taken.contains(&candidate) {
1749 return Err((
1750 ExitClass::Usage,
1751 format!("batch output collision: {candidate}"),
1752 ));
1753 }
1754 if !cli.overwrite && fs_path(&candidate).exists() {
1755 return Err((
1756 ExitClass::Input,
1757 format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1758 ));
1759 }
1760 taken.insert(candidate.clone());
1761 Ok(candidate)
1762 }
1763 }
1764}
1765
1766fn worker_args(args: &[String]) -> Vec<String> {
1769 let mut result = vec!["--no-config".into()];
1770 let mut iter = args.iter();
1771 let mut seen_resource = false;
1772 let mut selected_mode = false;
1773 while let Some(arg) = iter.next() {
1774 if arg == "--" {
1775 break;
1776 }
1777 if VALUE_OPTIONS.contains(&arg.as_str()) {
1778 let value = iter.next();
1779 if !matches!(
1780 arg.as_str(),
1781 "--jobs"
1782 | "--batch-input-root"
1783 | "--batch-name-template"
1784 | "--collision"
1785 | "--report"
1786 | "-o"
1787 | "--export-html"
1788 | "--export-svg"
1789 | "--config"
1790 | "--profile"
1791 ) {
1792 result.push(arg.clone());
1793 if let Some(value) = value {
1794 result.push(value.clone());
1795 }
1796 }
1797 } else if arg.starts_with('-') && arg != "-" {
1798 selected_mode |= mode_flag_alias(arg).is_some();
1799 if !matches!(
1800 arg.as_str(),
1801 "--batch"
1802 | "--batch-preserve-dirs"
1803 | "--no-batch-preserve-dirs"
1804 | "--no-batch"
1805 | "--dry-run"
1806 | "--progress"
1807 | "--no-progress"
1808 | "--continue-on-error"
1809 | "--no-continue-on-error"
1810 | "--overwrite"
1811 | "--no-overwrite"
1812 | "--machine-json"
1813 ) {
1814 result.push(arg.clone());
1815 }
1816 } else if !seen_resource {
1817 if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1818 selected_mode = true;
1819 result.push(format!("--{}", mode_name(mode)));
1820 } else {
1821 seen_resource = true;
1822 }
1823 }
1824 }
1825 result
1826}
1827
1828fn wants_json_report(args: &[String]) -> bool {
1829 let mut format = ReportFormat::Human;
1830 let mut iter = args.iter();
1831 while let Some(arg) = iter.next() {
1832 match arg.as_str() {
1833 "--report" => {
1834 if let Some(value) = iter.next() {
1835 let _ = format.apply_option("--report", Some(value));
1836 }
1837 }
1838 "--machine-json" => {
1839 let _ = format.apply_option("--machine-json", None);
1840 }
1841 "--" => break,
1842 _ => {}
1843 }
1844 }
1845 format == ReportFormat::Json
1846}
1847
1848fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1860 let name = name.to_ascii_lowercase();
1861 if name == "none" {
1862 return Ok(None);
1863 }
1864 PANEL_BOXES
1865 .iter()
1866 .find(|(known, _)| *known == name)
1867 .map(|(_, set)| Some(*set))
1868 .ok_or_else(|| {
1869 let names: Vec<&str> = PANEL_BOXES.iter().map(|(known, _)| *known).collect();
1870 format!(
1871 "unknown panel box {name:?} (use none or {})",
1872 names.join(", ")
1873 )
1874 })
1875}
1876
1877pub(crate) const PANEL_BOXES: &[(&str, BoxSet)] = {
1879 use rich::r#box as b;
1880 &[
1881 ("ascii", b::ASCII),
1882 ("ascii2", b::ASCII2),
1883 ("square", b::SQUARE),
1884 ("rounded", b::ROUNDED),
1885 ("heavy", b::HEAVY),
1886 ("double", b::DOUBLE),
1887 ("ascii_double_head", b::ASCII_DOUBLE_HEAD),
1888 ("square_double_head", b::SQUARE_DOUBLE_HEAD),
1889 ("minimal", b::MINIMAL),
1890 ("minimal_heavy_head", b::MINIMAL_HEAVY_HEAD),
1891 ("minimal_double_head", b::MINIMAL_DOUBLE_HEAD),
1892 ("simple", b::SIMPLE),
1893 ("simple_head", b::SIMPLE_HEAD),
1894 ("simple_heavy", b::SIMPLE_HEAVY),
1895 ("horizontals", b::HORIZONTALS),
1896 ("heavy_edge", b::HEAVY_EDGE),
1897 ("heavy_head", b::HEAVY_HEAD),
1898 ("double_edge", b::DOUBLE_EDGE),
1899 ("markdown", b::MARKDOWN),
1900 ]
1901};
1902
1903fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1907 let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1908 let parts: Result<Vec<usize>, _> = value
1909 .split(',')
1910 .map(|p| p.trim().parse::<usize>())
1911 .collect();
1912 let parts = parts.map_err(|_| error())?;
1913 match parts.as_slice() {
1914 [p] => Ok((*p, *p, *p, *p)),
1915 [v, h] => Ok((*v, *h, *v, *h)),
1916 [t, r, b, l] => Ok((*t, *r, *b, *l)),
1917 _ => Err(error()),
1918 }
1919}
1920
1921fn mode_name(mode: Mode) -> &'static str {
1923 MODE_SPECS
1924 .iter()
1925 .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1926 .unwrap_or("unknown")
1927}
1928
1929fn command_mode(command: &str) -> Option<Mode> {
1930 MODE_SPECS
1931 .iter()
1932 .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1933}
1934
1935fn parse_lines(flag: &str, value: Option<&String>) -> Result<usize, String> {
1937 let flag = match flag {
1938 "-h" | "--head" => "--head",
1939 _ => "--tail",
1940 };
1941 let value = value.ok_or_else(|| format!("{flag} requires a number of LINES"))?;
1942 match value.parse::<i64>() {
1943 Ok(lines) if lines >= 1 => Ok(lines as usize),
1944 Ok(lines) => Err(format!("{flag}: {lines} is not in the range x>=1")),
1945 Err(_) => Err(format!("{flag}: {value:?} is not a valid integer")),
1946 }
1947}
1948
1949fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1950 if *current != Mode::Auto && *current != mode {
1951 return Err(format!(
1952 "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1953 ));
1954 }
1955 *current = mode;
1956 Ok(())
1957}
1958
1959fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1961 let roots = ConfigRoots::default();
1962 if authoring::dispatch(args)? {
1963 return Ok(None);
1964 }
1965 if tools::bench_dispatch(args)? {
1966 return Ok(None);
1967 }
1968 if let Some(output) = config::inspect(args, &roots)? {
1969 authoring::out(&format!("{output}\n"));
1970 return Ok(None);
1971 }
1972 let merged = config_args(args, &roots)?;
1973 parse_inner(&merged)
1974}
1975
1976fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1977 let mut mode = Mode::Auto;
1978 let mut resources: Vec<String> = Vec::new();
1979 let mut loops = None;
1980 let mut animation = animation::AnimationControls::default();
1981 let mut diff_threshold = None;
1982 let mut image_mode = ImageMode::Auto;
1983 let mut image_fit = None;
1984 let mut image_anchor = None;
1985 let mut image_background = None;
1986 let mut image_color = None;
1987 let mut image_dither = None;
1988 let mut image_color_distance = None;
1989 let mut image_rotate = None;
1990 let mut image_flip_horizontal = false;
1991 let mut image_flip_vertical = false;
1992 let mut image_grayscale = false;
1993 let mut image_max_width = None;
1994 let mut image_max_height = None;
1995 let mut image_brightness = None;
1996 let mut image_contrast = None;
1997 let mut image_gamma = None;
1998 let mut log_presentation = String::from("plain");
1999 let mut mermaid_backend = None;
2000 let mut dot_backend = None;
2001 let mut graph_sources = sources::GraphSourceOptions::default();
2002 let mut chart = chart::ChartOptions::default();
2003 let mut schema_options = sources::SchemaOptions::default();
2004 let mut profile_options = profile::ProfileOptions::default();
2005 let mut rule_options = style_rules::RuleOptions::default();
2006 let mut highlighter: Option<String> = None;
2007 let mut plugins: Vec<String> = Vec::new();
2008 let mut code_theme: Option<String> = None;
2009 let mut theme_styles = std::collections::BTreeMap::new();
2010 let mut theme_file_styles = std::collections::BTreeMap::new();
2011 let mut height = None;
2012 let mut extensions = CliExtensions::default();
2013 let mut data = inspect::DataOptions::default();
2014 let mut tool_options = tools::ToolOptions::default();
2015 let mut viewer_options = viewers::ViewerOptions::default();
2016 let mut log_options = logs::LogOptions::default();
2017 let mut paging_explicit = false;
2019 let mut ansi_word = false;
2021 let mut width = None;
2022 let mut justify = None;
2023 let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
2024 let mut export_html: Option<String> = None;
2025 let mut export_svg: Option<String> = None;
2026 let mut export_png: Option<String> = None;
2027 let mut panel = None;
2028 let mut padding = None;
2029 let mut title = None;
2030 let mut caption = None;
2031 let mut panel_style = None;
2032 let mut style = None;
2033 let mut expand = false;
2034 let mut pager = false;
2035 let mut auto_pager = false;
2036 let mut hyperlinks = false;
2037 let mut source = SourceOptions::default();
2038 let mut text_flags = [false; 4];
2041 let mut emoji = false;
2042 let mut soft = false;
2043 let mut max_width = None;
2044 let mut rule_style = None;
2045 let mut rule_char = None;
2046 let mut force_terminal = false;
2047 let mut sanitize = None;
2050 let mut report_format = ReportFormat::Human;
2051 let mut watch = false;
2052 let mut watch_interval = 1.0;
2053 let mut watch_cache = false;
2054 let mut watch_debounce: Option<f64> = None;
2055 let mut watch_poll = false;
2056 let mut watch_exit_on_error = false;
2057 let mut batch = false;
2058 let mut progress = true;
2059 let mut micro_project = false;
2060 let mut continue_on_error = false;
2061 let mut jobs = 1usize;
2062 let mut dry_run = false;
2063 let mut batch_paths = batch_paths::BatchPathOptions::default();
2064 let mut overwrite = false;
2065 let mut collision = CollisionPolicy::Error;
2066 let mut end_of_options = false;
2071 let mut document_format: Option<(String, Mode)> = None;
2074
2075 let deps_mode = explicit_mode(args) == Some(Mode::Deps);
2078 let mut iter = args.iter();
2079 while let Some(arg) = iter.next() {
2080 if end_of_options {
2081 resources.push(arg.to_string());
2082 continue;
2083 }
2084 if extensions.parse_option(arg, &mut iter)? {
2085 continue;
2086 }
2087 if arg == "--format" {
2088 let named = iter
2089 .clone()
2090 .next()
2091 .and_then(|value| documents::format_mode(value).map(|mode| (value.clone(), mode)));
2092 if let Some(named) = named {
2093 iter.next();
2094 document_format = Some(named);
2095 data.format = None;
2096 continue;
2097 }
2098 document_format = None;
2099 }
2100 if data
2101 .parse_option(arg, &mut iter)
2102 .map_err(|err| match arg.as_str() {
2103 "--format" => documents::FORMAT_USAGE.to_string(),
2104 _ => err,
2105 })?
2106 {
2107 continue;
2108 }
2109 if tool_options.parse_option(arg, &mut iter)? {
2110 continue;
2111 }
2112 if log_options.parse_option(arg, &mut iter)? {
2115 continue;
2116 }
2117 if viewer_options.parse_option(arg, &mut iter)? {
2118 continue;
2119 }
2120 if graph_sources.parse_option(arg, &mut iter, deps_mode)? {
2121 continue;
2122 }
2123 if chart.parse_option(arg, &mut iter)? {
2124 continue;
2125 }
2126 if schema_options.parse_option(arg) {
2127 continue;
2128 }
2129 if profile_options.parse_option(arg, &mut iter)? {
2130 continue;
2131 }
2132 if rule_options.parse_option(arg, &mut iter)? {
2133 continue;
2134 }
2135 if matches!(
2136 arg.as_str(),
2137 "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
2138 ) {
2139 paging_explicit = true;
2140 }
2141 match arg.as_str() {
2142 "--" => end_of_options = true,
2143 "--help" => {
2145 let no_color = no_color || cli_spec::no_color_requested(args);
2146 let help = command_word(args)
2148 .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
2149 match help {
2150 Some(help) => authoring::out(&format!("{help}\n")),
2151 None => print_help(no_color),
2152 }
2153 return Ok(None);
2154 }
2155 "-V" | "-v" | "--version" => {
2156 println!("rich (rs-rich-cli) {VERSION}");
2157 return Ok(None);
2158 }
2159 "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
2160 "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
2161 "-j" | "-J" | "--json" => set_mode(&mut mode, Mode::Json)?,
2164 "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
2165 "--csv" => set_mode(&mut mode, Mode::Csv)?,
2166 "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
2167 "--rst" => set_mode(&mut mode, Mode::Rst)?,
2168 "--org" => set_mode(&mut mode, Mode::Org)?,
2169 "--asciidoc" | "--adoc" => set_mode(&mut mode, Mode::AsciiDoc)?,
2170 "--man" | "--roff" => set_mode(&mut mode, Mode::Man)?,
2171 "--gif" => set_mode(&mut mode, Mode::Gif)?,
2172 "--diff" => set_mode(&mut mode, Mode::Diff)?,
2173 "--image" => set_mode(&mut mode, Mode::Image)?,
2174 "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
2175 "--log" => set_mode(&mut mode, Mode::Log)?,
2176 "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
2177 "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
2178 "--highlighter" => {
2179 let value = iter
2180 .next()
2181 .ok_or("--highlighter requires a code highlighter's name")?;
2182 highlighter = Some(value.clone());
2183 }
2184 "--code-theme" => {
2185 let value = iter.next().ok_or("--code-theme requires a theme name")?;
2186 code_theme = Some(value.clone());
2187 }
2188 "--plugin" => {
2189 let path = iter.next().ok_or("--plugin requires PATH")?;
2190 plugins.push(path.clone());
2191 }
2192 "--mermaid-backend" => {
2193 let value = iter
2194 .next()
2195 .ok_or("--mermaid-backend requires text, mmdc or off")?;
2196 mermaid_backend = Some(MermaidBackend::parse(value)?);
2197 }
2198 "--dot-backend" => {
2199 let value = iter
2200 .next()
2201 .ok_or("--dot-backend requires text, graphviz or off")?;
2202 dot_backend = Some(sources::DotBackend::parse(value)?);
2203 }
2204 "--log-presentation" => {
2205 let value = iter
2206 .next()
2207 .ok_or("--log-presentation requires plain or rich")?;
2208 if !matches!(value.as_str(), "plain" | "rich") {
2209 return Err("--log-presentation requires plain or rich".into());
2210 }
2211 log_presentation = value.clone();
2212 }
2213 "--report" => {
2214 let value = iter.next().map(String::as_str);
2215 report_format.apply_option("--report", value)?;
2216 }
2217 "--machine-json" => {
2218 report_format.apply_option("--machine-json", None)?;
2219 }
2220 "--progress" => progress = true,
2221 "--no-progress" => progress = false,
2222 "--micro-project" => micro_project = true,
2223 "--no-micro-project" => micro_project = false,
2224 "--theme-style" => {
2225 let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
2226 let (name, value) = binding
2227 .split_once('=')
2228 .ok_or("--theme-style requires NAME=STYLE")?;
2229 config::validate_theme_binding(name, value)?;
2230 let style = Style::parse(value)
2231 .map_err(|error| format!("invalid theme style {name}: {error}"))?;
2232 theme_styles.insert(name.to_owned(), style);
2233 }
2234 "--theme-file" => {
2235 let path = iter.next().ok_or("--theme-file requires PATH")?;
2236 theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
2237 }
2238 "--image-color" => {
2239 const USAGE: &str =
2240 "--image-color requires truecolor, ansi256, ansi16 or grayscale";
2241 let value = iter.next().ok_or(USAGE)?;
2242 if !matches!(
2243 value.as_str(),
2244 "truecolor" | "ansi256" | "ansi16" | "grayscale"
2245 ) {
2246 return Err(USAGE.into());
2247 }
2248 image_color = Some(value.clone());
2249 }
2250 flag @ ("--image-max-width" | "--image-max-height") => {
2251 let usage = format!("{flag} requires a positive integer");
2252 let value = iter
2253 .next()
2254 .and_then(|v| v.parse::<usize>().ok())
2255 .filter(|v| *v > 0)
2256 .ok_or(usage)?;
2257 if flag == "--image-max-width" {
2258 image_max_width = Some(value);
2259 } else {
2260 image_max_height = Some(value);
2261 }
2262 }
2263 flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
2264 let gamma = flag == "--image-gamma";
2265 let usage = if gamma {
2266 format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
2267 } else {
2268 format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
2269 };
2270 let value = iter
2271 .next()
2272 .and_then(|v| v.parse::<f32>().ok())
2273 .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
2274 .ok_or(usage)?;
2275 match flag {
2276 "--image-brightness" => image_brightness = Some(value),
2277 "--image-contrast" => image_contrast = Some(value),
2278 _ => image_gamma = Some(value),
2279 }
2280 }
2281 "--image-flip-horizontal" => image_flip_horizontal = true,
2282 "--no-image-flip-horizontal" => image_flip_horizontal = false,
2283 "--image-flip-vertical" => image_flip_vertical = true,
2284 "--no-image-flip-vertical" => image_flip_vertical = false,
2285 "--image-grayscale" => image_grayscale = true,
2286 "--no-image-grayscale" => image_grayscale = false,
2287 "--image-rotate" => {
2288 let value = iter
2289 .next()
2290 .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
2291 image_rotate = Some(match value.as_str() {
2292 "0" => 0,
2293 "90" => 90,
2294 "180" => 180,
2295 "270" => 270,
2296 _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
2297 });
2298 }
2299 "--image-dither" => {
2300 const USAGE: &str =
2301 "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
2302 let value = iter.next().ok_or(USAGE)?;
2303 if !matches!(
2304 value.as_str(),
2305 "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
2306 ) {
2307 return Err(USAGE.into());
2308 }
2309 image_dither = Some(value.clone());
2310 }
2311 "--image-color-distance" => {
2312 const USAGE: &str = "--image-color-distance requires rgb or oklab";
2313 let value = iter.next().ok_or(USAGE)?;
2314 if !matches!(value.as_str(), "rgb" | "oklab") {
2315 return Err(USAGE.into());
2316 }
2317 image_color_distance = Some(value.clone());
2318 }
2319 "--image-mode" => {
2320 let value = iter.next().ok_or(
2321 "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
2322 )?;
2323 image_mode = value.parse()?;
2324 }
2325 "--image-fit" => {
2326 let value = iter
2327 .next()
2328 .ok_or("--image-fit requires contain, cover, stretch or native")?;
2329 if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
2330 return Err("--image-fit requires contain, cover, stretch or native".into());
2331 }
2332 image_fit = Some(value.clone());
2333 }
2334 "--image-anchor" => {
2335 let value = iter.next().ok_or("--image-anchor requires a position")?;
2336 if !matches!(
2337 value.as_str(),
2338 "center"
2339 | "top"
2340 | "bottom"
2341 | "left"
2342 | "right"
2343 | "top-left"
2344 | "top-right"
2345 | "bottom-left"
2346 | "bottom-right"
2347 ) {
2348 return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
2349 }
2350 image_anchor = Some(value.clone());
2351 }
2352 "--image-background" => {
2353 const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
2354 let value = iter.next().ok_or(USAGE)?;
2355 image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
2356 }
2357 "--height" => {
2358 let value = iter.next().ok_or("--height requires a number")?;
2359 let rows: usize = value
2360 .parse()
2361 .map_err(|_| format!("invalid height '{value}'"))?;
2362 if rows == 0 {
2363 return Err("--height must be at least 1".into());
2364 }
2365 height = Some(rows);
2366 }
2367 "--threshold" => {
2368 let value = iter
2369 .next()
2370 .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
2371 let percent: f32 = value
2372 .parse()
2373 .map_err(|_| format!("invalid threshold '{value}'"))?;
2374 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
2379 return Err(format!(
2380 "threshold must be a percentage between 0 and 100, got '{value}'"
2381 ));
2382 }
2383 diff_threshold = Some(percent);
2384 }
2385 "--loop" => {
2386 let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
2387 loops = Some(
2388 value
2389 .parse()
2390 .map_err(|_| format!("invalid loop count {value:?}"))?,
2391 );
2392 }
2393 "--speed" | "--fps" | "--max-frames" | "--max-duration" => {
2394 let value = iter
2395 .next()
2396 .ok_or_else(|| format!("{arg} requires a value"))?;
2397 animation.set(arg, value)?;
2398 }
2399 "-u" | "--rule" => set_mode(&mut mode, Mode::Rule)?,
2400 "-l" | "--left" => justify = Some(Justify::Left),
2401 "-r" | "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
2402 "-r" | "--right" => {}
2403 "-c" | "--center" if justify.is_none() => justify = Some(Justify::Center),
2404 "-c" | "--center" => {}
2405 "-L" | "--text-left" => text_flags[0] = true,
2406 "-R" | "--text-right" => text_flags[1] = true,
2407 "-C" | "--text-center" => text_flags[2] = true,
2408 "-F" | "--text-full" => text_flags[3] = true,
2409 "-h" | "--head" => source.head = Some(parse_lines(arg, iter.next())?),
2410 "-t" | "--tail" => source.tail = Some(parse_lines(arg, iter.next())?),
2411 "-n" | "--line-numbers" => source.line_numbers = true,
2412 "-g" | "--guides" => source.guides = true,
2413 "--lexer" => {
2414 source.lexer = Some(iter.next().ok_or("--lexer requires a LEXER")?.clone());
2415 }
2416 "--no-wrap" => source.no_wrap = true,
2417 "--infer" => source.infer = true,
2418 "--emoji" => emoji = true,
2419 "--soft" => soft = true,
2420 "-W" | "--max-width" => {
2421 let value = iter.next().ok_or("--max-width requires a SIZE")?;
2422 let size: i64 = value
2424 .parse()
2425 .map_err(|_| format!("invalid --max-width '{value}'"))?;
2426 max_width = usize::try_from(size).ok().filter(|size| *size > 0);
2427 }
2428 "--rule-style" => {
2429 let value = iter.next().ok_or("--rule-style requires a STYLE")?;
2430 rule_style = Some(
2431 Style::parse(value).map_err(|e| format!("unable to parse rule style: {e}"))?,
2432 );
2433 }
2434 "--rule-char" => {
2435 rule_char = Some(
2436 iter.next()
2437 .ok_or("--rule-char requires a CHARACTER")?
2438 .clone(),
2439 );
2440 }
2441 "--force-terminal" => force_terminal = true,
2442 "--no-color" => no_color = true,
2443 "--pager" => {
2444 pager = true;
2445 auto_pager = false;
2446 }
2447 "--auto-pager" => {
2448 auto_pager = true;
2449 pager = false;
2450 }
2451 "--no-pager" => {
2452 pager = false;
2453 auto_pager = false;
2454 }
2455 "--no-auto-pager" => auto_pager = false,
2456 "--color" => no_color = false,
2457 "--no-watch" => watch = false,
2458 "--no-watch-cache" => watch_cache = false,
2459 "--no-watch-poll" => watch_poll = false,
2460 "--no-watch-exit-on-error" => watch_exit_on_error = false,
2461 "--no-batch" => batch = false,
2462 "--no-continue-on-error" => continue_on_error = false,
2463 "--no-overwrite" => {
2464 overwrite = false;
2465 collision = CollisionPolicy::Error;
2466 }
2467 "--no-sanitize" => sanitize = Some(false),
2468 "--dry-run" => dry_run = true,
2469 "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
2470 "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
2471 "--batch-input-root" => {
2472 batch_paths.input_root = Some(fs_path(
2473 iter.next().ok_or("--batch-input-root requires a path")?,
2474 ))
2475 }
2476 "--batch-name-template" => {
2477 batch_paths.template = Some(
2478 batch_paths::FilenameTemplate::parse(
2479 iter.next()
2480 .ok_or("--batch-name-template requires a template")?,
2481 )
2482 .map_err(|e| e.to_string())?,
2483 )
2484 }
2485 "--watch" => watch = true,
2486 "--watch-cache" => watch_cache = true,
2487 "--watch-poll" => watch_poll = true,
2488 "--watch-exit-on-error" => watch_exit_on_error = true,
2489 "--watch-debounce" => {
2490 let value = iter
2491 .next()
2492 .ok_or("--watch-debounce requires seconds (0 or more)")?;
2493 let seconds = value
2494 .parse::<f64>()
2495 .map_err(|_| format!("invalid watch debounce {value:?}"))?;
2496 if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
2497 return Err("--watch-debounce must be between 0 and 3600 seconds".into());
2498 }
2499 watch_debounce = Some(seconds);
2500 }
2501 "--watch-interval" | "--interval" => {
2502 let value = iter
2503 .next()
2504 .ok_or("--watch-interval requires seconds greater than zero")?;
2505 watch_interval = value
2506 .parse::<f64>()
2507 .map_err(|_| format!("invalid watch interval {value:?}"))?;
2508 if !watch_interval.is_finite() || watch_interval <= 0.0 || watch_interval > 86400.0
2509 {
2510 return Err(
2511 "--watch-interval must be greater than zero and at most 86400 seconds"
2512 .into(),
2513 );
2514 }
2515 }
2516 "--batch" => batch = true,
2517 "--continue-on-error" => continue_on_error = true,
2518 "--overwrite" => {
2519 overwrite = true;
2520 collision = CollisionPolicy::Overwrite;
2521 }
2522 "--collision" => {
2523 collision = match iter
2524 .next()
2525 .ok_or("--collision requires error, overwrite, or suffix")?
2526 .as_str()
2527 {
2528 "error" => CollisionPolicy::Error,
2529 "overwrite" => CollisionPolicy::Overwrite,
2530 "suffix" => CollisionPolicy::Suffix,
2531 other => return Err(format!("unknown collision policy {other:?}")),
2532 };
2533 overwrite = collision == CollisionPolicy::Overwrite;
2534 }
2535 "--jobs" => {
2536 jobs = iter
2537 .next()
2538 .ok_or("--jobs requires a positive number")?
2539 .parse()
2540 .map_err(|_| "--jobs requires a positive number".to_string())?;
2541 if jobs == 0 {
2542 return Err("--jobs must be at least 1".into());
2543 }
2544 }
2545 "--sanitize" => sanitize = Some(true),
2546 "-y" | "--hyperlinks" => hyperlinks = true,
2550 "--export-html" | "-o" => {
2554 export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
2555 }
2556 "--export-svg" => {
2557 export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
2558 }
2559 "--export-png" => {
2560 export_png = Some(iter.next().ok_or("--export-png requires a PATH")?.clone());
2561 }
2562 "-a" | "--panel" => {
2563 let value = iter.next().ok_or("--panel requires a box name")?;
2564 panel = parse_box(value)?;
2565 }
2566 "-d" | "--padding" => {
2567 let value = iter.next().ok_or("--padding requires a value")?;
2568 padding = Some(parse_padding(value)?);
2569 }
2570 "-e" | "--expand" => expand = true,
2571 "--title" => {
2572 title = Some(iter.next().ok_or("--title requires a value")?.clone());
2573 }
2574 "--caption" => {
2575 caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
2576 }
2577 "-s" | "--style" => {
2582 let value = iter.next().ok_or("--style requires a value")?;
2583 style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
2584 }
2585 "-S" | "--panel-style" => {
2586 let value = iter.next().ok_or("--panel-style requires a value")?;
2587 panel_style =
2588 Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
2589 }
2590 "-w" | "--width" => {
2591 let value = iter.next().ok_or("--width requires a number")?;
2592 let columns: usize = value.parse().map_err(|_| {
2593 if std::path::Path::new(value).exists() {
2597 format!("--width needs a number, but got the file '{value}' — a value is missing")
2598 } else {
2599 format!("invalid width '{value}'")
2600 }
2601 })?;
2602 if columns == 0 {
2603 return Err("--width must be at least 1".into());
2604 }
2605 width = Some(columns);
2606 }
2607 other if other.starts_with('-') && other != "-" => {
2608 return Err(format!("unknown option {other:?}"));
2609 }
2610 other
2611 if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
2612 {
2613 ansi_word = other == "ansi";
2614 set_mode(&mut mode, command_mode(other).expect("checked above"))?;
2615 }
2616 "explain" if ansi_word && resources.is_empty() => ansi_word = false,
2617 other => resources.push(other.to_string()),
2618 }
2619 }
2620
2621 if let Some((name, document)) = document_format {
2625 if mode != Mode::Auto && mode != document {
2626 return Err(format!(
2627 "--format {name} cannot be combined with --{}",
2628 mode_name(mode)
2629 ));
2630 }
2631 mode = document;
2632 }
2633 if data.format.is_some() {
2636 if !matches!(mode, Mode::Auto | Mode::Inspect) {
2637 return Err(format!(
2638 "--format cannot be combined with --{}",
2639 mode_name(mode)
2640 ));
2641 }
2642 if mode == Mode::Auto && resources.is_empty() && !batch {
2643 resources.push("-".into());
2644 }
2645 }
2646 if let Some(flag) = data
2647 .inspect_only_option(matches!(mode, Mode::Capture | Mode::Log))
2648 .filter(|_| mode != Mode::Inspect)
2649 {
2650 if flag == "--redact" {
2651 return Err(
2652 "--redact only has an effect with --inspect, `rich capture` or `rich log`".into(),
2653 );
2654 }
2655 return Err(format!("{flag} only has an effect with --inspect"));
2656 }
2657 if let Some(flag) = data.transform_option() {
2658 if !transforms_apply(mode) {
2659 return Err(format!(
2660 "{flag} only has an effect on {}",
2661 transform_modes(flag)
2662 ));
2663 }
2664 data.check_transforms(mode == Mode::Inspect)?;
2665 }
2666 if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
2667 return Err(format!("{flag} only has an effect with --diff"));
2668 }
2669 if let Some(flag) = tool_options
2670 .ansi_option()
2671 .filter(|_| mode != Mode::AnsiExplain)
2672 {
2673 return Err(format!("{flag} only has an effect with --ansi-explain"));
2674 }
2675 if mode == Mode::Log {
2676 if viewer_options
2678 .given()
2679 .iter()
2680 .any(|(flag, _)| *flag == "--group")
2681 {
2682 return Err("--group requires trace, span or correlation with `rich log`".into());
2683 }
2684 log_options.validate(resources.first().map(String::as_str))?;
2685 if log_options.follows() && (watch || batch) {
2686 return Err("--follow cannot be combined with --watch or --batch".into());
2687 }
2688 }
2689 if mode == Mode::Profile
2692 && let Some(depth) = graph_sources.take_depth()
2693 {
2694 profile_options.set_depth(depth)?;
2695 }
2696 for (flag, commands) in viewer_options
2697 .given()
2698 .into_iter()
2699 .chain(graph_sources.given())
2700 .chain(chart.given())
2701 .chain(schema_options.given())
2702 .chain(profile_options.given())
2703 .chain(log_options.given())
2704 {
2705 if !commands.contains(&mode_name(mode)) {
2706 return Err(format!(
2707 "{flag} only has an effect with `rich {}`",
2708 commands.join("` or `rich ")
2709 ));
2710 }
2711 }
2712 if mode == Mode::Capture && resources.is_empty() {
2713 return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
2714 }
2715 if mode == Mode::View && !paging_explicit {
2716 auto_pager = true;
2717 }
2718 data.validate()?;
2719
2720 if watch && (batch || pager || auto_pager) {
2721 return Err("--watch cannot be combined with --batch or paging".into());
2722 }
2723
2724 if batch {
2728 if mode.accepts_multiple_resources() {
2729 return Err(format!(
2730 "--batch cannot be combined with {}",
2731 if mode == Mode::Diff {
2732 "--diff"
2733 } else {
2734 "--gif"
2735 }
2736 ));
2737 }
2738 if resources.is_empty() {
2739 return Err("--batch needs at least one file, directory or glob".into());
2740 }
2741 if pager || auto_pager {
2743 return Err("--pager cannot be combined with --batch".into());
2744 }
2745 }
2746
2747 if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
2750 return Err(
2751 "--diff needs two images or files to compare, or one patch: --diff before after".into(),
2752 );
2753 }
2754 if mode == Mode::Diff && tool_options.conflicts() {
2757 if resources.len() != 1 {
2758 return Err("--conflicts needs exactly one file: rich diff --conflicts FILE".into());
2759 }
2760 if diff_threshold.is_some() {
2761 return Err("--threshold cannot be combined with --conflicts".into());
2762 }
2763 }
2764 if mode == Mode::Image {
2765 if resources.is_empty() {
2766 return Err(
2767 "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
2768 );
2769 }
2770 if image_mode == ImageMode::None {
2771 return Err(
2772 "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
2773 .into(),
2774 );
2775 }
2776 }
2777 let quantized = matches!(
2778 image_color.as_deref(),
2779 Some("ansi256" | "ansi16" | "grayscale")
2780 );
2781 if quantized && image_mode == ImageMode::Braille {
2782 return Err(format!(
2783 "--image-color {} has no effect with --image-mode braille, which is monochrome",
2784 image_color.as_deref().unwrap()
2785 ));
2786 }
2787 for (flag, value, neutral) in [
2788 ("--image-dither", image_dither.as_deref(), "none"),
2789 (
2790 "--image-color-distance",
2791 image_color_distance.as_deref(),
2792 "rgb",
2793 ),
2794 ] {
2795 if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
2796 return Err(format!(
2797 "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
2798 ));
2799 }
2800 }
2801 if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
2802 return Err("--image-anchor requires --image-fit cover".into());
2803 }
2804 if (batch_paths.preserve_dirs
2805 || batch_paths.template.is_some()
2806 || batch_paths.input_root.is_some())
2807 && !batch
2808 {
2809 return Err("batch path options require --batch".into());
2810 }
2811 if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
2812 return Err("--batch-preserve-dirs requires --batch-input-root".into());
2813 }
2814 if dry_run && !batch {
2815 return Err("--dry-run requires --batch".into());
2816 }
2817 if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2818 return Err("--jobs greater than 1 requires --batch with a file export".into());
2819 }
2820 if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2822 return Err("--image-fit requires --height to define the target rectangle".into());
2823 }
2824 let effective_mode = if mode == Mode::Auto {
2829 detect_mode(resources.first().map(String::as_str))
2830 } else {
2831 mode
2832 };
2833 extensions.validate(
2834 effective_mode == Mode::Gif,
2835 effective_mode.allows_extensions()
2836 && !(mode == Mode::Auto && resources.is_empty())
2837 && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2838 )?;
2839 if export_png.is_some() && !cfg!(feature = "png") {
2840 return Err("--export-png needs a build with the png feature".into());
2841 }
2842 if export_png.is_some() && batch {
2843 return Err("--export-png cannot be combined with --batch".into());
2844 }
2845 let exporting = export_html.is_some() || export_svg.is_some() || export_png.is_some();
2846 if exporting {
2847 let unsupported = if effective_mode == Mode::Gif {
2848 Some("--gif")
2849 } else if mode == Mode::Auto && resources.is_empty() {
2850 Some("the capability demo")
2851 } else {
2852 None
2853 };
2854 if let Some(what) = unsupported {
2855 return Err(format!(
2856 "--export-html/--export-svg/--export-png cannot capture {what}"
2857 ));
2858 }
2859 }
2860
2861 let text_justify = [
2867 Justify::Left,
2868 Justify::Right,
2869 Justify::Center,
2870 Justify::Full,
2871 ]
2872 .into_iter()
2873 .zip(text_flags)
2874 .find_map(|(justify, given)| given.then_some(justify));
2875 let source_mode = matches!(effective_mode, Mode::Syntax | Mode::Ipynb | Mode::Auto);
2878 let print_mode = matches!(effective_mode, Mode::Print | Mode::Rule);
2879 let orphans = [
2880 (
2881 "--head/--tail",
2882 source.head.is_some() || source.tail.is_some(),
2883 "--syntax, --csv or --ipynb",
2884 source_mode || effective_mode == Mode::Csv,
2885 ),
2886 (
2887 "--infer",
2888 source.infer,
2889 "--csv",
2890 effective_mode == Mode::Csv,
2891 ),
2892 (
2893 "--line-numbers",
2894 source.line_numbers,
2895 "--syntax or --ipynb",
2896 source_mode,
2897 ),
2898 (
2899 "--guides",
2900 source.guides,
2901 "--syntax or --ipynb",
2902 source_mode,
2903 ),
2904 (
2905 "--lexer",
2906 source.lexer.is_some(),
2907 "--syntax, --ipynb, --rst, --org, --asciidoc or --man",
2908 source_mode
2909 || matches!(
2910 effective_mode,
2911 Mode::Rst | Mode::Org | Mode::AsciiDoc | Mode::Man
2912 ),
2913 ),
2914 (
2915 "--no-wrap",
2916 source.no_wrap,
2917 "--syntax, --ipynb, --print or --rule",
2918 source_mode || print_mode,
2919 ),
2920 (
2921 "--text-left/--text-right/--text-center/--text-full",
2922 text_justify.is_some(),
2923 "--print or --rule",
2924 print_mode,
2925 ),
2926 (
2927 "--rule-style",
2928 rule_style.is_some(),
2929 "--rule",
2930 effective_mode == Mode::Rule,
2931 ),
2932 (
2933 "--rule-char",
2934 rule_char.is_some(),
2935 "--rule",
2936 effective_mode == Mode::Rule,
2937 ),
2938 (
2941 "--soft",
2942 soft,
2943 "a mode other than --jsonl or --log",
2944 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2945 ),
2946 (
2947 "--max-width",
2948 max_width.is_some(),
2949 "a mode other than --jsonl or --log",
2950 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2951 ),
2952 (
2953 "--threshold",
2954 diff_threshold.is_some(),
2955 "--diff",
2956 mode == Mode::Diff,
2957 ),
2958 (
2959 "--image-mode",
2960 image_mode != ImageMode::Auto,
2961 "--diff/--image",
2962 mode == Mode::Diff || mode == Mode::Image,
2963 ),
2964 ("--height", height.is_some(), "--image", mode == Mode::Image),
2965 (
2966 "--image-rotate",
2967 image_rotate.is_some(),
2968 "--image",
2969 mode == Mode::Image,
2970 ),
2971 (
2972 "--image-flip-horizontal",
2973 image_flip_horizontal,
2974 "--image",
2975 mode == Mode::Image,
2976 ),
2977 (
2978 "--image-flip-vertical",
2979 image_flip_vertical,
2980 "--image",
2981 mode == Mode::Image,
2982 ),
2983 (
2984 "--image-grayscale",
2985 image_grayscale,
2986 "--image",
2987 mode == Mode::Image,
2988 ),
2989 (
2990 "--image-color",
2991 image_color.is_some(),
2992 "--image or --gif",
2993 mode == Mode::Image || effective_mode == Mode::Gif,
2994 ),
2995 (
2996 "--image-max-width",
2997 image_max_width.is_some(),
2998 "--image",
2999 mode == Mode::Image,
3000 ),
3001 (
3002 "--image-max-height",
3003 image_max_height.is_some(),
3004 "--image",
3005 mode == Mode::Image,
3006 ),
3007 (
3008 "--image-brightness",
3009 image_brightness.is_some(),
3010 "--image",
3011 mode == Mode::Image,
3012 ),
3013 (
3014 "--image-contrast",
3015 image_contrast.is_some(),
3016 "--image",
3017 mode == Mode::Image,
3018 ),
3019 (
3020 "--image-gamma",
3021 image_gamma.is_some(),
3022 "--image",
3023 mode == Mode::Image,
3024 ),
3025 (
3026 "--image-dither",
3027 image_dither.is_some(),
3028 "--image or --gif",
3029 mode == Mode::Image || effective_mode == Mode::Gif,
3030 ),
3031 (
3032 "--image-color-distance",
3033 image_color_distance.is_some(),
3034 "--image or --gif",
3035 mode == Mode::Image || effective_mode == Mode::Gif,
3036 ),
3037 (
3038 "--image-fit",
3039 image_fit.is_some(),
3040 "--image",
3041 mode == Mode::Image,
3042 ),
3043 (
3044 "--image-background",
3045 image_background.is_some(),
3046 "--image",
3047 mode == Mode::Image,
3048 ),
3049 (
3050 "--loop",
3051 loops.is_some(),
3052 "--gif",
3053 effective_mode == Mode::Gif,
3054 ),
3055 (
3056 "--speed",
3057 animation.speed.is_some(),
3058 "--gif",
3059 effective_mode == Mode::Gif,
3060 ),
3061 (
3062 "--fps",
3063 animation.fps.is_some(),
3064 "--gif",
3065 effective_mode == Mode::Gif,
3066 ),
3067 (
3068 "--max-frames",
3069 animation.max_frames.is_some(),
3070 "--gif",
3071 effective_mode == Mode::Gif,
3072 ),
3073 (
3074 "--max-duration",
3075 animation.max_duration.is_some(),
3076 "--gif",
3077 effective_mode == Mode::Gif,
3078 ),
3079 (
3084 "--panel-style",
3085 panel_style.is_some(),
3086 "--panel",
3087 panel.is_some(),
3088 ),
3089 (
3090 "--expand",
3091 expand,
3092 "--panel/--padding",
3093 panel.is_some() || padding.is_some(),
3094 ),
3095 ("--watch-interval", watch_interval != 1.0, "--watch", watch),
3096 ("--watch-cache", watch_cache, "--watch", watch),
3097 (
3098 "--watch-debounce",
3099 watch_debounce.is_some(),
3100 "--watch",
3101 watch,
3102 ),
3103 ("--watch-poll", watch_poll, "--watch", watch),
3104 (
3105 "--watch-exit-on-error",
3106 watch_exit_on_error,
3107 "--watch",
3108 watch,
3109 ),
3110 ];
3111 if let Some((flag, _, needs, _)) = orphans
3112 .iter()
3113 .find(|(_, given, _, mode_present)| *given && !*mode_present)
3114 {
3115 return Err(format!("{flag} only has an effect with {needs}"));
3116 }
3117
3118 let demo = mode == Mode::Auto && resources.is_empty();
3121 if effective_mode.draws_directly() || demo {
3122 let mode_name = if effective_mode == Mode::Gif {
3123 "--gif"
3124 } else if effective_mode == Mode::Image {
3125 "--image"
3126 } else if effective_mode.streams_records() {
3127 mode_name(effective_mode)
3128 } else if demo {
3129 "the capability demo"
3130 } else {
3131 "--diff"
3132 };
3133 let unsupported = [
3134 (
3135 "--pager",
3136 (pager || auto_pager)
3137 && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
3138 ),
3139 (
3140 "--export-html/--export-svg/--export-png",
3141 (export_html.is_some() || export_svg.is_some() || export_png.is_some())
3142 && effective_mode.streams_records(),
3143 ),
3144 ("--width", demo && width.is_some()),
3145 ("--theme-style", demo && !theme_styles.is_empty()),
3146 ("--theme-file", demo && !theme_file_styles.is_empty()),
3147 (
3148 "--hyperlinks",
3149 hyperlinks && (effective_mode.streams_records() || demo),
3150 ),
3151 ("--panel", panel.is_some()),
3152 ("--padding", padding.is_some()),
3153 ("--title", title.is_some()),
3154 ("--caption", caption.is_some()),
3155 ("--style", style.is_some()),
3156 ("--panel-style", panel_style.is_some()),
3157 ("--expand", expand),
3158 ("--left/--center/--right", justify.is_some()),
3159 ];
3160 if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
3161 return Err(format!("{flag} cannot be combined with {mode_name}"));
3162 }
3163 }
3164 if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
3167 return Err("only one resource may be given (except with --gif or --watch)".into());
3168 }
3169 let sanitize = sanitize.unwrap_or_else(|| {
3177 resources.iter().any(|r| is_url(r))
3178 || mode == Mode::View
3179 || mode == Mode::Schema
3180 || mode == Mode::Log
3181 || (mode == Mode::Diff
3182 && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
3183 });
3184 let resource = resources.first().map(|resource| {
3186 if print_mode {
3187 text_arg(resource)
3188 } else {
3189 resource.clone()
3190 }
3191 });
3192 let title = title.map(|title| text_arg(&title));
3193 let caption = caption.map(|caption| text_arg(&caption));
3194 let rule_char = rule_char.map(|characters| text_arg(&characters));
3195
3196 code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
3198 Ok(Some(Cli {
3199 mode,
3200 resource,
3201 resources,
3202 loops,
3203 animation,
3204 diff_threshold,
3205 image_mode,
3206 image_fit,
3207 image_anchor,
3208 image_background,
3209 image_color,
3210 image_dither,
3211 image_color_distance,
3212 image_rotate,
3213 image_flip_horizontal,
3214 image_flip_vertical,
3215 image_grayscale,
3216 image_max_width,
3217 image_max_height,
3218 image_brightness,
3219 image_contrast,
3220 image_gamma,
3221 log_presentation,
3222 mermaid_backend,
3223 dot_backend,
3224 graph_sources,
3225 chart,
3226 schema: schema_options,
3227 profile: profile_options,
3228 rules: rule_options,
3229 highlighter,
3230 code_theme,
3231 plugins,
3232 micro_project,
3233 theme_styles,
3234 theme_file_styles,
3235 height,
3236 extensions,
3237 width,
3238 justify,
3239 text_justify,
3240 emoji,
3241 soft,
3242 max_width,
3243 rule_style,
3244 rule_char,
3245 force_terminal,
3246 source,
3247 no_color,
3248 export_html,
3249 export_svg,
3250 export_png,
3251 panel,
3252 padding,
3253 title,
3254 caption,
3255 panel_style,
3256 style,
3257 expand,
3258 pager,
3259 auto_pager,
3260 hyperlinks,
3261 sanitize,
3262 report_format,
3263 watch,
3264 watch_interval,
3265 watch_cache,
3266 watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
3267 watch_poll,
3268 watch_exit_on_error,
3269 batch,
3270 progress,
3271 continue_on_error,
3272 jobs,
3273 dry_run,
3274 batch_paths,
3275 worker_args: worker_args(args),
3276 overwrite,
3277 collision,
3278 data,
3279 tools: tool_options,
3280 viewers: viewer_options,
3281 logs: log_options,
3282 }))
3283}
3284
3285fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
3295 use std::io::{Seek, SeekFrom};
3296 let resource = cli.resource.as_deref();
3297 let name = resource.unwrap_or("<stdin>");
3298 let window = |bytes: Vec<u8>| {
3299 let start = usize::try_from(offset)
3300 .unwrap_or(usize::MAX)
3301 .min(bytes.len());
3302 let mut rest = bytes[start..].to_vec();
3303 let more = rest.len() as u64 > max;
3304 rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3305 (rest, more)
3306 };
3307 if let Some(url) = resource.filter(|r| is_url(r)) {
3308 return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
3309 }
3310 if cli.extensions.encoding.is_some() {
3311 return read_resource_limited(
3312 resource,
3313 cli.extensions.encoding,
3314 Some(controls::INSPECT_LIMIT),
3315 )
3316 .map(|text| window(text.into_bytes()))
3317 .map_err(|err| format!("cannot read {name}: {err}"));
3318 }
3319 let error = |err: std::io::Error| format!("cannot read {name}: {err}");
3320 let take = max.saturating_add(1);
3322 let mut bytes = Vec::new();
3323 match resource.filter(|r| *r != "-") {
3324 Some(path) if fs_path(path).is_dir() => {
3325 return Err(format!("cannot read {path}: it is a directory"));
3326 }
3327 Some(path) => {
3328 let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
3329 if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
3331 std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
3332 .map_err(error)?;
3333 }
3334 file.take(take).read_to_end(&mut bytes).map_err(error)?;
3335 }
3336 None => {
3337 let mut stdin = std::io::stdin().lock();
3338 std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
3339 stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
3340 }
3341 }
3342 let more = bytes.len() as u64 > max;
3343 bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3344 Ok((bytes, more))
3345}
3346
3347fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
3349 let mut bytes = Vec::new();
3350 match limit {
3351 None => {
3352 let mut reader = reader;
3353 reader.read_to_end(&mut bytes)?;
3354 }
3355 Some(limit) => {
3356 reader
3357 .take(limit.saturating_add(1))
3358 .read_to_end(&mut bytes)?;
3359 if bytes.len() as u64 > limit {
3360 return Err(std::io::Error::other(format!(
3361 "larger than {}, the most this command reads",
3362 controls::size(limit)
3363 )));
3364 }
3365 }
3366 }
3367 Ok(bytes)
3368}
3369
3370fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
3383 read_resource_limited(resource, encoding, None)
3384}
3385
3386fn read_resource_limited(
3390 resource: Option<&str>,
3391 encoding: Option<Encoding>,
3392 limit: Option<u64>,
3393) -> std::io::Result<String> {
3394 let content = match resource {
3395 Some(path) if path != "-" => {
3396 let file = &fs_path(path);
3397 if file.is_dir() {
3400 return Err(std::io::Error::new(
3401 std::io::ErrorKind::InvalidInput,
3402 "is a directory, not a file",
3403 ));
3404 }
3405 let bytes = match limit {
3406 None => std::fs::read(file)?,
3407 Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
3408 };
3409 if let Some(encoding) = encoding {
3410 return encoding.decode(&bytes).map(normalize_newlines);
3411 }
3412 if has_utf16_bom(&bytes) {
3413 eprintln!(
3414 "rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)",
3415 controls::shown(path)
3416 );
3417 }
3418 match String::from_utf8(bytes) {
3419 Ok(text) => text,
3420 Err(_) if looks_like_image(path) => {
3425 return Err(std::io::Error::new(
3426 std::io::ErrorKind::InvalidData,
3427 "looks like an image; use `rich --image <path>` to view it, or \
3428 `rich --diff <before> <after>` to compare two images",
3429 ));
3430 }
3431 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
3432 }
3433 }
3434 _ => {
3435 if std::io::stdin().is_terminal() {
3436 let eof = if cfg!(windows) {
3437 "Ctrl-Z then Enter"
3438 } else {
3439 "Ctrl-D"
3440 };
3441 eprintln!("rich: reading stdin; finish input with {eof}");
3442 }
3443 let buffer = read_limited(std::io::stdin(), limit)?;
3444 decode_strict(buffer, encoding)?
3445 }
3446 };
3447 Ok(normalize_newlines(if encoding.is_some() {
3448 content
3449 } else {
3450 strip_bom(content)
3451 }))
3452}
3453
3454fn sanitize_json_value(value: &mut serde_json::Value) {
3455 match value {
3456 serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
3457 serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
3458 serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
3459 _ => {}
3460 }
3461}
3462
3463fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
3464 let mut value = serde_json::from_str::<serde_json::Value>(content)?;
3465 sanitize_json_value(&mut value);
3466 serde_json::to_string(&value)
3467}
3468
3469fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
3471 if let Some(encoding) = encoding {
3472 return encoding.decode(&bytes);
3473 }
3474 String::from_utf8(bytes).map_err(|err| std::io::Error::new(
3475 std::io::ErrorKind::InvalidData,
3476 if has_utf16_bom(err.as_bytes()) {
3477 "input has a UTF-16 BOM; use --encoding utf-16"
3478 } else {
3479 "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
3480 },
3481 ))
3482}
3483
3484fn normalize_newlines(text: String) -> String {
3493 const CR: char = '\r';
3494 const LF: char = '\n';
3495 if !text.contains(CR) {
3496 return text;
3497 }
3498 let mut out = String::with_capacity(text.len());
3499 let mut chars = text.chars().peekable();
3500 while let Some(c) = chars.next() {
3501 if c == CR {
3502 if chars.peek() == Some(&LF) {
3505 chars.next();
3506 }
3507 out.push(LF);
3508 } else {
3509 out.push(c);
3510 }
3511 }
3512 out
3513}
3514
3515fn looks_like_image(path: &str) -> bool {
3525 std::path::Path::new(path)
3526 .extension()
3527 .and_then(|e| e.to_str())
3528 .map(|e| e.to_ascii_lowercase())
3529 .is_some_and(|e| {
3530 matches!(
3531 e.as_str(),
3532 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
3533 )
3534 })
3535}
3536
3537fn strip_bom(text: String) -> String {
3538 match text.strip_prefix('\u{feff}') {
3539 Some(rest) => rest.to_string(),
3540 None => text,
3541 }
3542}
3543
3544fn is_url(resource: &str) -> bool {
3547 let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
3548 head.starts_with("http://") || head.starts_with("https://")
3549}
3550
3551fn resource_ext(resource: &str) -> Option<String> {
3555 let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
3556 let basename = basename.split(['?', '#']).next().unwrap_or(basename);
3557 basename
3558 .rsplit_once('.')
3559 .map(|(_, ext)| ext.to_ascii_lowercase())
3560 .filter(|ext| !ext.is_empty())
3561}
3562
3563#[cfg(feature = "fetch")]
3566const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
3567
3568#[cfg(feature = "fetch")]
3577fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3578 use std::time::Duration;
3579 let budget = Duration::from_secs(30);
3585 let mut secure = false;
3586 let mut response =
3587 rich_marketplace::redirect::get_following(url, 10, false, budget, |hop, deadline| {
3588 let https = is_https(hop);
3589 if secure && !https {
3590 return Err(FetchFailure::Downgrade(hop.to_string()));
3591 }
3592 secure |= https;
3593 rich_marketplace::tls::with_fallback(|trust| {
3594 ureq::get(hop)
3595 .config()
3596 .timeout_global(Some(deadline.remaining()?))
3598 .http_status_as_error(false)
3601 .max_redirects(0)
3602 .tls_config(trust.tls_config())
3603 .build()
3604 .call()
3605 })
3606 .map_err(FetchFailure::Trust)
3607 })
3608 .map_err(|err| match err {
3609 FetchFailure::Trust(err) => format!("cannot fetch {url}: {err}"),
3610 FetchFailure::Downgrade(to) => format!(
3611 "cannot fetch {url}: refused its redirect from https:// to {to}, which is not \
3612 encrypted (fetch the http:// address itself if that is intended)"
3613 ),
3614 })?;
3615 let content_type = response
3616 .headers()
3617 .get("content-type")
3618 .and_then(|value| value.to_str().ok())
3619 .map(str::to_string);
3620
3621 let mut buffer = Vec::new();
3627 response
3628 .body_mut()
3629 .with_config()
3630 .reader()
3631 .take(MAX_BODY_BYTES + 1)
3632 .read_to_end(&mut buffer)
3633 .map_err(|err| format!("cannot read {url}: {err}"))?;
3634 if buffer.len() as u64 > MAX_BODY_BYTES {
3635 return Err(format!(
3636 "{url} exceeds the {} MiB limit",
3637 MAX_BODY_BYTES / (1024 * 1024)
3638 ));
3639 }
3640 let body =
3641 decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
3642 Ok((body, content_type))
3643}
3644
3645#[cfg(feature = "fetch")]
3647enum FetchFailure {
3648 Trust(rich_marketplace::tls::TrustError),
3651 Downgrade(String),
3653}
3654
3655#[cfg(feature = "fetch")]
3656impl From<ureq::Error> for FetchFailure {
3657 fn from(error: ureq::Error) -> Self {
3658 FetchFailure::Trust(error.into())
3659 }
3660}
3661
3662#[cfg(feature = "fetch")]
3664fn is_https(url: &str) -> bool {
3665 url.get(..8)
3666 .is_some_and(|s| s.eq_ignore_ascii_case("https://"))
3667}
3668
3669#[cfg(not(feature = "fetch"))]
3671fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3672 Err(format!(
3673 "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
3674 ))
3675}
3676
3677fn content_type_mode(content_type: &str) -> Option<Mode> {
3681 let mime = mime_of(content_type);
3682 match mime.as_str() {
3683 "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
3684 "text/x-rst" | "text/prs.fallenstein.rst" => Some(Mode::Rst),
3685 "text/org" | "text/x-org" => Some(Mode::Org),
3686 "text/asciidoc" | "text/x-asciidoc" => Some(Mode::AsciiDoc),
3687 "text/troff" | "application/x-troff" | "application/x-troff-man" | "text/x-troff-man" => {
3688 Some(Mode::Man)
3689 }
3690 "application/json" | "text/json" => Some(Mode::Json),
3691 "text/csv" => Some(Mode::Csv),
3692 _ => None,
3693 }
3694}
3695
3696fn content_type_lexer(content_type: &str) -> Option<&'static str> {
3700 let mime = mime_of(content_type);
3701 Some(match mime.as_str() {
3702 "text/html" | "application/xhtml+xml" => "html",
3703 "text/css" => "css",
3704 "application/javascript" | "text/javascript" => "javascript",
3705 "application/xml" | "text/xml" => "xml",
3706 "text/x-python" | "application/x-python" | "text/x-python3" => "python",
3707 "text/x-rust" => "rust",
3708 "text/x-c" | "text/x-csrc" => "c",
3709 "application/x-sh" | "text/x-shellscript" => "bash",
3710 "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
3711 "application/toml" | "text/x-toml" => "toml",
3712 _ => return None,
3713 })
3714}
3715
3716fn is_uninformative_ext(ext: &str) -> bool {
3719 matches!(ext, "txt" | "text" | "log" | "dat" | "out")
3720}
3721
3722fn mime_of(content_type: &str) -> String {
3725 content_type
3726 .split(';')
3727 .next()
3728 .unwrap_or("")
3729 .trim()
3730 .to_ascii_lowercase()
3731}
3732
3733fn transforms_apply(mode: Mode) -> bool {
3737 matches!(
3738 mode,
3739 Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
3740 )
3741}
3742
3743fn transform_misplaced_in(cli: &Cli, mode: Mode) -> Option<String> {
3747 if let Some(flag) = cli
3748 .data
3749 .transform_option()
3750 .filter(|_| !transforms_apply(mode))
3751 {
3752 return Some(transforms_misplaced(flag, mode));
3753 }
3754 (mode == Mode::Inspect && !cli.data.transforms().is_empty())
3755 .then(|| cli.data.check_transforms(true).unwrap_err())
3756}
3757
3758fn transforms_misplaced(flag: &str, mode: Mode) -> String {
3759 format!(
3760 "{flag} only has an effect on {}, not {}",
3761 transform_modes(flag),
3762 mode_name(mode)
3763 )
3764}
3765
3766fn transform_modes(flag: &str) -> &'static str {
3768 if flag == "--transform" {
3769 "text, --print or --syntax"
3770 } else {
3771 "text, --print, --syntax or --inspect"
3772 }
3773}
3774
3775fn detect_mode(resource: Option<&str>) -> Mode {
3776 match resource.and_then(resource_ext).as_deref() {
3777 Some("md") | Some("markdown") => Mode::Markdown,
3778 Some("json") => Mode::Json,
3779 Some("csv") | Some("tsv") => Mode::Csv,
3780 Some("ipynb") => Mode::Ipynb,
3781 Some("rst") => Mode::Rst,
3782 Some("org") if !resource.is_some_and(documents::bare_host) => Mode::Org,
3785 Some("adoc" | "asciidoc") => Mode::AsciiDoc,
3786 Some("man" | "roff" | "mdoc") => Mode::Man,
3787 Some("gif") => Mode::Gif,
3788 Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
3789 Some("dot") | Some("gv") => Mode::Dot,
3790 Some(_) => Mode::Syntax,
3798 None => Mode::Auto,
3802 }
3803}
3804
3805struct ForceWidth {
3813 child: Box<dyn Renderable>,
3814 width: usize,
3815}
3816
3817impl Renderable for ForceWidth {
3818 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3819 self.child
3820 .rich_render(console, &options.update_width(self.width))
3821 }
3822
3823 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
3824 Measurement::new(self.width, self.width)
3825 }
3826}
3827
3828struct Aligned {
3838 child: Box<dyn Renderable>,
3839 width: usize,
3845 justify: Justify,
3846}
3847
3848impl Renderable for Aligned {
3849 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3850 let width = self.width.clamp(1, options.max_width);
3851 let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
3861 let widths: Vec<usize> = lines
3862 .iter()
3863 .map(|line| line.iter().map(Segment::cell_length).sum())
3864 .collect();
3865 let shape = widths.iter().copied().max().unwrap_or(0);
3869
3870 let excess = options.max_width.saturating_sub(shape);
3871 let (left, right) = match self.justify {
3872 Justify::Right => (excess, 0),
3873 Justify::Center => (excess / 2, excess - excess / 2),
3874 _ => (0, excess),
3875 };
3876 let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
3877
3878 let mut segments = Vec::new();
3879 let last = lines.len().saturating_sub(1);
3880 for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
3881 if left > 0 {
3882 segments.push(blank(left));
3883 }
3884 segments.extend(line);
3885 let pad = shape - line_width + right;
3886 if pad > 0 {
3887 segments.push(blank(pad));
3888 }
3889 if index != last {
3890 segments.push(Segment::line());
3891 }
3892 }
3893 segments
3894 }
3895}
3896
3897fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
3909 console
3910 .render_lines(renderable, &console.options(), false)
3911 .iter()
3912 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
3913 .max()
3914 .unwrap_or(0)
3915}
3916
3917fn micro_pending() -> bool {
3922 #[cfg(feature = "art")]
3923 return micro::drawing_pending();
3924 #[cfg(not(feature = "art"))]
3925 false
3926}
3927
3928fn validate_labels(cli: &Cli) -> Result<(), String> {
3929 for label in cli.title.iter().chain(cli.caption.iter()) {
3930 Text::from_markup(label).map_err(|err| err.to_string())?;
3931 }
3932 Ok(())
3933}
3934
3935fn decorate_and_emit(
3941 cli: &Cli,
3942 console: &Console,
3943 export: &Export,
3944 renderable: Box<dyn Renderable>,
3945 fit: Option<usize>,
3946) -> ExitCode {
3947 decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
3948}
3949
3950fn decorate_and_emit_with(
3953 cli: &Cli,
3954 console: &Console,
3955 export: &Export,
3956 renderable: Box<dyn Renderable>,
3957 fit: Option<usize>,
3958 finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
3959) -> ExitCode {
3960 if cli.panel.is_some()
3961 && let Err(err) = validate_labels(cli)
3962 {
3963 return fail(cli, ExitClass::Data, err);
3964 }
3965 let max_width = console.width();
3966 let expand = cli.expand || cli.width.is_some();
3968 let mut renderable = renderable;
3969 let mut fit = fit;
3970
3971 if let Some((top, right, bottom, left)) = cli.padding {
3977 let padded: Box<dyn Renderable> =
3978 Box::new(Padding::new(renderable, (top, right, bottom, left)));
3979 fit = fit.map(|width| (width + left + right).min(max_width));
3982 renderable = if expand {
3983 padded
3984 } else {
3985 Box::new(Constrain::new(padded, fit))
3986 };
3987 }
3988
3989 if let Some(box_set) = cli.panel {
3990 let mut panel = Panel::new(renderable).box_set(box_set);
3991 #[cfg(feature = "art")]
3994 let micro_label = |label: &Option<String>| {
3995 label
3996 .as_deref()
3997 .and_then(|label| micro::panel_label(console, label, cli.micro_project))
3998 };
3999 #[cfg(not(feature = "art"))]
4000 let micro_label = |_: &Option<String>| None::<Text>;
4001 let micro_title = micro_label(&cli.title);
4002 if let Some(title) = micro_title.clone() {
4003 panel = panel.title_as_text(title);
4004 } else if let Some(title) = cli.title.clone() {
4005 panel = panel.title(title);
4006 }
4007 if let Some(caption) = micro_label(&cli.caption) {
4008 panel = panel.subtitle_as_text(caption);
4009 } else if let Some(caption) = cli.caption.clone() {
4010 panel = panel.subtitle(caption);
4011 }
4012 if let Some(style) = cli.panel_style.clone() {
4013 panel = panel.border_style(style);
4014 }
4015 let inner = max_width.saturating_sub(4);
4024 let child = fit.unwrap_or(inner).min(inner);
4025 let title_width = cli
4026 .title
4027 .as_deref()
4028 .map(|title| {
4029 let mut title = match µ_title {
4032 Some(title) => {
4033 let mut flat = title.blank_copy();
4034 flat.append(&title.plain().replace('\n', " "), None);
4035 flat
4036 }
4037 None => {
4038 let title = rich::emoji::replace(title);
4039 Text::from_markup(&title.replace('\n', " "))
4040 .expect("title markup validated before rendering")
4041 }
4042 };
4043 title.expand_tabs(8);
4044 cell_len(title.plain()) + 2
4045 })
4046 .unwrap_or(0);
4047 fit = Some(child.max(title_width) + 4);
4048 let panel: Box<dyn Renderable> = Box::new(panel);
4049 renderable = if expand {
4050 panel
4051 } else {
4052 Box::new(Constrain::new(panel, fit))
4053 };
4054 }
4055
4056 if let Some(style) = cli.style.clone() {
4059 renderable = Box::new(Styled::new(renderable, style));
4060 }
4061
4062 if let Some(width) = cli.width.filter(|_| !cli.pager) {
4064 renderable = Box::new(ForceWidth {
4065 child: renderable,
4066 width,
4067 });
4068 fit = Some(width);
4069 }
4070
4071 if let Some(justify) = cli.justify {
4072 renderable = Box::new(Aligned {
4073 child: renderable,
4074 width: fit.unwrap_or(max_width),
4075 justify,
4076 });
4077 }
4078
4079 #[cfg(feature = "art")]
4084 if let Some(registry) =
4085 micro::take_drawing().filter(|_| export.terminal_only() && !watch::watching())
4086 {
4087 renderable = micro::drawn(console, registry, renderable);
4088 }
4089
4090 let mut options = console.options();
4093 if let Some(max_width) = cli.max_width {
4094 options.max_width = max_width.min(options.max_width);
4095 }
4096 let shown = if cli.soft {
4097 let mut segments = soft_wrap_segments(console, renderable.as_ref(), &options);
4098 if !segments.is_empty() {
4099 segments.push(Segment::line());
4100 }
4101 emit_segments(console, export, segments)
4102 } else {
4103 emit(console, export, |c| {
4104 c.print_with(renderable.as_ref(), &options)
4105 })
4106 };
4107 finish(cli, shown)
4108}
4109
4110fn soft_wrap_segments(
4116 console: &Console,
4117 renderable: &dyn Renderable,
4118 options: &ConsoleOptions,
4119) -> Vec<Segment> {
4120 let mut options = options.clone();
4121 options.no_wrap.get_or_insert(true);
4122 options
4123 .overflow
4124 .get_or_insert(rich::console::Overflow::Ignore);
4125 let joined = renderable.printed_text();
4126 let renderable: &dyn Renderable = match &joined {
4127 Some(text) => text,
4128 None => renderable,
4129 };
4130 if renderable.fit_to_measurement() {
4131 let measurement = renderable.measure(console, &options);
4132 options.max_width = measurement.maximum.min(options.max_width).max(1);
4133 }
4134 console.render(renderable, Some(&options))
4135}
4136
4137fn run_cli(cli: Cli) -> ExitCode {
4138 if cli.watch {
4139 return run_watch(cli);
4140 }
4141 run_once(cli)
4142}
4143
4144fn run_watch(mut cli: Cli) -> ExitCode {
4145 let Some(resource) = cli.resource.clone() else {
4146 return fail(
4147 &cli,
4148 ExitClass::Usage,
4149 "--watch requires a file path or URL",
4150 );
4151 };
4152 let several = cli.resources.len() > 1;
4153 for resource in &cli.resources {
4154 if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
4155 return fail(
4156 &cli,
4157 ExitClass::Usage,
4158 "--watch requires a local file or URL, not stdin or literal markup",
4159 );
4160 }
4161 if several && is_url(resource) {
4162 return fail(
4163 &cli,
4164 ExitClass::Usage,
4165 "--watch with several resources requires local files; watch a URL on its own",
4166 );
4167 }
4168 let effective_mode = if cli.mode == Mode::Auto {
4169 detect_mode(Some(resource))
4170 } else {
4171 cli.mode
4172 };
4173 if effective_mode.draws_directly() || effective_mode == Mode::Rule {
4174 return fail(
4175 &cli,
4176 ExitClass::Usage,
4177 "--watch requires a resource-backed render mode",
4178 );
4179 }
4180 }
4181 if several
4182 && (cli.export_html.is_some() || cli.export_svg.is_some() || cli.export_png.is_some())
4183 {
4184 return fail(
4185 &cli,
4186 ExitClass::Usage,
4187 "--export-html/--export-svg/--export-png cannot be combined with watching several \
4188 files",
4189 );
4190 }
4191 if !std::io::stdout().is_terminal() {
4195 cli.watch = false;
4196 if !several {
4197 return run_once(cli);
4198 }
4199 let mut first_failure = None;
4200 for resource in cli.resources.clone() {
4201 let mut single = cli.clone();
4202 single.resource = Some(resource.clone());
4203 single.resources = vec![resource];
4204 let status = run_once(single);
4205 if status != ExitClass::Success.exit_code() && first_failure.is_none() {
4206 first_failure = Some(status);
4207 }
4208 }
4209 return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
4210 }
4211 if cfg!(target_os = "wasi") {
4213 return fail(&cli, ExitClass::Usage, host::unavailable("--watch"));
4214 }
4215 watch::start_watching();
4216 let resource = resource.as_str();
4217 if !is_url(resource) {
4218 return watch::watch_files(cli);
4219 }
4220 if is_url(resource) {
4222 #[cfg(not(feature = "fetch"))]
4223 {
4224 cli.watch = false;
4225 return run_once(cli);
4226 }
4227 }
4228
4229 let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
4230 let mut previous = None;
4231 loop {
4232 #[cfg(feature = "fetch")]
4233 let cached = if is_url(resource) && cli.watch_cache {
4234 Some(fetch_url(resource, cli.extensions.encoding))
4235 } else {
4236 None
4237 };
4238 #[cfg(not(feature = "fetch"))]
4239 let cached: Option<Result<(String, Option<String>), String>> = None;
4240 let fingerprint = match cached.as_ref() {
4241 Some(Ok((body, content_type))) => {
4242 use std::hash::{Hash, Hasher};
4243 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4244 body.hash(&mut hasher);
4245 content_type.hash(&mut hasher);
4246 format!("url:{:x}", hasher.finish())
4247 }
4248 Some(Err(error)) => format!("url-error:{error}"),
4249 None => watch_fingerprint(resource, false, cli.extensions.encoding),
4250 };
4251 let changed = previous.as_ref() != Some(&fingerprint);
4252 if changed || (is_url(resource) && !cli.watch_cache) {
4253 previous = Some(fingerprint);
4254 let console = crate::console_builder().build();
4257 console.clear();
4258 console.control(&Control::home());
4259 let _ = std::io::stdout().flush();
4260 let mut iteration = cli.clone();
4261 iteration.watch = false;
4262 let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
4263 if status != ExitClass::Success.exit_code() {
4264 eprintln!("rich: watch will retry after the next change");
4267 }
4268 }
4269 std::thread::sleep(interval);
4270 }
4271}
4272
4273fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
4274 if is_url(resource) {
4275 #[cfg(feature = "fetch")]
4276 if cache_url {
4277 return match fetch_url(resource, encoding) {
4278 Ok((body, content_type)) => {
4279 use std::hash::{Hash, Hasher};
4280 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4281 body.hash(&mut hasher);
4282 content_type.hash(&mut hasher);
4283 format!("url:{:x}", hasher.finish())
4284 }
4285 Err(error) => format!("url-error:{error}"),
4286 };
4287 }
4288 #[cfg(not(feature = "fetch"))]
4289 let _ = (cache_url, encoding);
4290 return "url".to_string();
4291 }
4292 match std::fs::metadata(fs_path(resource)) {
4293 Ok(metadata) if metadata.is_file() => {
4294 use std::hash::Hasher;
4295 let mut file = match std::fs::File::open(fs_path(resource)) {
4299 Ok(file) => file,
4300 Err(error) => return format!("file-error:{error}"),
4301 };
4302 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4303 let mut buffer = [0u8; 64 * 1024];
4304 loop {
4305 match file.read(&mut buffer) {
4306 Ok(0) => break,
4307 Ok(count) => hasher.write(&buffer[..count]),
4308 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
4309 Err(error) => return format!("file-error:{error}"),
4310 }
4311 }
4312 format!("file:{:x}", hasher.finish())
4313 }
4314 Ok(_) => "not-file".to_string(),
4315 Err(_) => "missing".to_string(),
4316 }
4317}
4318
4319fn run_once(cli: Cli) -> ExitCode {
4320 run_once_with_fetch(cli, None)
4321}
4322
4323fn cli_theme(cli: &Cli) -> rich::Theme {
4326 let mut theme = rich::Theme::default_theme();
4327 for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
4328 theme.insert(name.clone(), style.clone());
4329 }
4330 theme
4331}
4332
4333impl Cli {
4334 fn themed(&self) -> bool {
4336 !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
4337 }
4338}
4339
4340fn read_theme_file(
4348 path: &str,
4349 label: &str,
4350) -> Result<std::collections::BTreeMap<String, Style>, String> {
4351 let limit = controls::THEME_FILE_LIMIT;
4352 let path = &fs_path(path);
4354 let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
4356 if !metadata.is_file() {
4357 return Err(format!("{label}: not a regular file"));
4358 }
4359 let too_large = || {
4360 format!(
4361 "{label}: larger than {}, the most read from a theme file",
4362 controls::size(limit)
4363 )
4364 };
4365 if metadata.len() > limit {
4366 return Err(too_large());
4367 }
4368 let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
4369 let mut bytes = Vec::new();
4370 file.take(limit + 1)
4371 .read_to_end(&mut bytes)
4372 .map_err(|err| format!("{label}: {err}"))?;
4373 if bytes.len() as u64 > limit {
4374 return Err(too_large());
4375 }
4376 let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
4377 let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
4378 rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
4380 _ => match bad_style_line(&text) {
4381 Some(line) => format!("{label}: line {line}: {err}"),
4382 None => format!("{label}: {err}"),
4383 },
4384 })?;
4385 Ok(theme
4386 .names()
4387 .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
4388 .collect())
4389}
4390
4391fn bad_style_line(text: &str) -> Option<usize> {
4394 let mut in_styles = false;
4395 for (index, line) in text.lines().enumerate() {
4396 let trimmed = line.trim();
4397 if trimmed.starts_with('[') {
4398 in_styles = trimmed.eq_ignore_ascii_case("[styles]");
4399 continue;
4400 }
4401 if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
4402 continue;
4403 }
4404 let Some((_, value)) = trimmed.split_once(['=', ':']) else {
4405 continue;
4406 };
4407 if Style::parse(value.trim()).is_err() {
4408 return Some(index + 1);
4409 }
4410 }
4411 None
4412}
4413
4414fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
4415 if cli.sanitize {
4416 cli.title = cli
4417 .title
4418 .take()
4419 .map(|title| sanitize_terminal_controls(&title));
4420 cli.caption = cli
4421 .caption
4422 .take()
4423 .map(|caption| sanitize_terminal_controls(&caption));
4424 }
4425
4426 if cli.batch {
4427 return run_batch(&cli);
4428 }
4429
4430 if cli.mode == Mode::Auto && cli.resource.is_none() {
4432 run_demo(cli.no_color, std::time::Duration::ZERO);
4433 return success(&cli);
4434 }
4435
4436 let mut mode = match cli.mode {
4437 Mode::Auto => detect_mode(cli.resource.as_deref()),
4438 other => other,
4439 };
4440 if let Some(message) = transform_misplaced_in(&cli, mode) {
4441 return fail(&cli, ExitClass::Usage, message);
4442 }
4443 if matches!(cli.mode, Mode::Auto | Mode::Man)
4445 && let Some(message) = cli
4446 .resource
4447 .as_deref()
4448 .and_then(documents::compressed_man_page)
4449 {
4450 return fail(&cli, ExitClass::Input, message);
4451 }
4452
4453 let mut prefetched = prefetched;
4457 let mut view_as = None;
4458 let mut view_bytes = Vec::new();
4459 if mode == Mode::View {
4460 let ext = cli
4461 .resource
4462 .as_deref()
4463 .filter(|r| *r != "-")
4464 .and_then(resource_ext);
4465 let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
4468 if !matches!(
4469 by_name,
4470 viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
4471 ) {
4472 let limit = if by_name == viewers::ViewAs::Notebook {
4475 controls::INSPECT_LIMIT
4476 } else {
4477 controls::VIEW_LIMIT
4478 };
4479 let more;
4480 (view_bytes, more) = match read_window(&cli, 0, limit) {
4481 Ok(read) => read,
4482 Err(err) => return fail(&cli, ExitClass::Input, err),
4483 };
4484 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4485 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4486 if kind == viewers::ViewAs::Binary {
4487 if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
4488 view_bytes.truncate(controls::BYTES_LIMIT as usize);
4489 viewers::notice(
4490 name,
4491 &controls::size(controls::BYTES_LIMIT),
4492 "use rich hex with --offset and --length for the rest",
4493 );
4494 }
4495 } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
4496 .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
4497 {
4498 view_bytes.truncate(end);
4500 viewers::notice(
4501 name,
4502 &format!("{} lines", controls::LINE_LIMIT),
4503 "use a pager or rich --syntax for the whole file",
4504 );
4505 } else if more {
4506 viewers::notice(
4507 name,
4508 &controls::size(limit),
4509 "use a pager or rich --syntax for the whole file",
4510 );
4511 }
4512 }
4513 let mut kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4514 if kind == viewers::ViewAs::Image
4516 && ext.as_deref() == Some("webp")
4517 && cli
4518 .resource
4519 .as_deref()
4520 .is_some_and(|r| !is_url(r) && viewers::animated_webp(&fs_path(r)))
4521 {
4522 kind = viewers::ViewAs::Gif;
4523 }
4524 mode = match kind {
4525 viewers::ViewAs::Markdown => Mode::Markdown,
4526 viewers::ViewAs::Csv => Mode::Csv,
4527 viewers::ViewAs::Notebook => Mode::Ipynb,
4528 viewers::ViewAs::Image => Mode::Image,
4529 viewers::ViewAs::Gif => Mode::Gif,
4530 viewers::ViewAs::Log => Mode::Log,
4531 _ => Mode::View,
4532 };
4533 let text = String::from_utf8_lossy(&view_bytes).into_owned();
4534 prefetched = Some((text, None));
4535 view_as = Some(kind);
4536 }
4537
4538 let mut builder = crate::console_builder()
4543 .no_color(cli.no_color)
4544 .emoji(cli.emoji);
4545 if cli.themed() {
4546 builder = builder.theme(cli_theme(&cli));
4547 }
4548 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
4551 builder = builder.force_terminal(terminal == "1");
4552 }
4553 if cli.force_terminal {
4554 builder = builder.force_terminal(true);
4555 }
4556 if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
4557 builder = builder.width(width);
4558 }
4559 if let Some(width) = watch::capture_width() {
4562 builder = builder.width(width);
4563 }
4564 let mut console = builder.build();
4565 render_target::attach(&mut console);
4566 install_render_defaults(&mut console);
4567 plugins::install_highlighters(&mut console);
4568 apply_code_highlighting(&mut console, &cli);
4569
4570 if mode == Mode::Rule
4571 && let Some(title) = &cli.resource
4572 && let Err(err) = Text::from_markup(title)
4573 {
4574 return fail(&cli, ExitClass::Data, err.to_string());
4575 }
4576
4577 let literal = mode == Mode::Rule
4583 || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
4584 let mut svg_title = cli
4585 .resource
4586 .as_deref()
4587 .filter(|r| *r != "-" && !literal)
4588 .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
4589 .unwrap_or_else(|| "rich".to_string());
4590 if cli.sanitize {
4591 svg_title = sanitize_terminal_controls(&svg_title);
4592 }
4593 #[allow(unused_mut)]
4594 let mut export = Export {
4595 html_path: cli.export_html.as_deref(),
4596 svg_path: cli.export_svg.as_deref(),
4597 png_path: cli.export_png.as_deref(),
4598 svg_title: &svg_title,
4599 pager: cli.pager,
4600 auto_pager: cli.auto_pager,
4601 };
4602
4603 if mode == Mode::Gif {
4606 return play_gifs(&cli, &console);
4607 }
4608
4609 if mode == Mode::Diff {
4612 let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
4613 if !images {
4614 return run_text_diff(&cli, &console, &export);
4615 }
4616 if let Some(flag) = cli.tools.diff_option() {
4617 return fail(
4618 &cli,
4619 ExitClass::Usage,
4620 format!("{flag} applies to text diffs, not images"),
4621 );
4622 }
4623 #[cfg(feature = "art")]
4624 return run_diff(&cli, &console, &export);
4625 #[cfg(not(feature = "art"))]
4626 return fail(
4627 &cli,
4628 ExitClass::Usage,
4629 "this build has no image support (rebuild with the `art` feature)",
4630 );
4631 }
4632
4633 #[cfg(feature = "art")]
4636 if mode == Mode::Image {
4637 return run_image(&cli, &console, &export);
4638 }
4639 #[cfg(not(feature = "art"))]
4640 if mode == Mode::Image {
4641 return fail(
4642 &cli,
4643 ExitClass::Usage,
4644 "this build has no image support (rebuild with the `art` feature)",
4645 );
4646 }
4647
4648 if mode.streams_records() {
4649 return run_json_lines(&cli, &console, mode == Mode::Log);
4650 }
4651
4652 if mode == Mode::Rule {
4656 let mut rule = match cli.resource.as_deref() {
4657 Some(title) if title != "-" && cli.sanitize => {
4658 Rule::new(sanitize_terminal_controls(title))
4659 }
4660 Some(title) if title != "-" => Rule::new(title),
4661 _ => Rule::line(),
4662 };
4663 let style = cli
4668 .rule_style
4669 .clone()
4670 .unwrap_or_else(|| Style::parse("bright_green").expect("a valid style"));
4671 rule = rule.style(style);
4672 if let Some(characters) = cli.rule_char.as_deref().filter(|c| !c.is_empty()) {
4673 if cli.sanitize {
4676 rule = rule.characters(sanitize_terminal_controls(characters));
4677 } else {
4678 rule = rule.characters(characters);
4679 }
4680 }
4681 match cli.text_justify {
4682 Some(Justify::Left) => rule = rule.align(rich::align::HorizontalAlign::Left),
4683 Some(Justify::Right) => rule = rule.align(rich::align::HorizontalAlign::Right),
4684 _ => {}
4685 }
4686 return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
4689 }
4690
4691 if mode == Mode::Capture {
4693 let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
4694 Ok(redactor) => redactor,
4695 Err(err) => return fail(&cli, ExitClass::Usage, err),
4696 };
4697 let preview = viewers::redact_preview(&cli.viewers);
4698 if preview && redactor.is_none() {
4699 return fail(
4700 &cli,
4701 ExitClass::Usage,
4702 "--redact-preview needs --redact or --redact-pattern",
4703 );
4704 }
4705 let width = cli
4708 .width
4709 .unwrap_or_else(|| console.width())
4710 .saturating_sub(4)
4711 .max(1);
4712 let cast = match viewers::cast_path(&cli.viewers).filter(|_| !preview) {
4715 Some(path) => match std::fs::File::create(path) {
4716 Ok(file) => Some((path, file)),
4717 Err(err) => {
4718 return fail(
4719 &cli,
4720 ExitClass::Input,
4721 format!("cannot write {path}: {err}"),
4722 );
4723 }
4724 },
4725 None => None,
4726 };
4727 let mut captured = match viewers::capture(&cli.resources, width) {
4728 Ok(captured) => captured,
4729 Err(err) => return fail(&cli, ExitClass::Input, err),
4730 };
4731 if let Some(notice) = &captured.notice {
4732 eprintln!("rich: capture: {notice}");
4733 }
4734 if let (true, Some(redactor)) = (preview, &redactor) {
4735 let would_write: Vec<String> = [
4736 viewers::cast_path(&cli.viewers),
4737 export.html_path,
4738 export.svg_path,
4739 export.png_path,
4740 ]
4741 .into_iter()
4742 .flatten()
4743 .map(str::to_string)
4744 .collect();
4745 let previews = redact_preview::capture(&captured, redactor);
4746 return redact_preview::emit(&cli, &console, previews, &would_write);
4747 }
4748 if let Some(redactor) = &redactor {
4750 captured.redact(redactor);
4751 }
4752 if cli.sanitize {
4753 captured.sanitize();
4754 }
4755 if let Some((path, mut file)) = cast {
4756 let cast = viewers::asciicast(&captured, width, console.height());
4757 if let Err(err) = file.write_all(cast.as_bytes()) {
4758 return fail(
4759 &cli,
4760 ExitClass::Input,
4761 format!("cannot write {path}: {err}"),
4762 );
4763 }
4764 }
4765 let view = viewers::capture_view(&captured);
4766 let fit = view.measure(&console, &console.options()).maximum;
4767 let code = captured.exit_code();
4770 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4771 if code == 0 || shown.is_err() {
4772 return emit_exit_code(cli, shown);
4773 }
4774 if cli.report_format == ReportFormat::Json {
4776 eprintln!("{}", viewers::capture_report(&captured));
4777 }
4778 ExitCode::from(code)
4779 });
4780 }
4781 if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
4782 return fail(
4783 &cli,
4784 ExitClass::Usage,
4785 "this build has no Mermaid support (the mermaid feature)",
4786 );
4787 }
4788 if mode == Mode::Env {
4789 let view = viewers::env(&cli.viewers, &cli.resources);
4790 let fit = view.measure(&console, &console.options()).maximum;
4791 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4792 }
4793 if mode == Mode::Profile {
4794 let profile = match profile::profile(&cli) {
4795 Ok(profile) => profile,
4796 Err((class, err)) => return fail(&cli, class, err),
4797 };
4798 if cli.report_format == ReportFormat::Json {
4800 let json = serde_json::to_string_pretty(&profile.to_json()).unwrap_or_default();
4801 authoring::out(&format!("{json}\n"));
4802 return ExitClass::Success.exit_code();
4803 }
4804 let view: Box<dyn Renderable> = Box::new(profile);
4805 let fit = view.measure(&console, &console.options()).maximum;
4806 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4807 }
4808 if matches!(mode, Mode::Deps | Mode::Schema | Mode::Chart) {
4809 let view = match mode {
4810 Mode::Deps => sources::deps(&cli).map(|deps| (deps.view, deps.gate)),
4811 Mode::Schema => sources::schema(&cli).map(|view| (view, None)),
4812 _ => chart::chart(&cli).map(|view| (view, None)),
4813 };
4814 let (view, gate) = match view {
4815 Ok(view) => view,
4816 Err((class, err)) => return fail(&cli, class, err),
4817 };
4818 let fit = view.measure(&console, &console.options()).maximum;
4819 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4822 match gate {
4823 Some(message) if shown.is_ok() => fail(cli, ExitClass::Gate, message),
4824 _ => emit_exit_code(cli, shown),
4825 }
4826 });
4827 }
4828 if matches!(mode, Mode::Hex | Mode::Unicode) {
4829 let hex = mode == Mode::Hex;
4832 let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
4833 let length = cli.viewers.length().filter(|_| hex);
4834 let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
4835 let (bytes, more) = match read_window(&cli, offset, max) {
4836 Ok(read) => read,
4837 Err(err) => return fail(&cli, ExitClass::Input, err),
4838 };
4839 if more && length.is_none() {
4840 let hint = if hex {
4841 "use --offset and --length for the rest"
4842 } else {
4843 "pass a shorter input"
4844 };
4845 viewers::notice(
4846 cli.resource.as_deref().unwrap_or("<stdin>"),
4847 &controls::size(controls::BYTES_LIMIT),
4848 hint,
4849 );
4850 }
4851 let view = if hex {
4852 viewers::hex(&cli.viewers, bytes, offset)
4853 } else {
4854 viewers::unicode(&cli.viewers, &bytes)
4855 };
4856 let view = match view {
4857 Ok(view) => view,
4858 Err(err) => return fail(&cli, ExitClass::Usage, err),
4859 };
4860 let fit = view.measure(&console, &console.options()).maximum;
4861 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4862 }
4863
4864 let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
4868 let (mut content, content_type) = if let Some(fetched) = prefetched {
4869 fetched
4870 } else if resource_is_url {
4871 match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
4872 Ok(fetched) => fetched,
4873 Err(err) => {
4874 return fail(&cli, ExitClass::Input, err);
4875 }
4876 }
4877 } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
4878 (cli.resource.clone().unwrap(), None)
4879 } else {
4880 let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
4882 match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
4883 Ok(content) => (content, None),
4884 Err(err) => {
4885 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4886 return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
4887 }
4888 }
4889 };
4890 if cli.sanitize && mode != Mode::AnsiExplain {
4893 content = sanitize_terminal_controls(&content);
4894 }
4895
4896 let mut routed_language = None;
4899 let mut routed = false;
4900 if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto)
4901 && (mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)))
4902 {
4903 routed = true;
4904 mode = match inspect::route(format, &content, cli.resource.as_deref()) {
4905 inspect::Route::Json => Mode::Json,
4906 inspect::Route::Syntax(lexer) => {
4907 routed_language = Some(lexer);
4908 Mode::Syntax
4909 }
4910 inspect::Route::Text => Mode::Auto,
4911 };
4912 }
4913
4914 if cli.mode == Mode::Auto
4919 && !cli.source.wants_syntax()
4920 && (mode == Mode::Auto || (mode == Mode::Syntax && !routed))
4921 {
4922 let auto = cli.data.format == Some(inspect::InputFormat::Auto);
4923 if let Some(document) = documents::by_content(cli.resource.as_deref(), &content, auto) {
4924 mode = document;
4925 }
4926 }
4927
4928 if resource_is_url && mode == Mode::Auto && !routed {
4932 mode = content_type
4933 .as_deref()
4934 .and_then(content_type_mode)
4935 .unwrap_or(Mode::Syntax);
4936 }
4937
4938 if mode == Mode::Auto && cli.source.wants_syntax() {
4941 mode = Mode::Syntax;
4942 }
4943
4944 let json_content = if mode == Mode::Json && cli.sanitize {
4946 match sanitize_json_source(content.trim()) {
4947 Ok(content) => Some(content),
4948 Err(err) => {
4949 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4950 }
4951 }
4952 } else {
4953 None
4954 };
4955
4956 let json = if mode == Mode::Json {
4957 let source = json_content.as_deref().unwrap_or_else(|| content.trim());
4958 match Json::new(source) {
4959 Ok(json) => Some(json),
4960 Err(err) => {
4961 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4962 }
4963 }
4964 } else {
4965 None
4966 };
4967
4968 if mode == Mode::Ipynb {
4973 let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
4974 Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
4975 if cli.sanitize {
4976 sanitize_json_value(&mut value);
4977 }
4978 value
4979 }
4980 Ok(_) => {
4981 return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
4982 }
4983 Err(err) => {
4984 return fail(
4985 &cli,
4986 ExitClass::Data,
4987 format!("invalid notebook JSON: {err}"),
4988 );
4989 }
4990 };
4991 let renderable = match build_ipynb(¬ebook, cli.hyperlinks, cli.justify, &cli.source) {
4992 Ok(renderable) => renderable,
4993 Err(err) => return fail(&cli, ExitClass::Usage, err),
4994 };
4995 let fit = renderable.measure(&console, &console.options()).maximum;
4996 return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
4997 }
4998
4999 if mode == Mode::Print
5003 && let Err(err) = console.try_build_text(&content)
5004 {
5005 return fail(&cli, ExitClass::Data, err.to_string());
5006 }
5007
5008 let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
5013 .filter(|ext| !is_uninformative_ext(ext));
5014 let language = routed_language
5015 .map(str::to_string)
5016 .or(extension)
5017 .or_else(|| {
5018 content_type
5019 .as_deref()
5020 .and_then(content_type_lexer)
5021 .map(str::to_string)
5022 })
5023 .unwrap_or_default();
5024
5025 if let Some(message) = transform_misplaced_in(&cli, mode) {
5027 return fail(&cli, ExitClass::Usage, message);
5028 }
5029
5030 let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
5033 Mode::Markdown => {
5036 #[cfg(feature = "art")]
5038 let micro = micro::markdown(&console, &content, cli.micro_project);
5039 #[cfg(feature = "art")]
5040 let source = micro.as_ref().map_or(content.as_str(), |m| m.source());
5041 #[cfg(not(feature = "art"))]
5042 let source = content.as_str();
5043 let mut markdown = build_markdown(source, cli.hyperlinks);
5044 if let Some(fences) = plugin_registry_with(
5047 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
5048 cli.dot_backend != Some(sources::DotBackend::Off),
5049 )
5050 .fences()
5051 {
5052 markdown = markdown.fence_renderer(fences);
5053 }
5054 #[cfg(feature = "art")]
5055 let view: Box<dyn Renderable> = match micro {
5056 Some(micro) => Box::new(micro.view(markdown)),
5057 None => Box::new(markdown),
5058 };
5059 #[cfg(not(feature = "art"))]
5060 let view: Box<dyn Renderable> = Box::new(markdown);
5061 (view, None)
5062 }
5063 Mode::Rst => {
5067 let lexer = cli.source.lexer.as_deref().unwrap_or("python");
5068 let document = rich_ext::rst::RestructuredText::new(&content).default_lexer(lexer);
5069 (Box::new(document), None)
5070 }
5071 Mode::Org | Mode::AsciiDoc | Mode::Man => {
5074 let fences = plugin_registry_with(
5075 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
5076 cli.dot_backend != Some(sources::DotBackend::Off),
5077 )
5078 .fences();
5079 (documents::render(mode, &content, &cli, fences), None)
5080 }
5081 #[cfg(feature = "mermaid")]
5082 Mode::Mermaid => {
5083 let default = if cfg!(feature = "mmdc") {
5085 MermaidBackend::Mmdc
5086 } else {
5087 MermaidBackend::Text
5088 };
5089 let backend = match cli.mermaid_backend.unwrap_or(default) {
5090 MermaidBackend::Off => MermaidBackend::Text,
5091 chosen => chosen,
5092 };
5093 let diagram =
5094 rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
5095 (Box::new(diagram), None)
5096 }
5097 Mode::Dot => {
5098 let graphviz = cli.dot_backend == Some(sources::DotBackend::Graphviz);
5101 let mut svg_done = false;
5102 if let (true, Some(path)) = (graphviz, export.svg_path) {
5103 svg_done = sources::graphviz_svg(&content, path);
5104 if svg_done {
5105 export.svg_path = None;
5106 }
5107 }
5108 match sources::dot(&content, cli.resource.as_deref()) {
5109 Ok(diagram) => (diagram, None),
5110 Err((_, err)) if svg_done => {
5113 eprintln!("rich: warning: {err}; only Graphviz's SVG was written");
5114 return success(&cli);
5115 }
5116 Err((class, err)) => return fail(&cli, class, err),
5117 }
5118 }
5119 Mode::Json => {
5120 let nested = cli.padding.is_some()
5137 || cli.panel.is_some()
5138 || cli.style.is_some()
5139 || cli.width.is_some()
5140 || cli.pager
5141 || cli.auto_pager;
5142 let json = json.expect("json parsed above").no_wrap(nested);
5143 #[cfg(feature = "json-escape-safe")]
5144 let json = json.escape_safe(true);
5145 let fit = measure_rendered(&console, &json);
5146 (Box::new(json), Some(fit))
5147 }
5148 Mode::Csv => {
5149 if let Err(err) = validate_labels(&cli) {
5150 return fail(&cli, ExitClass::Data, err);
5151 }
5152 let fallback = csv_fallback_delimiter(cli.resource.as_deref());
5155 let rules = cli.rules.rules();
5156 let table = match build_csv_table_with(
5157 &content,
5158 fallback,
5159 cli.title.as_deref(),
5160 cli.caption.as_deref(),
5161 (cli.source.head, cli.source.tail),
5162 cli.source.infer,
5163 (!rules.is_empty()).then_some((&console, rules)),
5164 ) {
5165 Some(table) => table,
5166 None => {
5169 return fail(&cli, ExitClass::Data, "Could not determine delimiter");
5170 }
5171 };
5172 #[cfg(feature = "art")]
5175 let table = {
5176 let mut table = table;
5177 let label = |label: &Option<String>, style| {
5178 label.as_deref().and_then(|label| {
5179 micro::table_label(&console, label, style, cli.micro_project)
5180 })
5181 };
5182 if let Some(title) = label(&cli.title, "table.title") {
5183 table = table.title_text(title);
5184 }
5185 if let Some(caption) = label(&cli.caption, "table.caption") {
5186 table = table.caption_text(caption);
5187 }
5188 table
5189 };
5190 if cli.panel.is_none()
5194 && cli.padding.is_none()
5195 && cli.style.is_none()
5196 && cli.justify.is_none()
5197 && cli.max_width.is_none()
5198 && !cli.soft
5199 && !cli.pager
5200 && !cli.auto_pager
5201 && cli.export_html.is_none()
5202 && cli.export_svg.is_none()
5203 && cli.export_png.is_none()
5204 && !watch::capturing()
5205 && !micro_pending()
5206 {
5207 let mut options = console.options();
5208 options.max_width = cli.width.unwrap_or_else(|| {
5209 table
5210 .measure(&console, &options)
5211 .maximum
5212 .min(options.max_width)
5213 .max(1)
5214 });
5215 let stdout = std::io::stdout();
5216 let mut output = std::io::BufWriter::new(stdout.lock());
5217 let result = table
5218 .try_for_each_line(&console, &options, |line| {
5219 let line = Segment::crop_lines(&line, console.width());
5220 writeln!(output, "{}", console.segments_to_string(&line))
5221 })
5222 .and_then(|()| output.flush());
5223 if let Err(error) = result {
5224 if error.kind() == std::io::ErrorKind::BrokenPipe {
5227 return success(&cli);
5228 }
5229 return fail(
5230 &cli,
5231 ExitClass::Input,
5232 format!("could not write CSV output: {error}"),
5233 );
5234 }
5235 return success(&cli);
5236 }
5237 let fit = measure_rendered(&console, &table);
5238 (Box::new(table), Some(fit))
5239 }
5240 Mode::Syntax if cli.data.transform_option().is_some() => {
5243 let language = cli.source.lexer.as_deref().unwrap_or(&language);
5244 let code = match cli.source.line_range(&content) {
5247 Ok(Some((start, end))) => {
5248 let start = start.max(1) as usize - 1;
5249 let end = end.max(0) as usize;
5250 let lines: Vec<&str> = content.lines().collect();
5251 lines
5252 .get(start.min(lines.len())..end.min(lines.len()))
5253 .unwrap_or_default()
5254 .join("\n")
5255 }
5256 Ok(None) => content.clone(),
5257 Err(err) => return fail(&cli, ExitClass::Usage, err),
5258 };
5259 let text = Syntax::new(code.as_str(), language).highlight_for(&console);
5260 let text = match cli
5261 .data
5262 .text_pipeline(&plugin_registry(MermaidBackend::Text))
5263 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
5264 {
5265 Ok(text) => text,
5266 Err(err) => return fail(&cli, ExitClass::Data, err),
5267 };
5268 let fit = text.measurement().1;
5269 let background = match text.base_style() {
5272 rich::style::StyleType::Style(style) => style.clone(),
5273 rich::style::StyleType::Name(_) => rich::Style::new(),
5274 };
5275 let padded =
5278 rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
5279 (Box::new(padded), Some(fit))
5280 }
5281 Mode::Syntax => {
5282 let syntax = match cli.source.syntax(&content, &language) {
5283 Ok(syntax) => syntax,
5284 Err(err) => return fail(&cli, ExitClass::Usage, err),
5285 };
5286 let fit = syntax.measure(&console, &console.options()).maximum;
5289 (Box::new(syntax), Some(fit))
5290 }
5291 Mode::View => {
5292 let view: Box<dyn Renderable> = match view_as {
5293 Some(viewers::ViewAs::Patch) => {
5294 let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
5295 match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
5296 Ok(outcome) => outcome.renderable,
5297 Err(err) => return fail(&cli, ExitClass::Data, err),
5298 }
5299 }
5300 Some(viewers::ViewAs::Binary) => {
5301 match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
5302 Ok(view) => view,
5303 Err(err) => return fail(&cli, ExitClass::Usage, err),
5304 }
5305 }
5306 Some(viewers::ViewAs::Source(ref lexer)) => {
5307 if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
5308 eprintln!("{summary}");
5309 }
5310 viewers::source(&cli.viewers, &content, lexer)
5311 }
5312 _ => viewers::source(&cli.viewers, &content, "txt"),
5313 };
5314 let fit = view.measure(&console, &console.options()).maximum;
5315 (view, Some(fit))
5316 }
5317 Mode::AnsiExplain => {
5318 let view = tools::ansi_explain(&cli.tools, &content);
5319 let fit = view.measure(&console, &console.options()).maximum;
5320 (view, Some(fit))
5321 }
5322 Mode::Inspect => {
5323 let encoding = cli.extensions.encoding;
5324 let read = |path: &str| {
5325 read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
5326 .map_err(|err| format!("cannot read {path}: {err}"))
5327 };
5328 let rules = cli.rules.rules();
5329 let view = match inspect::build_with(
5330 &cli.data,
5331 &content,
5332 cli.resource.as_deref(),
5333 read,
5334 rules,
5335 ) {
5336 Ok(view) => view,
5337 Err(err) => return fail(&cli, ExitClass::Data, err),
5338 };
5339 let fit = view.measure(&console, &console.options()).maximum;
5340 (view, Some(fit))
5341 }
5342 _ => {
5345 #[cfg(feature = "art")]
5348 let micro = (mode == Mode::Print && cli.emoji)
5349 .then(|| micro::print_markup(&console, &content, cli.micro_project))
5350 .flatten();
5351 #[cfg(feature = "art")]
5352 let mut text = match micro {
5353 Some(text) => text,
5354 None if mode == Mode::Print => console.build_text(&content),
5355 None => Text::new(content.as_str()),
5356 };
5357 #[cfg(not(feature = "art"))]
5358 let mut text = if mode == Mode::Print {
5359 console.build_text(&content)
5360 } else {
5361 Text::new(content.as_str())
5362 };
5363 if let Some(justify) = cli.text_justify {
5367 text.set_justify(justify);
5368 }
5369 if cli.source.no_wrap {
5370 text.set_no_wrap(Some(true));
5371 }
5372 let text = match cli
5373 .data
5374 .text_pipeline(&plugin_registry(MermaidBackend::Text))
5375 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
5376 {
5377 Ok(text) => text,
5378 Err(err) => return fail(&cli, ExitClass::Data, err),
5379 };
5380 let fit = text.measurement().1;
5381 #[cfg(feature = "art")]
5383 let renderable = micro::rows(text);
5384 #[cfg(not(feature = "art"))]
5385 let renderable: Box<dyn Renderable> = Box::new(text);
5386 (renderable, Some(fit))
5387 }
5388 };
5389 decorate_and_emit(&cli, &console, &export, renderable, fit)
5390}
5391
5392#[allow(dead_code)]
5395fn exit_code(ok: bool) -> ExitCode {
5396 if ok {
5397 ExitCode::SUCCESS
5398 } else {
5399 ExitCode::FAILURE
5400 }
5401}
5402
5403fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
5404 match result {
5405 Ok(()) => success(cli),
5406 Err(message) => fail(cli, ExitClass::Input, message),
5407 }
5408}
5409
5410fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
5411 let output = console.render_export(renderable);
5412 let stdout = std::io::stdout();
5413 let mut lock = stdout.lock();
5414 lock.write_all(output.as_bytes())
5415}
5416
5417fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
5418 if cli.extensions.encoding.is_some() {
5419 return fail(
5420 cli,
5421 ExitClass::Usage,
5422 "--encoding is not supported with streaming JSONL/log mode",
5423 );
5424 }
5425 if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
5426 return fail(
5427 cli,
5428 ExitClass::Usage,
5429 "streaming JSONL/log mode reads files or stdin, not URLs",
5430 );
5431 }
5432 if log_mode {
5433 return logs::run(cli, console);
5434 }
5435
5436 let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
5437 Some(path) if path != "-" => {
5438 let file = &fs_path(path);
5439 if file.is_dir() {
5440 return fail(
5441 cli,
5442 ExitClass::Input,
5443 format!("cannot read {path}: is a directory, not a file"),
5444 );
5445 }
5446 match std::fs::File::open(file) {
5447 Ok(file) => Box::new(std::io::BufReader::new(file)),
5448 Err(err) => {
5449 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
5450 }
5451 }
5452 }
5453 _ => {
5454 if std::io::stdin().is_terminal() {
5455 let eof = if cfg!(windows) {
5456 "Ctrl-Z then Enter"
5457 } else {
5458 "Ctrl-D"
5459 };
5460 eprintln!("rich: reading stdin; finish input with {eof}");
5461 }
5462 Box::new(std::io::BufReader::new(std::io::stdin()))
5463 }
5464 };
5465
5466 let mut line = String::new();
5467 let mut line_number = 0usize;
5468 loop {
5469 line.clear();
5470 let read = match input.read_line(&mut line) {
5471 Ok(read) => read,
5472 Err(err) => {
5473 return fail(
5474 cli,
5475 ExitClass::Input,
5476 format!("cannot read JSONL stream: {err}"),
5477 );
5478 }
5479 };
5480 if read == 0 {
5481 break;
5482 }
5483 line_number += 1;
5484 let record = line.trim_end_matches(['\r', '\n']);
5485 let mut value = match serde_json::from_str::<serde_json::Value>(record) {
5486 Ok(value) => value,
5487 Err(err) => {
5488 return fail(
5489 cli,
5490 ExitClass::Data,
5491 format!("invalid JSONL at line {line_number}: {err}"),
5492 );
5493 }
5494 };
5495 if cli.sanitize {
5496 sanitize_json_value(&mut value);
5497 }
5498 let source = serde_json::to_string(&value).expect("JSON value serializes");
5499 let json = Json::new(&source).expect("serialized JSON parses");
5500 let wrote = write_rendered_stdout(console, &json);
5501 if let Err(error) = wrote {
5502 if error.kind() == std::io::ErrorKind::BrokenPipe {
5503 return success(cli);
5504 }
5505 return fail(
5506 cli,
5507 ExitClass::Input,
5508 format!("could not write JSONL output: {error}"),
5509 );
5510 }
5511 }
5512 success(cli)
5513}
5514
5515fn is_number(value: &str) -> bool {
5521 let body = value.strip_prefix('-').unwrap_or(value);
5522 let (integer, fraction) = match body.split_once('.') {
5523 Some((integer, fraction)) => (integer, Some(fraction)),
5524 None => (body, None),
5525 };
5526 let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
5527 digits(integer) && fraction.is_none_or(digits)
5528}
5529
5530fn build_csv_table(
5549 content: &str,
5550 fallback_delimiter: Option<char>,
5551 title: Option<&str>,
5552 caption: Option<&str>,
5553 window: (Option<usize>, Option<usize>),
5554 infer: bool,
5555) -> Option<Table> {
5556 build_csv_table_with(
5557 content,
5558 fallback_delimiter,
5559 title,
5560 caption,
5561 window,
5562 infer,
5563 None,
5564 )
5565}
5566
5567fn build_csv_table_with(
5570 content: &str,
5571 fallback_delimiter: Option<char>,
5572 title: Option<&str>,
5573 caption: Option<&str>,
5574 window: (Option<usize>, Option<usize>),
5575 infer: bool,
5576 rules: Option<(&Console, &rich_ext::table::StyleRules)>,
5577) -> Option<Table> {
5578 let sample: String = content.chars().take(1024).collect();
5581 let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
5582 let (dialect, header) = match sniffed {
5583 Some(sniffed) => sniffed,
5584 None => (Dialect::excel(fallback_delimiter?), true),
5585 };
5586 let mut rows = read_rows(content, &dialect);
5587 if window.0.is_some() || window.1.is_some() {
5590 let header_row = if header && !rows.is_empty() {
5591 Some(rows.remove(0))
5592 } else {
5593 None
5594 };
5595 let mut data: Vec<Vec<String>> = rows.into_iter().filter(|row| !row.is_empty()).collect();
5596 match window {
5597 (Some(head), _) => data.truncate(head),
5598 (None, Some(tail)) => {
5599 data.drain(..data.len().saturating_sub(tail));
5600 }
5601 (None, None) => {}
5602 }
5603 rows = header_row.into_iter().chain(data).collect();
5604 }
5605 let types = infer.then(|| infer_csv_types(&rows, header));
5606 Some(render_csv(rows, header, title, caption, types, rules))
5607}
5608
5609fn infer_csv_types(rows: &[Vec<String>], has_header: bool) -> Vec<InferredType> {
5613 let width = rows.iter().map(Vec::len).max().unwrap_or(0);
5614 let mut data = rich_data::Rows::new((0..width).map(|index| index.to_string()));
5615 for row in rows
5616 .iter()
5617 .skip(usize::from(has_header))
5618 .filter(|row| !row.is_empty())
5619 {
5620 data.push(
5621 (0..width)
5622 .map(|index| rich_data::Value::from(row.get(index).map_or("", String::as_str))),
5623 );
5624 }
5625 Inferrer::new()
5626 .infer(&data)
5627 .columns()
5628 .iter()
5629 .map(|column| column.data_type())
5630 .collect()
5631}
5632
5633fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
5640 let name = resource.unwrap_or_default().to_lowercase();
5641 if name.ends_with(".csv") {
5642 Some(',')
5643 } else if name.ends_with(".tsv") {
5644 Some('\t')
5645 } else {
5646 None
5647 }
5648}
5649
5650fn render_csv(
5664 rows: Vec<Vec<String>>,
5665 has_header: bool,
5666 title: Option<&str>,
5667 caption: Option<&str>,
5668 types: Option<Vec<InferredType>>,
5669 rules: Option<(&Console, &rich_ext::table::StyleRules)>,
5670) -> Table {
5671 let mut table = Table::new()
5672 .border_style(Style::parse("blue").expect("valid style"))
5673 .show_header(has_header || types.is_some())
5674 .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
5675 if let Some(title) = title {
5676 table = table.title(title);
5677 }
5678 if let Some(caption) = caption {
5679 table = table.caption(caption);
5680 }
5681
5682 let empty: Vec<String> = Vec::new();
5683 let (header, body) = if has_header {
5684 match rows.split_first() {
5685 Some((header, body)) => (header, body),
5686 None => return table,
5687 }
5688 } else {
5689 (&empty, rows.as_slice())
5690 };
5691 let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
5693
5694 let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
5699 let columns = header.len().max(widest);
5700
5701 for index in 0..columns {
5702 let inferred = types
5707 .as_ref()
5708 .map(|types| types.get(index).copied().unwrap_or(InferredType::Null));
5709 let numeric = match inferred {
5710 Some(inferred) => matches!(inferred, InferredType::Integer | InferredType::Float),
5711 None => data.iter().all(|row| match row.get(index) {
5712 Some(value) => value.is_empty() || is_number(value),
5713 None => false,
5714 }),
5715 };
5716 let name = header.get(index).map(String::as_str).unwrap_or("");
5717 if let Some(inferred) = inferred {
5718 let mut heading = Text::new(name);
5719 if !name.is_empty() {
5720 heading.append("\n", None);
5721 }
5722 heading.append(
5723 inferred.name(),
5724 Some(Style::parse("dim").expect("valid style").into()),
5725 );
5726 let justify = if numeric {
5727 Justify::Right
5728 } else {
5729 Justify::Left
5730 };
5731 table.add_column_text(heading, justify);
5732 } else if numeric {
5733 table.add_column_justify(name, Justify::Right);
5734 } else {
5735 table.add_column(name);
5736 }
5737 if numeric {
5738 table.column_style(Style::parse("bold green").expect("valid style"));
5739 table.column_header_fill(Style::parse("bold green").expect("valid style"));
5740 }
5741 }
5742 drop(data);
5745 let headers: Vec<String> = match rules {
5746 Some(_) => (0..columns)
5747 .map(|index| header.get(index).cloned().unwrap_or_default())
5748 .collect(),
5749 None => Vec::new(),
5750 };
5751 let body = rows
5752 .into_iter()
5753 .skip(usize::from(has_header))
5754 .filter(|row| !row.is_empty());
5755 match rules.filter(|(_, rules)| !rules.is_empty()) {
5756 None => {
5757 table.extend_owned_rows(body.collect());
5758 }
5759 Some((console, rules)) => {
5760 add_styled_csv_rows(&mut table, console, rules, &headers, body.collect())
5761 }
5762 }
5763 table
5764}
5765
5766fn add_styled_csv_rows(
5770 table: &mut Table,
5771 console: &Console,
5772 rules: &rich_ext::table::StyleRules,
5773 headers: &[String],
5774 rows: Vec<Vec<String>>,
5775) {
5776 use rich_ext::table::{Target, Value};
5777 let headers: Vec<&str> = headers.iter().map(String::as_str).collect();
5778 let values = |row: &[String]| -> Vec<Value> { row.iter().cloned().map(Value::Str).collect() };
5779 let resolved = if rules
5781 .rules()
5782 .iter()
5783 .any(|r| r.target_kind() == Target::Column)
5784 {
5785 rules.resolve(&headers, rows.iter().map(|row| values(row)))
5786 } else {
5787 rules.resolve(&headers, std::iter::empty::<Vec<Value>>())
5788 };
5789 style_rules::warn_unresolved(&resolved, &headers);
5790 for row in rows {
5791 let cells: Vec<Value> = row.into_iter().map(Value::Str).collect();
5792 let row_style = resolved.row_style(&cells);
5793 let styles: Vec<Option<Style>> = (0..cells.len())
5794 .map(|index| resolved.cell_style(&cells, index))
5795 .collect();
5796 let cells = cells
5797 .into_iter()
5798 .zip(styles)
5799 .map(|(cell, style)| {
5800 let markup = match cell {
5801 Value::Str(text) => text,
5802 other => other.plain(),
5803 };
5804 match style {
5805 None => rich::Cell::Markup(markup),
5806 Some(style) => {
5807 let mut text = console.render_str(&markup, Some(false));
5808 let len = text.plain().len();
5809 text.stylize(style, 0, len);
5810 rich::Cell::Text(text)
5811 }
5812 }
5813 })
5814 .collect();
5815 table.add_row_with(cells, row_style.map(Into::into), false);
5816 }
5817}
5818
5819fn join_source(value: &serde_json::Value) -> String {
5822 match value {
5823 serde_json::Value::String(s) => s.clone(),
5824 serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5825 _ => String::new(),
5826 }
5827}
5828
5829fn join_traceback(value: &serde_json::Value) -> String {
5833 match value {
5834 serde_json::Value::String(s) => s.clone(),
5835 serde_json::Value::Array(a) => a
5836 .iter()
5837 .filter_map(|v| v.as_str())
5838 .collect::<Vec<_>>()
5839 .join(
5840 "
5841",
5842 ),
5843 _ => String::new(),
5844 }
5845}
5846
5847fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5851 let base_style = Style::parse(base).ok();
5852 let number_style = Style::parse(number).ok();
5853 let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5854 let mut text = Text::new("");
5855 text.append(&format!("{word}["), base_style.clone().map(Into::into));
5856 text.append(&n, number_style.map(Into::into));
5857 text.append("]:", base_style.map(Into::into));
5858 text
5859}
5860
5861struct Notebook {
5864 items: Vec<Box<dyn Renderable>>,
5865 justify: Option<Justify>,
5868}
5869
5870impl Renderable for Notebook {
5871 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5872 let mut options = options.clone();
5873 if let Some(justify) = self.justify {
5874 options.justify = justify;
5875 }
5876 let mut segments = Vec::new();
5877 for item in &self.items {
5878 let rendered = item.rich_render(console, &options);
5879 if !rendered.is_empty() {
5880 segments.extend(rendered);
5881 segments.push(Segment::line());
5882 }
5883 }
5884 if !segments.is_empty() {
5886 segments.pop();
5887 }
5888 segments
5889 }
5890
5891 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5892 self.items
5893 .iter()
5894 .map(|item| item.measure(console, options))
5895 .fold(Measurement::new(0, 0), |width, item| {
5896 Measurement::new(
5897 width.minimum.max(item.minimum),
5898 width.maximum.max(item.maximum),
5899 )
5900 })
5901 }
5902}
5903
5904fn notebook_ansi(source: &str) -> Text {
5906 let mut result = Text::new("");
5907 let mut decoder = AnsiDecoder::new();
5910 let lines: Vec<Text> = source
5911 .lines()
5912 .map(|line| decoder.decode_line(line))
5913 .collect();
5914 for (index, line) in lines.iter().enumerate() {
5915 if index > 0 {
5916 result.append("\n", None);
5917 }
5918 result = result.append_text(line);
5919 }
5920 result
5921}
5922
5923fn build_ipynb(
5926 notebook: &serde_json::Value,
5927 hyperlinks: bool,
5928 justify: Option<Justify>,
5929 source_options: &SourceOptions,
5930) -> Result<Notebook, String> {
5931 let language = notebook["metadata"]["kernelspec"]["language"]
5932 .as_str()
5933 .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5934 .unwrap_or("python");
5935 let empty = Vec::new();
5936 let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5937 let mut new_line = true;
5938 for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5939 if new_line {
5940 items.push(text(""));
5941 }
5942 if cell.get("execution_count").is_some() {
5943 items.push(Box::new(io_label(
5944 "In ",
5945 cell["execution_count"].as_i64().filter(|n| *n != 0),
5946 "green",
5947 "#66ff00",
5948 )));
5949 }
5950 let source = join_source(&cell["source"]);
5951 items.push(match cell["cell_type"].as_str().unwrap_or("") {
5952 "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5953 "code" => Box::new(
5956 Panel::new(Box::new(source_options.syntax(&source, language)?))
5957 .border_style(Style::parse("dim").expect("valid style")),
5958 ),
5959 _ => text(&source),
5960 });
5961 new_line = true;
5962 for output in cell["outputs"].as_array().unwrap_or(&empty) {
5963 let rendered = match output["output_type"].as_str().unwrap_or("") {
5964 "stream" => {
5965 new_line = false;
5966 notebook_ansi(&join_source(&output["text"]))
5967 }
5968 "error" => {
5969 new_line = true;
5970 notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5971 }
5972 "execute_result" => {
5973 new_line = true;
5974 let mut label = io_label(
5975 "Out",
5976 output["execution_count"].as_i64().filter(|n| *n != 0),
5977 "red",
5978 "#ee4b2b",
5979 );
5980 label.append("\n", None);
5981 label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
5982 }
5983 _ => continue,
5984 };
5985 items.push(Box::new(rendered));
5986 }
5987 }
5988 Ok(Notebook { items, justify })
5989}
5990
5991fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5992 let displayed = |value: f32| {
5995 format!("{value:.1}")
5996 .parse::<f32>()
5997 .expect("formatted percentage is a number")
5998 };
5999 displayed(changed) > displayed(limit)
6000}
6001
6002fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6005 let mut contents = Vec::with_capacity(cli.resources.len());
6006 for resource in &cli.resources {
6007 let content = if is_url(resource) {
6008 fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
6009 } else {
6010 read_resource(Some(resource), cli.extensions.encoding)
6011 .map_err(|err| format!("cannot read {resource}: {err}"))
6012 };
6013 match content {
6014 Ok(content) => contents.push(content),
6015 Err(err) => return fail(cli, ExitClass::Input, err),
6016 }
6017 }
6018 let names: Vec<String> = cli
6019 .resources
6020 .iter()
6021 .map(|r| {
6022 if r == "-" {
6023 "<stdin>".to_string()
6024 } else {
6025 r.clone()
6026 }
6027 })
6028 .collect();
6029 let outcome = match tools::text_diff(
6030 &cli.tools,
6031 &names,
6032 &contents,
6033 cli.diff_threshold,
6034 cli.sanitize,
6035 ) {
6036 Ok(outcome) => outcome,
6037 Err(err) => return fail(cli, ExitClass::Data, err),
6038 };
6039 let fit = outcome
6040 .renderable
6041 .measure(console, &console.options())
6042 .maximum;
6043 let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
6044 if code != ExitCode::SUCCESS || !outcome.failed {
6045 return code;
6046 }
6047 fail(cli, ExitClass::Gate, "diff threshold exceeded")
6048}
6049
6050#[cfg(feature = "art")]
6066fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
6067 if fs_path(path).is_dir() {
6068 return Err(format!("cannot read {path}: is a directory, not a file"));
6069 }
6070 match rich_art::image::open(fs_path(path)) {
6071 Ok(image) => Ok(image),
6072 Err(err) => {
6073 use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
6074 let hint = match &err {
6075 rich_art::image::ImageError::Unsupported(error) => match error.kind() {
6076 UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) => {
6077 Some(format!(
6078 "unsupported image extension {}; use a supported image format such as PNG, JPEG, GIF or WebP",
6079 ext.display()
6080 ))
6081 }
6082 _ => None,
6083 },
6084 _ => None,
6085 };
6086 Err(format!(
6087 "cannot read {path}: {}",
6088 hint.unwrap_or_else(|| err.to_string())
6089 ))
6090 }
6091 }
6092}
6093
6094#[cfg(feature = "art")]
6100fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
6101 match mode {
6102 ImageMode::Auto => rich_art::ImageMode::Auto,
6103 ImageMode::Sixel => rich_art::ImageMode::Sixel,
6104 ImageMode::Blocks => rich_art::ImageMode::Blocks,
6105 ImageMode::Braille => rich_art::ImageMode::Braille,
6106 ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
6107 ImageMode::Ascii => rich_art::ImageMode::Ascii,
6108 ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
6109 }
6110}
6111
6112#[cfg(feature = "art")]
6119fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6120 use rich_art::ImageArt;
6121
6122 let Some(resource) = cli.resource.as_deref() else {
6123 return fail(
6124 cli,
6125 ExitClass::Usage,
6126 "--image requires an image file: rich --image photo.png (or `-` for stdin)",
6127 );
6128 };
6129
6130 let image = if resource == "-" {
6131 let mut buffer = Vec::new();
6132 if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
6133 return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
6134 }
6135 match rich_art::image::load_from_memory(&buffer) {
6136 Ok(image) => image,
6137 Err(err) => {
6138 return fail(
6139 cli,
6140 ExitClass::Input,
6141 format!("cannot decode image from stdin: {err}"),
6142 );
6143 }
6144 }
6145 } else {
6146 match open_image_path(resource) {
6147 Ok(image) => image,
6148 Err(message) => return fail(cli, ExitClass::Input, message),
6149 }
6150 };
6151
6152 let width = cli.width.unwrap_or_else(|| console.width());
6153 let mut art = ImageArt::new(image)
6154 .transforms(rich_art::ImageTransforms {
6155 rotation: match cli.image_rotate {
6156 Some(90) => rich_art::Rotation::Clockwise90,
6157 Some(180) => rich_art::Rotation::Clockwise180,
6158 Some(270) => rich_art::Rotation::Clockwise270,
6159 _ => rich_art::Rotation::None,
6160 },
6161 flip_horizontal: cli.image_flip_horizontal,
6162 flip_vertical: cli.image_flip_vertical,
6163 grayscale: cli.image_grayscale,
6164 brightness: cli.image_brightness.unwrap_or(1.0),
6165 contrast: cli.image_contrast.unwrap_or(1.0),
6166 gamma: cli.image_gamma.unwrap_or(1.0),
6167 })
6168 .mode(to_art_image_mode(cli.image_mode))
6169 .width(width)
6170 .color(!cli.no_color);
6171 if let Some(height) = cli.height {
6172 art = art.height(height);
6173 }
6174 if let Some(columns) = cli.image_max_width {
6175 art = art.max_width(columns);
6176 }
6177 if let Some(rows) = cli.image_max_height {
6178 art = art.max_height(rows);
6179 }
6180
6181 if let Some(fit) = cli.image_fit.as_deref() {
6182 art = art.fit(match fit {
6183 "cover" => rich_art::ImageFit::Cover,
6184 "stretch" => rich_art::ImageFit::Stretch,
6185 "native" => rich_art::ImageFit::Native,
6186 _ => rich_art::ImageFit::Contain,
6187 });
6188 }
6189 if let Some(anchor) = cli.image_anchor.as_deref() {
6190 use rich_art::ImageAnchor;
6191 art = art.anchor(match anchor {
6192 "top" => ImageAnchor::Top,
6193 "bottom" => ImageAnchor::Bottom,
6194 "left" => ImageAnchor::Left,
6195 "right" => ImageAnchor::Right,
6196 "top-left" => ImageAnchor::TopLeft,
6197 "top-right" => ImageAnchor::TopRight,
6198 "bottom-left" => ImageAnchor::BottomLeft,
6199 "bottom-right" => ImageAnchor::BottomRight,
6200 _ => ImageAnchor::Center,
6201 });
6202 }
6203 match cli.image_background {
6204 Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
6205 Some(ImageBackdrop::Terminal) => {
6206 art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
6207 }
6208 Some(ImageBackdrop::Checkerboard) => {
6209 art = art.background_mode(rich_art::ImageBackground::Checkerboard)
6210 }
6211 None => {}
6212 }
6213 let (color_mode, dither, distance) = image_color_processing(cli);
6214 art = art
6215 .color_mode(color_mode)
6216 .dither(dither)
6217 .color_distance(distance);
6218
6219 let options = console.options();
6224 if let Err(err) = art.render(console, &options) {
6225 let message = match err {
6229 rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
6230 "this terminal (TERM={}) is not known to support Sixel graphics; set \
6231 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
6232 --image-mode blocks, braille or ascii",
6233 std::env::var("TERM").unwrap_or_default()
6234 ),
6235 err => err.to_string(),
6236 };
6237 return fail(cli, ExitClass::Input, message);
6238 }
6239
6240 let terminal_only = Export {
6241 html_path: None,
6242 svg_path: None,
6243 png_path: None,
6244 ..*export
6245 };
6246 if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
6247 return fail(cli, ExitClass::Input, message);
6248 }
6249 if export.any_file() {
6250 use rich::protocol::RenderEnvironment;
6251 use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
6252 let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
6253 caps.color_system = if cli.no_color {
6254 None
6255 } else {
6256 Some(ColorSystem::Truecolor)
6257 };
6258 let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
6259 let export_console = target.console();
6260 if let Err(err) =
6261 art.render_with_environment(&export_console, &export_console.options(), &target)
6262 {
6263 return fail(cli, ExitClass::Input, err.to_string());
6264 }
6265 let segments = target.segments(&art);
6266 debug_assert!(!target.capabilities().interactive);
6267 if let Err(message) = save_exports(&export_console, export, &segments) {
6268 return fail(cli, ExitClass::Input, message);
6269 }
6270 }
6271 success(cli)
6272}
6273
6274#[cfg(feature = "art")]
6275fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
6276 use rich::Table;
6277 use rich_art::imagediff::{DiffSettings, diff};
6278 use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
6279
6280 let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
6281 let before = match open_image_path(before_path) {
6282 Ok(image) => image,
6283 Err(message) => return fail(cli, ExitClass::Input, message),
6284 };
6285 let after = match open_image_path(after_path) {
6286 Ok(image) => image,
6287 Err(message) => return fail(cli, ExitClass::Input, message),
6288 };
6289
6290 let report = match diff(&before, &after, &DiffSettings::default()) {
6291 Ok(report) => report,
6292 Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
6293 };
6294
6295 let width = cli.width.unwrap_or_else(|| console.width());
6296 let changed = report.changed_fraction * 100.0;
6297 let naive = report.naive_changed_fraction * 100.0;
6298
6299 let failed = cli
6308 .diff_threshold
6309 .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
6310
6311 let render_report = |c: &Console, for_export: bool| {
6312 let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
6315
6316 let has_color = c.color_system().is_some() && !cli.no_color;
6323 let is_terminal = c.is_terminal();
6324 let mut mode = cli.image_mode;
6325 if mode == ImageMode::Auto {
6326 if !has_color && !for_export && cli.report_format == ReportFormat::Human {
6327 eprintln!(
6328 "rich: auto image mode has no terminal colour, drawing the diff as ASCII instead"
6329 );
6330 }
6331 mode = if !has_color {
6332 ImageMode::Ascii
6333 } else if is_terminal && rich_art::sixel::is_probably_supported() {
6334 ImageMode::Sixel
6335 } else {
6336 ImageMode::Blocks
6337 };
6338 }
6339
6340 if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
6344 if !for_export && cli.report_format == ReportFormat::Human {
6345 eprintln!("rich: no colour available, drawing the diff as ASCII art");
6346 }
6347 mode = ImageMode::Ascii;
6348 }
6349 if mode == ImageMode::Sixel && !is_terminal {
6350 if cli.image_mode != ImageMode::Sixel
6351 && !for_export
6352 && cli.report_format == ReportFormat::Human
6353 {
6354 eprintln!(
6355 "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
6356 if has_color { "blocks" } else { "ASCII art" }
6357 );
6358 }
6359 mode = if has_color {
6360 ImageMode::Blocks
6361 } else {
6362 ImageMode::Ascii
6363 };
6364 }
6365
6366 match mode {
6367 ImageMode::None => {}
6368 ImageMode::Ascii => c.print(
6369 &AsciiArt::new(report.heatmap())
6370 .width(width)
6371 .color(has_color),
6372 ),
6373 ImageMode::Blocks => c.print(
6374 &BlockArt::new(report.heatmap())
6375 .width(width)
6376 .height(rows_cap),
6377 ),
6378 ImageMode::Quadrants => c.print(
6379 &rich_art::QuadrantArt::new(report.heatmap())
6380 .width(width)
6381 .height(rows_cap),
6382 ),
6383 ImageMode::Braille => c.print(
6384 &BrailleArt::new(report.heatmap())
6385 .width(width)
6386 .height(rows_cap),
6387 ),
6388 ImageMode::Sixel => {
6389 let art = SixelArt::new(report.heatmap())
6390 .width(width)
6391 .height(rows_cap);
6392 if art.encode(width).is_some() {
6395 c.print(&art);
6396 } else {
6397 if cli.report_format == ReportFormat::Human {
6398 eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
6399 }
6400 c.print(
6401 &BlockArt::new(report.heatmap())
6402 .width(width)
6403 .height(rows_cap),
6404 );
6405 }
6406 }
6407 ImageMode::Auto => unreachable!("resolved above"),
6408 }
6409 if mode != ImageMode::None {
6413 c.print_str("");
6414 }
6415 c.print_str(&format!(
6416 "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
6417 [dim](a plain pixel diff would say {naive:.1}%)[/]"
6418 ));
6419
6420 if report.regions.is_empty() {
6421 c.print_str("[dim]No region large enough to report.[/]");
6422 } else {
6423 let mut table = Table::new().title("Where it changed");
6424 table.add_column("#");
6425 table.add_column_justify("Share", rich::Justify::Right);
6426 table.add_column_justify("Mean ΔE", rich::Justify::Right);
6427 table.add_column_justify("Area px", rich::Justify::Right);
6428 table.add_column("Box (x,y w×h)");
6429 for (rank, r) in report.regions.iter().enumerate() {
6430 table.add_row(&[
6431 &format!("{}", rank + 1),
6432 &format!("{:.0}%", r.share_of_change * 100.0),
6433 &format!("{:.1}", r.mean_delta_e),
6434 &format!("{}", r.area_px),
6435 &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
6436 ]);
6437 }
6438 c.print(&table);
6439 }
6440
6441 if let Some(limit) = cli.diff_threshold {
6445 if failed {
6446 c.print_str(&format!(
6447 "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
6448 ));
6449 } else {
6450 c.print_str(&format!(
6451 "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
6452 ));
6453 }
6454 }
6455 };
6456
6457 let terminal_only = Export {
6458 html_path: None,
6459 svg_path: None,
6460 png_path: None,
6461 ..*export
6462 };
6463 let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
6464 if export.any_file() {
6465 let mut export_console = crate::console_builder()
6469 .force_terminal(false)
6470 .color_system(Some(ColorSystem::Truecolor))
6471 .no_color(cli.no_color)
6472 .width(console.width())
6473 .height(console.height())
6474 .theme(console.theme().clone())
6475 .build();
6476 install_render_defaults(&mut export_console);
6477 let segments = export_console.record_output(|c| render_report(c, true));
6478 wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
6479 }
6480
6481 if let Err(message) = wrote {
6482 return fail(cli, ExitClass::Input, message);
6483 }
6484 if failed {
6485 return fail(cli, ExitClass::Gate, "diff threshold exceeded");
6486 }
6487 success(cli)
6488}
6489
6490#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6493#[cfg_attr(not(feature = "art"), allow(dead_code))]
6494enum ImageBackdrop {
6495 Color([u8; 3]),
6497 Terminal,
6499 Checkerboard,
6501}
6502
6503impl ImageBackdrop {
6504 fn parse(value: &str) -> Option<Self> {
6505 match value {
6506 "default" => return Some(ImageBackdrop::Terminal),
6507 "checkerboard" => return Some(ImageBackdrop::Checkerboard),
6508 _ => {}
6509 }
6510 let hex = value.strip_prefix('#')?;
6511 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
6512 return None;
6513 }
6514 let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
6515 Some(ImageBackdrop::Color([
6516 channel(0)?,
6517 channel(1)?,
6518 channel(2)?,
6519 ]))
6520 }
6521}
6522
6523#[cfg(feature = "art")]
6526fn image_color_processing(
6527 cli: &Cli,
6528) -> (
6529 rich_art::ImageColorMode,
6530 rich_art::Dither,
6531 rich_art::ColorDistance,
6532) {
6533 use rich_art::{ColorDistance, Dither, ImageColorMode};
6534 let mode = match cli.image_color.as_deref() {
6535 Some("ansi256") => ImageColorMode::Ansi256,
6536 Some("ansi16") => ImageColorMode::Ansi16,
6537 Some("grayscale") => ImageColorMode::Grayscale,
6538 _ => ImageColorMode::TrueColor,
6539 };
6540 let dither = match cli.image_dither.as_deref() {
6541 Some("floyd-steinberg") => Dither::FloydSteinberg,
6542 Some("bayer4x4") => Dither::Bayer4x4,
6543 Some("atkinson") => Dither::Atkinson,
6544 _ => Dither::None,
6545 };
6546 let distance = match cli.image_color_distance.as_deref() {
6547 Some("oklab") => ColorDistance::Oklab,
6548 _ => ColorDistance::Rgb,
6549 };
6550 (mode, dither, distance)
6551}
6552
6553#[cfg(feature = "art")]
6554fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
6555 use rich_art::{AnimatedArt, Repeat, Stage, Until};
6556
6557 if cli.resources.is_empty() {
6558 return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
6559 }
6560 let count = cli.resources.len();
6561 const GAP: usize = 2;
6562
6563 let total = cli.width.unwrap_or_else(|| console.width());
6565 let per_gif = total
6566 .saturating_sub(GAP * count.saturating_sub(1))
6567 .checked_div(count)
6568 .unwrap_or(total)
6569 .max(8);
6570
6571 let repeat = match cli.loops {
6572 Some(0) => Repeat::Forever,
6573 Some(n) => Repeat::Times(n),
6574 None => Repeat::Times(1),
6575 };
6576
6577 let mut arts = Vec::with_capacity(count);
6578 for path in &cli.resources {
6579 match AnimatedArt::from_path(path) {
6580 Ok(art) => {
6581 let (color_mode, dither, distance) = image_color_processing(cli);
6582 arts.push(
6583 art.width(per_gif)
6584 .blocks(cli.extensions.gif_blocks())
6585 .color(!cli.no_color)
6586 .color_mode(color_mode)
6587 .dither(dither)
6588 .color_distance(distance)
6589 .repeat(repeat),
6590 );
6591 }
6592 Err(err) => {
6593 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
6594 }
6595 }
6596 }
6597
6598 if !console.is_terminal() && cli.report_format == ReportFormat::Human {
6599 eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
6600 }
6601
6602 let mut builder = crate::console_builder().no_color(cli.no_color);
6604 if cli.themed() {
6605 builder = builder.theme(cli_theme(cli));
6606 }
6607 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
6610 builder = builder.force_terminal(terminal == "1");
6611 }
6612 if let Some(width) = cli.width {
6613 builder = builder.width(width);
6614 }
6615 let console = builder.build();
6616 let played = if cli.animation.needs_player() && console.is_terminal() {
6617 let passes = match repeat {
6619 Repeat::Forever => None,
6620 Repeat::Times(n) => Some(n),
6621 Repeat::Once => Some(1),
6622 };
6623 animation::play(arts, GAP, passes, &cli.animation, console)
6624 } else {
6625 let fps = cli.animation.fps();
6627 let mut stage = Stage::new().gap(GAP);
6628 for art in arts {
6629 stage = stage.with(art.max_fps(fps));
6630 }
6631 if let Some(limit) = cli.animation.max_duration {
6632 stage = stage.until(Until::Elapsed(limit));
6633 }
6634 stage.play_stdout(console)
6635 };
6636 match played {
6637 Ok(()) => success(cli),
6638 Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
6639 }
6640}
6641
6642#[cfg(not(feature = "art"))]
6644fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
6645 fail(
6646 _cli,
6647 ExitClass::Usage,
6648 "this build has no GIF support (rebuild with the `art` feature)",
6649 )
6650}
6651
6652struct Export<'a> {
6655 html_path: Option<&'a str>,
6657 svg_path: Option<&'a str>,
6659 png_path: Option<&'a str>,
6661 svg_title: &'a str,
6663 pager: bool,
6665 auto_pager: bool,
6666}
6667
6668impl Export<'_> {
6669 fn terminal_only(&self) -> bool {
6672 !watch::capturing() && !self.any_file() && !self.pager && !self.auto_pager
6673 }
6674
6675 fn any_file(&self) -> bool {
6678 self.html_path.is_some() || self.svg_path.is_some() || self.png_path.is_some()
6679 }
6680}
6681
6682fn should_page(
6693 explicit: bool,
6694 automatic: bool,
6695 terminal: bool,
6696 lines: usize,
6697 height: usize,
6698) -> bool {
6699 terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
6700}
6701
6702fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
6703 if export.terminal_only() {
6704 render(console);
6705 return Ok(());
6706 }
6707 emit_segments(console, export, console.record_output(render))
6708}
6709
6710fn emit_segments(console: &Console, export: &Export, segments: Vec<Segment>) -> Result<(), String> {
6713 if watch::capturing() {
6714 watch::capture_segments(segments);
6716 return Ok(());
6717 }
6718 let mut first_error = None;
6719
6720 let terminal = console.segments_to_string(&segments);
6722 if should_page(
6723 export.pager,
6724 export.auto_pager,
6725 std::io::stdout().is_terminal(),
6726 terminal.lines().count(),
6727 console.height(),
6728 ) {
6729 if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
6732 first_error = Some(format!("cannot page output: {err}"));
6733 }
6734 } else {
6735 let stdout = std::io::stdout();
6736 let mut lock = stdout.lock();
6737 if let Err(err) = lock.write_all(terminal.as_bytes()) {
6738 first_error = Some(format!("could not write output: {err}"));
6739 }
6740 }
6741
6742 save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
6743}
6744
6745fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
6746 let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
6747 let segments = filtered.as_slice();
6748 let mut first_error = None;
6749 if let Some(path) = export.html_path {
6750 let html = rich::export::export_html_classes(segments, &DEFAULT_TERMINAL_THEME);
6752 if let Err(err) = std::fs::write(fs_path(path), html) {
6753 first_error = Some(format!("failed to save HTML: {err}"));
6754 }
6755 }
6756 if let Some(path) = export.svg_path {
6757 let svg = rich::svg::export_svg(
6758 segments,
6759 &rich::SVG_EXPORT_THEME,
6760 export.svg_title,
6761 "rich-cli",
6764 console.width(),
6765 );
6766 if let Err(err) = std::fs::write(fs_path(path), svg) {
6767 first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
6768 }
6769 }
6770 if let Some(path) = export.png_path
6771 && let Err(message) = save_png(path, segments, export.svg_title, console.width())
6772 {
6773 first_error.get_or_insert(message);
6774 }
6775 first_error.map_or(Ok(()), Err)
6776}
6777
6778#[cfg(feature = "png")]
6782fn save_png(path: &str, segments: &[Segment], title: &str, width: usize) -> Result<(), String> {
6783 use rich_record::render::png::{PngOptions, segments_png};
6784 let options = PngOptions {
6785 title,
6786 columns: Some(width),
6787 ..PngOptions::default()
6788 };
6789 let fonts = rich_record::render::raster::Fonts::embedded();
6790 let png = segments_png(segments, &fonts, &options)
6791 .map_err(|err| format!("failed to save PNG: {err}"))?;
6792 std::fs::write(fs_path(path), png).map_err(|err| format!("failed to save PNG: {err}"))
6793}
6794
6795#[cfg(not(feature = "png"))]
6797fn save_png(_: &str, _: &[Segment], _: &str, _: usize) -> Result<(), String> {
6798 Err("--export-png needs a build with the png feature".into())
6799}
6800
6801fn print_help(no_color: bool) {
6803 cli_spec::print_help(no_color);
6804}
6805
6806fn build_demo_console(no_color: bool) -> Console {
6807 let mut console = demo_console_builder(no_color).build();
6808 console.install_extensions();
6809 console
6810}
6811
6812fn demo_console_builder(no_color: bool) -> rich::console::ConsoleBuilder {
6814 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6819 crate::console_builder()
6820 .force_terminal(to_terminal)
6821 .no_color(no_color)
6822 .color_system(if to_terminal {
6823 Some(ColorSystem::Truecolor)
6824 } else {
6825 None
6826 })
6827 .theme(rich_ext::extended_theme())
6830}
6831
6832fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
6833 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6834 let console = build_demo_console(no_color);
6835
6836 demo::section(&console, delay, "rs-rich-cli");
6837 console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
6838 console.print_str(
6839 "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
6840 );
6841
6842 demo::section(&console, delay, "markup · color · theme · extension");
6844 console.print_str(
6845 "Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
6846 );
6847 console.print_str(
6848 "Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
6849 );
6850 console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
6851 let mut themed = build_demo_console(no_color);
6853 let release =
6854 rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
6855 themed.use_theme(release).print_str(
6856 "Stack: [release]release[/] from a pushed theme, popped when its guard drops",
6857 );
6858 console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
6859 let hl = crate::console_builder()
6865 .force_terminal(to_terminal)
6866 .no_color(no_color)
6867 .color_system(if to_terminal {
6868 Some(ColorSystem::Truecolor)
6869 } else {
6870 None
6871 })
6872 .highlight(true)
6873 .build();
6874 hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
6875 let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
6877 ISO8601Highlighter::new().highlight(&mut stamp);
6878 console.print(&Text::new("ISO 8601: ").append_text(&stamp));
6879 console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
6880
6881 demo::section(&console, delay, "rule");
6883 console.print(&Rule::line());
6884 demo::section(&console, delay, "centered");
6885 console.print(&Rule::new("left").align(HorizontalAlign::Left));
6886 console.print(&Rule::new("right").align(HorizontalAlign::Right));
6887
6888 demo::section(&console, delay, "panel");
6890 console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
6891 console.print(
6892 &Panel::new(text("square box"))
6893 .box_set(SQUARE)
6894 .title("square"),
6895 );
6896 console.print(
6897 &Panel::new(text("double box"))
6898 .box_set(DOUBLE)
6899 .title("double"),
6900 );
6901 console.print(
6902 &Panel::new(text("title + subtitle"))
6903 .title("top")
6904 .subtitle("bottom"),
6905 );
6906 let legacy = crate::console_builder()
6908 .force_terminal(to_terminal)
6909 .no_color(no_color)
6910 .color_system(if to_terminal {
6911 Some(ColorSystem::Truecolor)
6912 } else {
6913 None
6914 })
6915 .width(console.width())
6916 .legacy_windows(true)
6917 .build();
6918 legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6919
6920 demo::section(&console, delay, "padding");
6922 console.print(
6923 &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6924 );
6925
6926 demo::section(&console, delay, "styled");
6928 console.print(&Styled::new(
6929 Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6930 Style::parse("green").unwrap(),
6931 ));
6932
6933 demo::section(&console, delay, "align");
6935 console.print(&Align::left(text("← left")));
6936 console.print(&Align::center(text("center")));
6937 console.print(&Align::right(text("right →")));
6938
6939 demo::section(&console, delay, "justify");
6941 console.print_justified("left justified", Justify::Left);
6942 console.print_justified("centered", Justify::Center);
6943 console.print_justified("right justified", Justify::Right);
6944
6945 demo::section(&console, delay, "constrain (width 24)");
6947 console.print(&Constrain::new(
6948 Box::new(Panel::new(text("constrained")).title("≤24")),
6949 Some(24),
6950 ));
6951
6952 demo::section(&console, delay, "table");
6954 let mut table = Table::new().title("ported renderables");
6955 table
6957 .add_column("Renderable")
6958 .column_style(Style::parse("cyan").unwrap());
6959 table.add_column("Module").column_width(12);
6960 table.add_column_justify("Parity", Justify::Center);
6961 for (renderable, module, parity) in [
6962 ("Text / markup", "text, markup", "✓"),
6963 ("Rule", "rule", "✓"),
6964 ("Panel", "panel", "✓"),
6965 ("Padding", "padding", "✓"),
6966 ("Align", "align", "✓"),
6967 ("Constrain", "constrain", "✓"),
6968 ("Columns", "columns", "✓"),
6969 ("Table", "table", "✓"),
6970 ("Tree", "tree", "✓"),
6971 ("ProgressBar", "progress_bar", "✓"),
6972 ] {
6973 table.add_row(&[renderable, module, parity]);
6974 }
6975 console.print(&table);
6976
6977 demo::section(&console, delay, "columns");
6979 let months = [
6980 "January",
6981 "February",
6982 "March",
6983 "April",
6984 "May",
6985 "June",
6986 "July",
6987 "August",
6988 "September",
6989 "October",
6990 "November",
6991 "December",
6992 ];
6993 console.print(&Columns::new(
6994 months.iter().map(|m| m.to_string()).collect(),
6995 ));
6996
6997 demo::section(&console, delay, "tree");
6999 let mut tree = Tree::new("rs-rich-cli");
7000 let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
7001 core.add("color · style · text · console");
7002 core.add("box · rule · panel · padding · align");
7003 core.add("table · tree · wrap · filesize");
7004 tree.add("crates/rich-ext (our extensions)");
7005 tree.add("crates/rich-cli (this binary)");
7006 console.print(&tree);
7007
7008 demo::section(&console, delay, "progress bar");
7010 for pct in [0.0, 33.0, 66.0, 100.0] {
7011 console.print(&ProgressBar::new(100.0, pct).width(48));
7012 }
7013
7014 demo::section(&console, delay, "progress");
7018 let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
7019 let set_time =
7020 |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
7021 let reader = clock.clone();
7022 let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
7023 let mut progress = Progress::new().clock(now.clone());
7024 let downloading = progress.add_task("Downloading", 100.0, 0.0);
7025 let processing = progress.add_task("Processing", 100.0, 0.0);
7026 progress.add_task("Waiting", 100.0, 0.0);
7027 set_time(4.0);
7028 progress.advance(downloading, 20.0);
7029 progress.advance(processing, 60.0);
7030 set_time(8.0);
7031 progress.advance(downloading, 30.0);
7032 progress.advance(processing, 40.0);
7033 console.print(&progress);
7034
7035 let mut transfer = Progress::new().clock(now).columns(vec![
7037 ProgressColumn::spinner(),
7038 ProgressColumn::Description,
7039 ProgressColumn::Bar,
7040 ProgressColumn::Download,
7041 ProgressColumn::TransferSpeed,
7042 ProgressColumn::TimeElapsed,
7043 ProgressColumn::time_remaining(),
7044 ]);
7045 let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
7046 let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
7047 set_time(12.0);
7048 transfer.advance(iso, 1_200_000_000.0);
7049 transfer.advance(notes, 20_000.0);
7050 set_time(20.0);
7051 transfer.advance(iso, 900_000_000.0);
7052 transfer.advance(notes, 22_000.0);
7053 console.print(&transfer);
7054
7055 let mut mofn = Progress::new().columns(vec![
7057 ProgressColumn::Description,
7058 ProgressColumn::Bar,
7059 ProgressColumn::MofN,
7060 ]);
7061 mofn.add_task("Files", 8.0, 3.0);
7062 mofn.add_task("Chunks", 128.0, 128.0);
7063 console.print(&mofn);
7064
7065 let mut download = Progress::new().columns(vec![
7067 ProgressColumn::Description,
7068 ProgressColumn::Bar,
7069 ProgressColumn::Download,
7070 ]);
7071 download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
7072 console.print(&download);
7073
7074 demo::section(&console, delay, "json");
7076 if let Ok(json) = Json::new(
7077 r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
7078 ) {
7079 console.print(&json);
7080 }
7081
7082 demo::section(&console, delay, "pretty");
7084 let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
7085 console.print(&Pretty::new(&value));
7086
7087 demo::section(&console, delay, "log");
7089 let log = LogRender::new().show_level(true);
7090 for (level, message) in [
7091 (LogLevel::Info, "server started on port 8080"),
7092 (LogLevel::Debug, "cache warm: 128 entries"),
7093 (LogLevel::Warn, "disk usage at 85%"),
7094 (LogLevel::Error, "connection reset by peer"),
7095 ] {
7096 console.print(&log.render(
7097 &console,
7098 Text::new(message),
7099 Some(Text::new("[12:00:00]")),
7100 level.text(),
7101 Some("main.rs"),
7102 Some(42),
7103 None,
7104 ));
7105 }
7106 let logging = || {
7110 let mut logger = demo_console_builder(no_color)
7111 .get_datetime(|| std::time::UNIX_EPOCH + std::time::Duration::from_secs(12 * 3600))
7112 .build();
7113 logger.install_extensions();
7114 logger
7115 };
7116 let logger = logging();
7117 for (line, message) in [
7118 (
7119 7,
7120 "[bold]console.log[/]: the time, the message and the caller",
7121 ),
7122 (8, "a log in the same second leaves the time blank"),
7123 ] {
7124 let _ = logger.log_at(
7125 &[rich::LogObject::Str(message)],
7126 &rich::LogOptions::default(),
7127 "<demo>",
7128 line,
7129 );
7130 }
7131 let live = Progress::new()
7133 .clock(|| 0.0)
7134 .columns(vec![
7135 ProgressColumn::Description,
7136 ProgressColumn::Bar,
7137 ProgressColumn::MofN,
7138 ])
7139 .start(logging(), std::io::stdout(), 10.0);
7140 let deploy = live.add_task("Deploying", 3.0, 0.0);
7141 for (line, step) in [(12, "built"), (13, "tested"), (14, "shipped")] {
7142 if !delay.is_zero() {
7143 std::thread::sleep(delay / 3);
7144 }
7145 let message = format!("progress.log: [green]{step}[/]");
7146 let _ = live.log_at(
7147 &[rich::LogObject::Str(&message)],
7148 &rich::LogOptions::default(),
7149 "<demo>",
7150 line,
7151 );
7152 live.advance(deploy, 1.0);
7153 }
7154 let _ = live.stop();
7155
7156 demo::section(&console, delay, "traceback");
7158 let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
7159 let err = std::io::Error::other(cause);
7160 console.print(&Traceback::new(&err));
7161
7162 demo::section(&console, delay, "bar (range)");
7164 for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
7165 console.print(&Bar::new(100.0, begin, end).width(48));
7166 }
7167
7168 demo::section(&console, delay, "markdown");
7170 console.print(&Markdown::new(
7171 "# Heading\n\nA paragraph with **bold**, *italic*, `code`, ~~struck~~ text, and a [link](https://example.com).\nTilde runs pair as upstream pairs them: a ~~~x~~~ b.\n\n- bullet item\n- another\n\n1. first\n2. second\n\n> a block quote\n\n| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\n---",
7172 ));
7173
7174 demo::section(&console, delay, "syntax");
7176 console.print(&Syntax::new(
7177 "fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
7178 "rust",
7179 ));
7180
7181 demo::section(&console, delay, "control codes");
7183 let show_escape = |label: &str, control: &Control| {
7184 let escaped = control
7186 .as_str()
7187 .replace('\x1b', "\\x1b")
7188 .replace('\x07', "\\a")
7189 .replace('\r', "\\r");
7190 console.print(&Text::new(format!(" {label:>14}: {escaped}")));
7191 };
7192 show_escape("clear screen", &Control::clear());
7193 show_escape("home", &Control::home());
7194 show_escape("move (2,-1)", &Control::move_(2, -1));
7195 show_escape("move_to (3,4)", &Control::move_to(3, 4));
7196 show_escape("hide cursor", &Control::show_cursor(false));
7197 show_escape("bell", &Control::bell());
7198 let live_render = LiveRender::new(text("a\nb\nc"));
7200 let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
7202 let live_console = crate::console_builder()
7205 .force_terminal(true)
7206 .color_system(Some(ColorSystem::Truecolor))
7207 .width(20)
7208 .build();
7209 let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
7210 live.start();
7211 live.update(text("frame two"));
7212 live.stop();
7213 let stream = String::from_utf8_lossy(live.writer())
7214 .replace('\x1b', "\\x1b")
7215 .replace('\r', "\\r")
7216 .replace('\n', "\\n");
7217 console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
7218
7219 demo::section(&console, delay, "ansi decoder");
7221 let raw =
7222 "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
7223 console.print(&Text::new(format!(
7224 " input: {}",
7225 raw.replace('\x1b', "\\x1b")
7226 )));
7227 let mut decoder = rich::AnsiDecoder::new();
7228 for line in raw.lines().map(|line| decoder.decode_line(line)) {
7229 console.print(&Text::new(" output: ").append_text(&line));
7230 }
7231
7232 demo::section(&console, delay, "capture · export_text");
7234 let plain = console.export_text(|c| {
7235 c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
7236 });
7237 console.print(&Text::new(" export_text (styles stripped):"));
7238 for line in plain.lines() {
7239 console.print(&Text::new(format!(" {line}")));
7240 }
7241 let html_rule = Style::parse("bold red")
7243 .unwrap()
7244 .get_html_style(&DEFAULT_TERMINAL_THEME);
7245 console.print(&Text::new(format!(
7246 " export_html: [bold red] → <span style=\"{html_rule}\">"
7247 )));
7248 let recorder = demo_console_builder(no_color)
7250 .record(true)
7251 .width(40)
7252 .build();
7253 recorder.print_str("[bold magenta]recorded[/] by a [green]record=True[/] console");
7254 let plain = recorder
7255 .export_text_recorded(false, false)
7256 .unwrap_or_default();
7257 let html = recorder
7258 .export_html_recorded(&rich::ExportHtmlOptions {
7259 clear: false,
7260 inline_styles: true,
7261 ..Default::default()
7262 })
7263 .unwrap_or_default();
7264 let svg = recorder
7265 .export_svg_recorded(&rich::ExportSvgOptions {
7266 title: "demo",
7267 ..Default::default()
7268 })
7269 .unwrap_or_default();
7270 console.print(&Text::new(format!(
7271 " export_text_recorded: {:?}",
7272 plain.trim_end()
7273 )));
7274 let span = html
7276 .lines()
7277 .find_map(|line| line.find("<span").map(|at| &line[at..]))
7278 .and_then(|span| span.split("</span>").next())
7279 .map_or_else(
7280 || format!("{} bytes of HTML", html.len()),
7281 |span| format!("{span}</span>"),
7282 );
7283 console.print(&Text::new(format!(" export_html_recorded: {span}")));
7284 console.print(&Text::new(format!(
7285 " export_svg_recorded: {} bytes of SVG",
7286 svg.len()
7287 )));
7288
7289 demo::section(&console, delay, "layout");
7292 let panel_leaf = |title: &str, body: &str| {
7293 Layout::with_renderable(Box::new(
7294 Panel::new(text(body))
7295 .box_set(SQUARE)
7296 .title(title.to_string()),
7297 ))
7298 };
7299 let mut header = Layout::new();
7300 header.split_row(vec![
7301 panel_leaf("left", "pane A"),
7302 panel_leaf("right", "pane B"),
7303 ]);
7304 let mut root = Layout::new();
7305 root.split_column(vec![
7306 header,
7307 Layout::with_renderable(text("footer (fixed size 3)")).size(3),
7308 ]);
7309 let grid = crate::console_builder()
7310 .force_terminal(true)
7311 .color_system(Some(ColorSystem::Truecolor))
7312 .width(48)
7313 .height(7)
7314 .build();
7315 for line in grid.export_text(|c| c.print(&root)).lines() {
7316 console.print(&Text::new(line));
7317 }
7318
7319 demo::section(&console, delay, "spinner (frames)");
7321 for name in ["dots", "line", "arrow", "simpleDots"] {
7322 let spinner = Spinner::new(name);
7323 let frames: String = (0..6)
7324 .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
7325 .collect::<Vec<_>>()
7326 .join(" ");
7327 console.print_str(&format!(" {name:>11}: {frames}"));
7328 }
7329 let status = Status::new("Loading…").renderable().render(0.0);
7331 console.print(&Text::new(" status: ").append_text(&status));
7332 if to_terminal && !delay.is_zero() {
7335 let _ = std::io::stdout().flush();
7336 let running = console.status(Status::new("console.status: working…"));
7337 std::thread::sleep(delay);
7338 running.stop();
7339 }
7340 console.print_str(" console.status: a spinner on a live display while work runs");
7341
7342 demo::section(&console, delay, "csv");
7344 let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
7345 if let Some(table) = build_csv_table(csv, Some(','), None, None, (None, None), false) {
7346 console.print(&table);
7347 }
7348
7349 demo::section(&console, delay, "filesize");
7351 for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
7352 console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
7353 }
7354 console.print(&Rule::line());
7355 console
7356}
7357
7358#[cfg(test)]
7359mod tests {
7360 use super::*;
7361 use rich::ColorSystem;
7362
7363 const MODE_FLAGS: &[&str] = &[
7365 "--print",
7366 "-p",
7367 "--markdown",
7368 "-m",
7369 "--json",
7370 "-j",
7371 "-J",
7372 "--syntax",
7373 "-x",
7374 "--csv",
7375 "--ipynb",
7376 "--rst",
7377 "--jsonl",
7378 "--ndjson",
7379 "--log",
7380 "--rule",
7381 "-u",
7382 "--image",
7383 "--gif",
7384 "--diff",
7385 "--inspect",
7386 "--ansi-explain",
7387 ];
7388
7389 #[test]
7392 fn a_mode_flag_before_a_subcommand_word_makes_it_a_resource() {
7393 for flag in MODE_FLAGS {
7394 assert!(mode_flag_alias(flag).is_some(), "{flag}");
7395 }
7396 let claimed = |args: &[String]| {
7397 let mut words = Vec::new();
7398 if plugins::requested(args) {
7399 words.push("plugins");
7400 }
7401 if doctor::requested(args) {
7402 words.push("doctor");
7403 }
7404 if spin::requested(args) {
7405 words.push("spin");
7406 }
7407 if console::requested(args) {
7408 words.push("console");
7409 }
7410 #[cfg(feature = "art")]
7411 if micro::requested(args) {
7412 words.push("micro");
7413 }
7414 #[cfg(feature = "record")]
7415 if record::requested(args) {
7416 words.push("record");
7417 }
7418 #[cfg(feature = "serve")]
7419 if serve::requested(args) {
7420 words.push("serve");
7421 }
7422 #[cfg(feature = "interact")]
7423 words.extend(interactive::requested(args));
7424 #[cfg(feature = "interact")]
7425 words.extend(approve_jump::requested(args));
7426 if tools::bench_dispatch(args).unwrap_or(true) {
7427 words.push("bench");
7428 }
7429 let roots = ConfigRoots {
7430 home: None,
7431 cwd: std::env::temp_dir(),
7432 no_color_env: false,
7433 };
7434 if !matches!(config::inspect(args, &roots), Ok(None)) {
7435 words.push("config");
7436 }
7437 words
7438 };
7439 let words = [
7440 "plugins", "doctor", "record", "serve", "choose", "filter", "input", "confirm",
7441 "pager", "date", "bench", "config", "spin", "console",
7442 ];
7443 for word in words {
7444 let plain = claimed(&[word.to_string(), "--no-color".into()]);
7446 assert!(plain.is_empty() || plain == [word], "{word}: {plain:?}");
7447 for flag in MODE_FLAGS {
7448 let args = [flag.to_string(), word.to_string()];
7449 assert_eq!(claimed(&args), Vec::<&str>::new(), "{flag} {word}");
7450 let args = ["--no-color".to_string(), flag.to_string(), word.to_string()];
7451 assert_eq!(claimed(&args), Vec::<&str>::new(), "{args:?}");
7452 }
7453 }
7454 assert!(plugins::requested(&["--no-color".into(), "plugins".into()]));
7455 assert!(doctor::requested(&["doctor".into()]));
7456 }
7457
7458 #[cfg(feature = "art")]
7459 #[test]
7460 fn diff_threshold_uses_the_reports_tie_rounding() {
7461 assert!(!diff_threshold_exceeded(0.25, 0.24));
7463 assert!(diff_threshold_exceeded(0.75, 0.74));
7464 assert!(!diff_threshold_exceeded(0.75, 0.76));
7465 }
7466
7467 #[test]
7468 fn detects_urls() {
7469 assert!(is_url("http://example.com"));
7470 assert!(is_url("https://example.com/x"));
7471 assert!(is_url("HTTPS://example.com"));
7473 assert!(is_url("Http://example.com"));
7474 assert!(!is_url("example.com"));
7475 assert!(!is_url("./file.md"));
7476 assert!(!is_url("-"));
7477 assert!(!is_url("")); assert!(!is_url("ht"));
7479 assert!(!is_url("ftp://example.com"));
7480 }
7481
7482 #[test]
7483 fn uninformative_extensions_defer_to_content_type() {
7484 assert!(is_uninformative_ext("txt"));
7485 assert!(is_uninformative_ext("log"));
7486 assert!(!is_uninformative_ext("py"));
7487 assert!(!is_uninformative_ext("rs"));
7488 }
7489
7490 #[test]
7491 fn resource_ext_uses_basename_without_query() {
7492 assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
7493 assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
7494 assert_eq!(
7495 resource_ext("https://x.com/c.py?raw=1").as_deref(),
7496 Some("py")
7497 );
7498 assert_eq!(resource_ext("https://api.example.com/data"), None);
7500 assert_eq!(resource_ext("https://x.com/"), None);
7501 assert_eq!(resource_ext("noext"), None);
7502 }
7503
7504 #[test]
7505 fn content_type_maps_mode_and_lexer() {
7506 assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
7507 assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
7508 assert_eq!(
7509 content_type_mode("application/json; charset=utf-8"),
7510 Some(Mode::Json)
7511 );
7512 assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
7513 assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
7515 assert_eq!(content_type_lexer("text/x-python"), Some("python"));
7516 assert_eq!(content_type_lexer("text/plain"), None);
7517 }
7518
7519 #[test]
7520 fn join_source_handles_string_and_array() {
7521 use serde_json::json;
7522 assert_eq!(join_source(&json!("a\nb")), "a\nb");
7523 assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
7525 assert_eq!(join_source(&json!(null)), "");
7526 }
7527
7528 #[test]
7529 fn ipynb_extension_auto_detects() {
7530 assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
7531 }
7532
7533 #[test]
7534 fn io_label_builds_execution_count() {
7535 assert_eq!(
7537 io_label("In ", Some(1), "green", "#66ff00").plain(),
7538 "In [1]:"
7539 );
7540 assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
7541 }
7542
7543 #[test]
7544 fn parse_padding_unpacks_like_upstream() {
7545 assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
7546 assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
7547 assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
7548 assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
7549 assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
7551 }
7552
7553 #[test]
7554 fn parse_box_maps_names() {
7555 assert!(matches!(parse_box("rounded"), Ok(Some(_))));
7556 assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
7557 assert!(matches!(parse_box("none"), Ok(None)));
7559 assert!(parse_box("bogus").is_err());
7560 }
7561
7562 #[test]
7563 fn paging_policy_respects_viewport_and_redirection() {
7564 assert!(!should_page(true, true, false, 100, 24));
7565 assert!(!should_page(false, true, true, 23, 24));
7566 assert!(should_page(false, true, true, 24, 24));
7567 assert!(should_page(true, false, true, 1, 24));
7568 assert!(!should_page(false, false, true, 100, 24));
7569 }
7570
7571 #[test]
7572 fn worker_arguments_preserve_resources_named_like_commands() {
7573 for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
7574 let args: Vec<String> = source.into_iter().map(str::to_string).collect();
7575 assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
7576 }
7577 }
7578
7579 #[test]
7580 fn worker_arguments_keep_option_values_and_remove_batch_operands() {
7581 let args: Vec<String> = [
7582 "--batch",
7583 "--title",
7584 "--batch",
7585 "--jobs",
7586 "2",
7587 "json",
7588 "--export-html",
7589 "out.html",
7590 "--",
7591 "--pager",
7592 ]
7593 .into_iter()
7594 .map(str::to_string)
7595 .collect();
7596 assert_eq!(
7597 worker_args(&args),
7598 ["--no-config", "--title", "--batch", "--json"]
7599 );
7600 }
7601
7602 #[test]
7604 fn worker_arguments_keep_the_code_highlighter() {
7605 let args: Vec<String> = [
7606 "--batch",
7607 "--highlighter",
7608 "syntect",
7609 "--code-theme",
7610 "ansi_dark",
7611 "a.py",
7612 ]
7613 .into_iter()
7614 .map(str::to_string)
7615 .collect();
7616 assert_eq!(
7617 worker_args(&args),
7618 [
7619 "--no-config",
7620 "--highlighter",
7621 "syntect",
7622 "--code-theme",
7623 "ansi_dark"
7624 ]
7625 );
7626 }
7627
7628 #[test]
7629 fn parses_pager_flag() {
7630 let s = |v: &str| v.to_string();
7631 assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
7632 assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
7633 }
7634
7635 #[test]
7636 fn parses_watch_options_and_rejects_orphans() {
7637 let s = |v: &str| v.to_string();
7638 let cli = parse(&[
7639 s("--watch"),
7640 s("--watch-interval"),
7641 s("0.25"),
7642 s("--watch-cache"),
7643 s("file.md"),
7644 ])
7645 .unwrap()
7646 .unwrap();
7647 assert!(cli.watch);
7648 assert_eq!(cli.watch_interval, 0.25);
7649 assert!(cli.watch_cache);
7650 assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
7651 assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
7652 }
7653
7654 #[test]
7655 fn parses_multi_file_watch_options() {
7656 let s = |v: &str| v.to_string();
7657 let parse = parse_inner;
7658 let cli = parse(&[
7659 s("--watch"),
7660 s("--watch-debounce"),
7661 s("0.3"),
7662 s("--watch-poll"),
7663 s("--watch-exit-on-error"),
7664 s("a.md"),
7665 s("b.json"),
7666 ])
7667 .unwrap()
7668 .unwrap();
7669 assert_eq!(cli.resources, ["a.md", "b.json"]);
7670 assert_eq!(cli.watch_debounce, 0.3);
7671 assert!(cli.watch_poll && cli.watch_exit_on_error);
7672 let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
7673 assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
7674 assert!(!default.watch_poll && !default.watch_exit_on_error);
7675 for orphan in [
7676 vec![s("--watch-debounce"), s("0.2"), s("a.md")],
7677 vec![s("--watch-poll"), s("a.md")],
7678 vec![s("--watch-exit-on-error"), s("a.md")],
7679 ] {
7680 assert!(
7681 parse(&orphan)
7682 .err()
7683 .unwrap()
7684 .contains("only has an effect with --watch")
7685 );
7686 }
7687 for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
7688 assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
7689 }
7690 assert!(parse(&[s("a.md"), s("b.md")]).is_err());
7692 }
7693
7694 #[test]
7695 fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
7696 let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
7697 let _ = std::fs::remove_file(&path);
7698 let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
7699 std::fs::write(&path, "first").unwrap();
7700 let first = watch_fingerprint(path.to_str().unwrap(), false, None);
7701 std::fs::write(&path, "second content").unwrap();
7702 let second = watch_fingerprint(path.to_str().unwrap(), false, None);
7703 let _ = std::fs::remove_file(&path);
7704 assert_eq!(missing, "missing");
7705 assert_ne!(first, missing);
7706 assert_ne!(first, second);
7707 }
7708
7709 #[test]
7710 fn parses_batch_controls_and_cli_values_override_config_defaults() {
7711 let s = |v: &str| v.to_string();
7712 let cli = parse(&[
7713 s("--no-config"),
7714 s("--batch"),
7715 s("--jobs"),
7716 s("3"),
7717 s("-o"),
7718 s("out.html"),
7719 s("--continue-on-error"),
7720 s("--collision"),
7721 s("suffix"),
7722 s("input"),
7723 ])
7724 .unwrap()
7725 .unwrap();
7726 assert!(cli.batch);
7727 assert_eq!(cli.jobs, 3);
7728 assert!(cli.continue_on_error);
7729 assert_eq!(cli.collision, CollisionPolicy::Suffix);
7730 }
7731
7732 #[test]
7733 fn batch_expansion_is_recursive_sorted_and_deduplicated() {
7734 let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
7735 let _ = std::fs::remove_dir_all(&root);
7736 let nested = root.join("nested");
7737 std::fs::create_dir_all(&nested).unwrap();
7738 std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
7739 std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
7740
7741 let root_arg = root.to_string_lossy().into_owned();
7742 let glob_arg = root
7743 .join("*richbatchonly.md")
7744 .to_string_lossy()
7745 .into_owned();
7746 let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
7747 assert_eq!(inputs.len(), 2);
7748 assert!(inputs[0].ends_with("richbatchnested.md"));
7749 assert!(inputs[1].ends_with("richbatchonly.md"));
7750
7751 std::fs::remove_dir_all(root).unwrap();
7752 }
7753
7754 #[test]
7755 fn batch_output_suffixes_are_deterministic_for_collisions() {
7756 let inputs = ["one.md", "two.md"];
7757 let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
7758 let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
7759 assert_eq!(first, "one.html");
7760 assert_eq!(second, "two.html");
7761 assert_eq!(
7762 Path::new("one.html")
7763 .with_file_name("one-2.html")
7764 .to_string_lossy(),
7765 "one-2.html"
7766 );
7767 }
7768
7769 #[test]
7770 fn config_profiles_support_profiles_section_and_source_errors() {
7771 let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
7772 std::fs::create_dir_all(&root).unwrap();
7773 let config = root.join("config.toml");
7774 std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
7775 let args = vec![
7776 "--config".to_string(),
7777 config.to_string_lossy().into_owned(),
7778 "--profile".to_string(),
7779 "ci".to_string(),
7780 ];
7781 let merged = config_args(
7782 &args,
7783 &ConfigRoots {
7784 home: None,
7785 cwd: root.clone(),
7786 no_color_env: false,
7787 },
7788 )
7789 .unwrap();
7790 assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
7791 assert!(
7792 merged
7793 .windows(2)
7794 .any(|pair| pair[0] == "--jobs" && pair[1] == "4")
7795 );
7796
7797 let bad = root.join("bad.toml");
7798 std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
7799 let error = config_args(
7800 &["--config".to_string(), bad.to_string_lossy().into_owned()],
7801 &ConfigRoots {
7802 home: None,
7803 cwd: root.clone(),
7804 no_color_env: false,
7805 },
7806 )
7807 .unwrap_err();
7808 assert!(error.contains("config "));
7809 assert!(error.contains("unknown key"));
7810 std::fs::remove_dir_all(root).unwrap();
7811 }
7812
7813 #[test]
7814 fn batch_rejects_modes_that_consume_their_whole_resource_list() {
7815 let s = |v: &str| v.to_string();
7816 let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
7817 .err()
7818 .unwrap();
7819 assert!(error.contains("--batch cannot be combined with --diff"));
7820 let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
7821 .err()
7822 .unwrap();
7823 assert!(error.contains("--batch cannot be combined with --gif"));
7824 let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
7826 .err()
7827 .unwrap();
7828 assert!(error.contains("--pager cannot be combined with --batch"));
7829 }
7830
7831 #[test]
7832 fn line_counts_follow_python_splitlines() {
7833 assert_eq!(python_line_count(""), 0);
7834 assert_eq!(python_line_count("a"), 1);
7835 assert_eq!(python_line_count("a\n"), 1);
7836 assert_eq!(python_line_count("a\n\n"), 2);
7837 assert_eq!(python_line_count("a\r\nb"), 2);
7838 assert_eq!(
7839 python_line_count("a\rb\x0bc\x0cd\x1ce\u{85}f\u{2028}g\u{2029}h"),
7840 8
7841 );
7842 }
7843
7844 #[test]
7845 fn glob_matches_metacharacters_rather_than_substrings() {
7846 assert!(glob_match("*.md", "notes.md"));
7847 assert!(!glob_match("*.md", "notes.md.bak"));
7849 assert!(glob_match("a?c.md", "abc.md"));
7850 assert!(!glob_match("a?c.md", "ac.md"));
7851 assert!(glob_match("*", "anything"));
7852 assert!(glob_match("a*b*c", "axxbyyc"));
7853 assert!(!glob_match("a*b*c", "axxbyy"));
7854 }
7855
7856 #[test]
7857 fn batch_expansion_skips_symlinked_directories() {
7858 let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
7859 let _ = std::fs::remove_dir_all(&root);
7860 std::fs::create_dir_all(&root).unwrap();
7861 std::fs::write(root.join("a.md"), "# a").unwrap();
7862 #[cfg(unix)]
7864 let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
7865 #[cfg(windows)]
7866 let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
7867 let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
7868 if linked {
7869 assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
7870 }
7871 assert!(inputs[0].ends_with("a.md"));
7872 let _ = std::fs::remove_dir_all(&root);
7873 }
7874
7875 #[test]
7876 fn subcommand_mode_wins_over_config_mode_default() {
7877 let s = |v: &str| v.to_string();
7878 assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
7879 assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
7880 assert!(!selects_mode_explicitly(&[
7882 s("--title"),
7883 s("json"),
7884 s("file")
7885 ]));
7886 assert!(!selects_mode_explicitly(&[s("file.md")]));
7887
7888 let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
7889 let _ = std::fs::remove_dir_all(&root);
7890 std::fs::create_dir_all(&root).unwrap();
7891 let config = root.join("config.toml");
7892 std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
7893 let merged = config_args(
7894 &[
7895 "--config".to_string(),
7896 config.to_string_lossy().into_owned(),
7897 "json".to_string(),
7898 "file.json".to_string(),
7899 ],
7900 &ConfigRoots {
7901 home: None,
7902 cwd: root.clone(),
7903 no_color_env: false,
7904 },
7905 )
7906 .unwrap();
7907 assert!(
7908 !merged.iter().any(|arg| arg == "--markdown"),
7909 "config mode must not override an explicit subcommand: {merged:?}"
7910 );
7911 let _ = std::fs::remove_dir_all(&root);
7912 }
7913
7914 #[test]
7915 fn suffix_collisions_step_past_files_already_on_disk() {
7916 let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
7917 let _ = std::fs::remove_dir_all(&root);
7918 std::fs::create_dir_all(&root).unwrap();
7919 let target = root.join("out.html");
7920 std::fs::write(&target, "existing").unwrap();
7921 let target = target.to_string_lossy().into_owned();
7922
7923 let cli = parse(&[
7924 "--no-config".to_string(),
7925 "--batch".to_string(),
7926 "--collision".to_string(),
7927 "suffix".to_string(),
7928 "x".to_string(),
7929 ])
7930 .unwrap()
7931 .unwrap();
7932 let mut taken = std::collections::BTreeSet::new();
7933 let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7934 assert!(
7935 first.ends_with("out-2.html"),
7936 "existing file must be kept: {first}"
7937 );
7938 let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7939 assert!(second.ends_with("out-3.html"));
7940
7941 let mut cli_error = cli.clone();
7943 cli_error.collision = CollisionPolicy::Error;
7944 let mut taken = std::collections::BTreeSet::new();
7945 let (class, message) =
7946 resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
7947 assert_eq!(class, ExitClass::Input);
7948 assert!(message.contains("--overwrite"));
7949
7950 let mut cli_overwrite = cli.clone();
7952 cli_overwrite.overwrite = true;
7953 let mut taken = std::collections::BTreeSet::new();
7954 assert_eq!(
7955 resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
7956 target
7957 );
7958 let _ = std::fs::remove_dir_all(&root);
7959 }
7960
7961 #[test]
7962 fn batch_outputs_keep_their_export_kind() {
7963 let outputs = BatchOutputs {
7965 html: batch_export_path(Some("result.out"), "a.md", 0, 1),
7966 ..BatchOutputs::default()
7967 };
7968 assert_eq!(outputs.html.as_deref(), Some("result.out"));
7969 assert!(outputs.svg.is_none());
7970 }
7971
7972 #[test]
7973 fn parses_export_flags() {
7974 let s = |v: &str| v.to_string();
7975 let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
7977 .unwrap()
7978 .unwrap();
7979 assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
7980 assert!(cli.export_html.is_none());
7981 assert_eq!(cli.resource.as_deref(), Some("x"));
7982
7983 let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
7984 assert_eq!(cli.export_html.as_deref(), Some("out.html"));
7985
7986 let cli = parse(&[
7988 s("--export-html"),
7989 s("a.html"),
7990 s("--export-svg"),
7991 s("b.svg"),
7992 s("x"),
7993 ])
7994 .unwrap()
7995 .unwrap();
7996 assert_eq!(cli.export_html.as_deref(), Some("a.html"));
7997 assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
7998
7999 assert!(parse(&[s("--export-html")]).is_err());
8001 }
8003
8004 #[test]
8005 fn is_number_matches_pattern() {
8006 for ok in [
8011 "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
8012 ] {
8013 assert!(is_number(ok), "{ok:?} should be numeric");
8014 }
8015 for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
8016 assert!(!is_number(no), "{no:?} should not be numeric");
8017 }
8018 }
8019
8020 #[test]
8021 fn render_csv_matches_upstream() {
8022 let table = build_csv_table(
8026 "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
8027 Some(','),
8028 None,
8029 None,
8030 (None, None),
8031 false,
8032 )
8033 .expect("the sniffer reads this one");
8034 let out = Console::builder()
8035 .force_terminal(true)
8036 .color_system(Some(ColorSystem::Truecolor))
8037 .width(40)
8038 .no_color(false)
8039 .build()
8040 .render_to_string(&table);
8041 let expected = concat!(
8042 "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
8043 "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
8044 "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
8045 "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
8046 "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
8047 "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
8048 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
8049 "\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
8050 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
8051 "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
8052 );
8053 assert_eq!(out, expected);
8054 }
8055}