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