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