1use std::io::{BufRead, IsTerminal, Read, Write};
26use std::path::{Path, PathBuf};
27use std::process::ExitCode;
28
29mod authoring;
30mod batch;
31mod batch_output;
32mod batch_paths;
33#[cfg(test)]
34mod batch_races;
35mod cli_spec;
36mod config;
37mod controls;
38mod demo;
39mod doctor;
40mod inspect;
41mod render_target;
42mod structured_log;
43mod tools;
44mod viewers;
45mod watch;
46use batch::run_batch;
47use config::{config_args, ConfigRoots};
48
49use rich::cells::cell_len;
50use rich::markdown::Markdown;
51use rich::measure::Measurement;
52use rich::protocol::{LineRenderable, OwnedTableRows};
53use rich::r#box::{Box as BoxSet, ASCII, ASCII2, DOUBLE, HEAVY, HEAVY_HEAD, ROUNDED, SQUARE};
54use rich::text::Text;
55use rich::{
56 filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
57 Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
58 LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
59 Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
60 DEFAULT_TERMINAL_THEME,
61};
62use rich_ext::cli::CliExtensions;
63use rich_ext::encoding::{has_utf16_bom, Encoding};
64use rich_ext::sanitize_terminal_controls;
65use rich_ext::ConsoleExt;
66
67fn text(content: &str) -> Box<dyn Renderable> {
69 Box::new(Text::new(content))
70}
71
72const VERSION: &str = env!("CARGO_PKG_VERSION");
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum Mode {
77 Auto,
79 Print,
81 Markdown,
83 Json,
85 Syntax,
87 Csv,
89 Ipynb,
91 Gif,
93 Diff,
95 Image,
97 Rule,
99 JsonLines,
101 Log,
103 Inspect,
105 AnsiExplain,
107 View,
110 Hex,
112 Unicode,
114 Env,
116 Capture,
118 Mermaid,
120}
121
122impl Mode {
123 fn streams_records(self) -> bool {
124 matches!(self, Self::JsonLines | Self::Log)
125 }
126
127 fn draws_directly(self) -> bool {
128 matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
129 }
130
131 fn allows_extensions(self) -> bool {
132 !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
133 && !self.streams_records()
134 }
135
136 fn accepts_multiple_resources(self) -> bool {
137 matches!(self, Self::Gif | Self::Diff | Self::Env | Self::Capture)
138 }
139}
140
141struct ModeSpec {
142 mode: Mode,
143 primary: &'static str,
144 aliases: &'static [&'static str],
145}
146
147const MODE_SPECS: &[ModeSpec] = &[
148 ModeSpec {
149 mode: Mode::Auto,
150 primary: "auto",
151 aliases: &[],
152 },
153 ModeSpec {
154 mode: Mode::Print,
155 primary: "print",
156 aliases: &["print"],
157 },
158 ModeSpec {
159 mode: Mode::Markdown,
160 primary: "markdown",
161 aliases: &["markdown", "md"],
162 },
163 ModeSpec {
164 mode: Mode::Json,
165 primary: "json",
166 aliases: &["json"],
167 },
168 ModeSpec {
169 mode: Mode::Syntax,
170 primary: "syntax",
171 aliases: &["syntax", "code"],
172 },
173 ModeSpec {
174 mode: Mode::Csv,
175 primary: "csv",
176 aliases: &["csv", "tsv"],
177 },
178 ModeSpec {
179 mode: Mode::Ipynb,
180 primary: "ipynb",
181 aliases: &["ipynb", "notebook"],
182 },
183 ModeSpec {
184 mode: Mode::Gif,
185 primary: "gif",
186 aliases: &["gif"],
187 },
188 ModeSpec {
189 mode: Mode::Diff,
190 primary: "diff",
191 aliases: &["diff"],
192 },
193 ModeSpec {
194 mode: Mode::Image,
195 primary: "image",
196 aliases: &["image"],
197 },
198 ModeSpec {
199 mode: Mode::Rule,
200 primary: "rule",
201 aliases: &["rule"],
202 },
203 ModeSpec {
204 mode: Mode::JsonLines,
205 primary: "jsonl",
206 aliases: &["jsonl", "ndjson"],
207 },
208 ModeSpec {
209 mode: Mode::Log,
210 primary: "log",
211 aliases: &["log", "logs"],
212 },
213 ModeSpec {
214 mode: Mode::Inspect,
215 primary: "inspect",
216 aliases: &["inspect"],
217 },
218 ModeSpec {
219 mode: Mode::AnsiExplain,
220 primary: "ansi",
221 aliases: &["ansi", "ansi-explain"],
222 },
223 ModeSpec {
224 mode: Mode::View,
225 primary: "view",
226 aliases: &["view"],
227 },
228 ModeSpec {
229 mode: Mode::Hex,
230 primary: "hex",
231 aliases: &["hex", "hexdump"],
232 },
233 ModeSpec {
234 mode: Mode::Unicode,
235 primary: "unicode",
236 aliases: &["unicode"],
237 },
238 ModeSpec {
239 mode: Mode::Env,
240 primary: "env",
241 aliases: &["env"],
242 },
243 ModeSpec {
244 mode: Mode::Capture,
245 primary: "capture",
246 aliases: &["capture"],
247 },
248 ModeSpec {
249 mode: Mode::Mermaid,
250 primary: "mermaid",
251 aliases: &["mermaid", "mmd"],
252 },
253];
254
255const RENDER_MODE_FLAGS: &str =
256 "--print/--markdown/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259enum ReportFormat {
260 Human,
261 Json,
262}
263
264impl ReportFormat {
265 fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
266 match option {
267 "--report" => {
268 *self = match value.ok_or("--report requires one of: human, json")? {
269 "human" => Self::Human,
270 "json" => Self::Json,
271 other => return Err(format!("unknown report format {other:?} (human, json)")),
272 };
273 }
274 "--machine-json" => *self = Self::Json,
275 _ => {}
276 }
277 Ok(())
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282enum ExitClass {
283 Success,
284 Usage,
285 Input,
286 Data,
287 #[cfg_attr(not(feature = "art"), allow(dead_code))]
288 Gate,
289}
290
291impl ExitClass {
292 fn code(self) -> u8 {
293 match self {
294 Self::Success => 0,
295 Self::Usage => 2,
296 Self::Input => 3,
297 Self::Data => 4,
298 Self::Gate => 5,
299 }
300 }
301
302 fn name(self) -> &'static str {
303 match self {
304 Self::Success => "success",
305 Self::Usage => "usage",
306 Self::Input => "input",
307 Self::Data => "data",
308 Self::Gate => "gate",
309 }
310 }
311
312 fn exit_code(self) -> ExitCode {
313 ExitCode::from(self.code())
314 }
315}
316
317#[derive(Clone, Copy, PartialEq, Eq, Debug)]
324enum ImageMode {
325 Auto,
327 Sixel,
329 Blocks,
331 Braille,
333 Quadrants,
335 Ascii,
337 None,
339}
340
341impl std::str::FromStr for ImageMode {
342 type Err = String;
343
344 fn from_str(value: &str) -> Result<Self, Self::Err> {
345 match value.to_ascii_lowercase().as_str() {
346 "auto" => Ok(Self::Auto),
347 "sixel" => Ok(Self::Sixel),
348 "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
349 "braille" => Ok(Self::Braille),
350 "quadrants" | "quadrant" => Ok(Self::Quadrants),
351 "ascii" | "art" => Ok(Self::Ascii),
352 "none" | "off" => Ok(Self::None),
353 other => Err(format!(
354 "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
355 )),
356 }
357 }
358}
359
360#[derive(Clone)]
362struct Cli {
363 mode: Mode,
364 resource: Option<String>,
365 #[cfg_attr(not(feature = "art"), allow(dead_code))]
369 resources: Vec<String>,
370 #[cfg_attr(not(feature = "art"), allow(dead_code))]
372 loops: Option<usize>,
373 #[cfg_attr(not(feature = "art"), allow(dead_code))]
376 diff_threshold: Option<f32>,
377 #[cfg_attr(not(feature = "art"), allow(dead_code))]
379 image_mode: ImageMode,
380 #[cfg_attr(not(feature = "art"), allow(dead_code))]
381 image_fit: Option<String>,
382 #[cfg_attr(not(feature = "art"), allow(dead_code))]
383 image_anchor: Option<String>,
384 #[cfg_attr(not(feature = "art"), allow(dead_code))]
385 image_background: Option<ImageBackdrop>,
386 #[cfg_attr(not(feature = "art"), allow(dead_code))]
387 image_color: Option<String>,
388 #[cfg_attr(not(feature = "art"), allow(dead_code))]
389 image_dither: Option<String>,
390 #[cfg_attr(not(feature = "art"), allow(dead_code))]
391 image_color_distance: Option<String>,
392 #[cfg_attr(not(feature = "art"), allow(dead_code))]
393 image_rotate: Option<u16>,
394 #[cfg_attr(not(feature = "art"), allow(dead_code))]
395 image_flip_horizontal: bool,
396 #[cfg_attr(not(feature = "art"), allow(dead_code))]
397 image_flip_vertical: bool,
398 #[cfg_attr(not(feature = "art"), allow(dead_code))]
399 image_grayscale: bool,
400 #[cfg_attr(not(feature = "art"), allow(dead_code))]
401 image_max_width: Option<usize>,
402 #[cfg_attr(not(feature = "art"), allow(dead_code))]
403 image_max_height: Option<usize>,
404 #[cfg_attr(not(feature = "art"), allow(dead_code))]
406 image_brightness: Option<f32>,
407 #[cfg_attr(not(feature = "art"), allow(dead_code))]
408 image_contrast: Option<f32>,
409 #[cfg_attr(not(feature = "art"), allow(dead_code))]
410 image_gamma: Option<f32>,
411 log_presentation: String,
412 mermaid_backend: Option<MermaidBackend>,
415 highlighter: Option<String>,
417 code_theme: Option<String>,
419 theme_styles: std::collections::BTreeMap<String, Style>,
420 theme_file_styles: std::collections::BTreeMap<String, Style>,
423 #[cfg_attr(not(feature = "art"), allow(dead_code))]
426 height: Option<usize>,
427 extensions: CliExtensions,
428 width: Option<usize>,
429 justify: Option<Justify>,
430 no_color: bool,
431 export_html: Option<String>,
434 export_svg: Option<String>,
436 panel: Option<BoxSet>,
440 padding: Option<(usize, usize, usize, usize)>,
442 title: Option<String>,
444 caption: Option<String>,
446 panel_style: Option<Style>,
448 style: Option<Style>,
451 expand: bool,
454 pager: bool,
456 auto_pager: bool,
457 hyperlinks: bool,
461 sanitize: bool,
463 report_format: ReportFormat,
466 watch: bool,
468 watch_interval: f64,
470 watch_cache: bool,
472 watch_debounce: f64,
475 watch_poll: bool,
477 watch_exit_on_error: bool,
479 batch: bool,
480 progress: bool,
481 continue_on_error: bool,
482 jobs: usize,
483 dry_run: bool,
484 batch_paths: batch_paths::BatchPathOptions,
485 worker_args: Vec<String>,
486 overwrite: bool,
487 collision: CollisionPolicy,
488 data: inspect::DataOptions,
489 tools: tools::ToolOptions,
490 viewers: viewers::ViewerOptions,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495enum MermaidBackend {
496 Text,
498 Mmdc,
501 Off,
503}
504
505impl MermaidBackend {
506 fn parse(value: &str) -> Result<Self, String> {
507 match value {
508 "text" => Ok(Self::Text),
509 "off" => Ok(Self::Off),
510 "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
511 "mmdc" => Err(
512 "--mermaid-backend mmdc needs a build with the mmdc feature \
513 (cargo install rs-rich-cli --features mmdc)"
514 .into(),
515 ),
516 other => Err(format!(
517 "unknown Mermaid backend {other:?} (text, mmdc, off)"
518 )),
519 }
520 }
521}
522
523#[cfg(feature = "mermaid")]
525fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
526 rich_mermaid::MermaidOptions {
527 backend: match backend {
528 MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
529 MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
530 },
531 ..rich_mermaid::MermaidOptions::default()
532 }
533}
534
535fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
537 #[cfg_attr(not(feature = "mermaid"), allow(unused_mut))]
538 let mut registry = rich_ext::ExtensionRegistry::with_defaults();
539 #[cfg(feature = "mermaid")]
540 if mermaid != MermaidBackend::Off {
541 registry
542 .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
543 .expect("the Mermaid plugin registers cleanly");
544 }
545 #[cfg(not(feature = "mermaid"))]
546 let _ = mermaid;
547 #[cfg(feature = "lumis")]
548 registry
549 .add_plugin(&rich_lumis::LumisPlugin)
550 .expect("the lumis plugin registers cleanly");
551 registry
552}
553
554fn code_highlighting(
558 highlighter: Option<&str>,
559 theme: Option<&str>,
560) -> Result<Option<rich::CodeHighlighting>, String> {
561 if highlighter.is_none() && theme.is_none() {
562 return Ok(None);
563 }
564 let mut registry = plugin_registry(MermaidBackend::Off);
565 let name = highlighter.unwrap_or("syntect");
566 if let Err(error) = registry.set_default_code_highlighter(name, theme) {
567 return Err(match error {
568 rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
569 if name == "lumis" && !cfg!(feature = "lumis") =>
570 {
571 format!(
572 "{error} (lumis needs a build with the lumis feature: cargo install \
573 rs-rich-cli --features lumis)"
574 )
575 }
576 rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
577 let themes = registry
578 .code_highlighter(name)
579 .map(|engine| engine.themes())
580 .unwrap_or_default();
581 if themes.len() <= 40 {
582 format!("{error}; its themes: {}", themes.join(", "))
583 } else {
584 format!(
585 "{error}; it has {} themes, such as {}; `rich doctor --report json` \
586 lists them all",
587 themes.len(),
588 themes[..12].join(", ")
589 )
590 }
591 }
592 other => other.to_string(),
593 });
594 }
595 Ok(registry.code_highlighting())
596}
597
598fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
600 use rich::ConsoleCodeHighlighting;
601 if let Ok(Some(highlighting)) =
602 code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
603 {
604 console.set_code_highlighting(Some(highlighting));
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609enum CollisionPolicy {
610 Error,
611 Overwrite,
612 Suffix,
613}
614
615fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
627 Markdown::new(source).hyperlinks(hyperlinks)
628}
629
630pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
644 dispatch(remember_raw_args(args))
645}
646
647static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
651 std::sync::Mutex::new(Vec::new());
652
653fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
656 let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
657 let args = args
658 .into_iter()
659 .map(|arg| match arg.into_string() {
660 Ok(arg) => arg,
661 Err(arg) => {
662 let lossy = arg.to_string_lossy().into_owned();
663 match raw.iter_mut().find(|(spelling, _)| *spelling == lossy) {
664 Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
665 Some(_) => {}
666 None => raw.push((lossy.clone(), Some(arg))),
667 }
668 lossy
669 }
670 })
671 .collect();
672 if let Ok(mut slot) = RAW_ARGS.lock() {
673 *slot = raw;
674 }
675 args
676}
677
678pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
682 RAW_ARGS
683 .lock()
684 .ok()
685 .and_then(|raw| {
686 raw.iter()
687 .find(|(spelling, _)| spelling == arg)
688 .and_then(|(_, original)| original.clone())
689 })
690 .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
691}
692
693pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
699 if let Ok(mut slot) = SELF_PROGRAM.lock() {
700 *slot = program;
701 }
702 run(args)
703}
704
705static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
707
708fn dispatch(args: Vec<String>) -> ExitCode {
709 if demo::requested(&args) {
710 return demo::dispatch(&args);
711 }
712 if doctor::requested(&args) {
713 return doctor::dispatch(&args);
714 }
715 match parse(&args) {
716 Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
718 if cli.batch {
719 if let Err(error) = batch::install_interrupt_handler() {
720 return fail(
721 &cli,
722 ExitClass::Input,
723 format!("cannot install batch interruption handler: {error}"),
724 );
725 }
726 }
727 run_cli(cli)
728 }
729 Err(message) => emit_error(
730 wants_json_report(&args),
731 ExitClass::Usage,
732 &format!("{message} (try --help)"),
733 ),
734 }
735}
736
737fn self_command() -> std::io::Result<std::process::Command> {
741 let program = SELF_PROGRAM
742 .lock()
743 .map(|program| program.clone())
744 .unwrap_or_default();
745 Ok(match program.split_first() {
746 Some((executable, rest)) => {
747 let mut command = std::process::Command::new(executable);
748 command.args(rest);
749 command
750 }
751 None => std::process::Command::new(std::env::current_exe()?),
752 })
753}
754
755const VALUE_OPTIONS: &[&str] = &[
758 "--batch-input-root",
759 "--batch-name-template",
760 "--log-presentation",
761 "--mermaid-backend",
762 "--highlighter",
763 "--code-theme",
764 "-w",
765 "--width",
766 "-o",
767 "--export-html",
768 "--export-svg",
769 "--panel",
770 "--padding",
771 "--title",
772 "--caption",
773 "-s",
774 "--style",
775 "-S",
776 "--panel-style",
777 "--report",
778 "--image-mode",
779 "--image-fit",
780 "--image-anchor",
781 "--image-background",
782 "--height",
783 "--theme",
784 "--theme-style",
785 "--theme-file",
786 "--image-color",
787 "--image-dither",
788 "--image-color-distance",
789 "--image-rotate",
790 "--image-max-width",
791 "--image-max-height",
792 "--image-brightness",
793 "--image-contrast",
794 "--image-gamma",
795 "--demo-section",
796 "--demo-delay",
797 "--watch-interval",
798 "--interval",
799 "--watch-debounce",
800 "--gif-mode",
801 "--encoding",
802 "--threshold",
803 "--loop",
804 "--jobs",
805 "--collision",
806 "--format",
807 "--select",
808 "--filter",
809 "--highlight",
810 "--find",
811 "--max-depth",
812 "--max-length",
813 "--compare",
814 "--context",
815 "--language",
816 "--search",
817 "--offset",
818 "--length",
819 "--bytes-per-line",
820 "--group",
821 "--limit",
822 "--cast",
823 "--redact-pattern",
824 "--config",
825 "--profile",
826];
827
828fn selects_mode_explicitly(args: &[String]) -> bool {
832 explicit_mode(args).is_some()
833}
834
835fn explicit_mode(args: &[String]) -> Option<Mode> {
837 let mut iter = args.iter();
838 let mut seen_positional = false;
839 while let Some(arg) = iter.next() {
840 if arg == "--" {
841 break;
842 }
843 if VALUE_OPTIONS.contains(&arg.as_str()) {
844 iter.next();
845 continue;
846 }
847 if arg.starts_with('-') && arg.len() > 1 {
848 if let Some(flag) = mode_flag_alias(arg) {
849 return command_mode(&flag[2..]);
850 }
851 continue;
852 }
853 if !seen_positional {
854 seen_positional = true;
855 if let Some(mode) = command_mode(arg) {
856 return Some(mode);
857 }
858 }
859 }
860 None
861}
862
863fn command_word(args: &[String]) -> Option<&str> {
866 let mut iter = args.iter();
867 while let Some(arg) = iter.next() {
868 if arg == "--" {
869 return None;
870 }
871 if VALUE_OPTIONS.contains(&arg.as_str()) {
872 iter.next();
873 } else if !arg.starts_with('-') || arg == "-" {
874 return command_mode(arg).map(|_| arg.as_str());
875 }
876 }
877 None
878}
879
880fn mode_flag_alias(arg: &str) -> Option<&'static str> {
882 Some(match arg {
883 "--print" | "-p" => "--print",
884 "--markdown" | "-m" => "--markdown",
885 "--json" | "-j" => "--json",
886 "--syntax" | "-x" => "--syntax",
887 "--csv" => "--csv",
888 "--ipynb" => "--ipynb",
889 "--jsonl" | "--ndjson" => "--jsonl",
890 "--log" => "--log",
891 "--rule" => "--rule",
892 "--image" => "--image",
893 "--gif" => "--gif",
894 "--diff" => "--diff",
895 "--inspect" => "--inspect",
896 "--ansi-explain" => "--ansi-explain",
897 _ => return None,
898 })
899}
900
901fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
902 let message = &controls::shown(message);
905 if json {
906 eprintln!(
907 "{}",
908 serde_json::json!({
909 "ok": false,
910 "code": class.name(),
911 "exit_code": class.code(),
912 "message": message,
913 "error": {
914 "message": message,
915 },
916 })
917 );
918 } else {
919 eprintln!("rich: {message}");
920 }
921 class.exit_code()
922}
923
924fn emit_success_report(format: ReportFormat) {
925 if format == ReportFormat::Json {
926 eprintln!(
927 "{}",
928 serde_json::json!({
929 "ok": true,
930 "code": ExitClass::Success.name(),
931 "exit_code": ExitClass::Success.code(),
932 "result": {},
933 })
934 );
935 }
936}
937
938fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
939 let message = controls::shown(message.as_ref());
940 if watch::capture_error(&message) {
942 return class.exit_code();
943 }
944 emit_error(cli.report_format == ReportFormat::Json, class, &message)
945}
946
947fn success(cli: &Cli) -> ExitCode {
948 if !watch::capturing() {
949 emit_success_report(cli.report_format);
950 }
951 ExitClass::Success.exit_code()
952}
953
954fn glob_match(pattern: &str, text: &str) -> bool {
961 let pattern: Vec<char> = pattern.chars().collect();
962 let text: Vec<char> = text.chars().collect();
963 let (mut p, mut t) = (0usize, 0usize);
964 let (mut star, mut retry) = (None, 0usize);
965 while t < text.len() {
966 if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
967 p += 1;
968 t += 1;
969 } else if p < pattern.len() && pattern[p] == '*' {
970 star = Some(p);
971 retry = t;
972 p += 1;
973 } else if let Some(open) = star {
974 p = open + 1;
975 retry += 1;
976 t = retry;
977 } else {
978 return false;
979 }
980 }
981 while p < pattern.len() && pattern[p] == '*' {
982 p += 1;
983 }
984 p == pattern.len()
985}
986
987fn has_glob_meta(value: &str) -> bool {
988 value.contains('*') || value.contains('?')
989}
990
991fn split_glob(resource: &str) -> (PathBuf, String) {
993 let meta = resource.find(['*', '?']).unwrap_or(0);
994 match resource[..meta].rfind(['/', '\\']) {
995 Some(cut) => (
996 PathBuf::from(&resource[..cut]),
997 resource[cut + 1..].to_string(),
998 ),
999 None => (PathBuf::from("."), resource.to_string()),
1000 }
1001}
1002
1003fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1011 let mut stack = vec![dir.to_path_buf()];
1012 while let Some(current) = stack.pop() {
1013 batch::check_interrupted()?;
1014 let entries = std::fs::read_dir(¤t)
1015 .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1016 for entry in entries {
1017 batch::check_interrupted()?;
1018 let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1019 let path = entry.path();
1020 let kind = std::fs::symlink_metadata(&path)
1021 .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1022 .file_type();
1023 if kind.is_symlink() {
1024 continue;
1025 }
1026 if kind.is_dir() {
1027 stack.push(path);
1028 } else if kind.is_file() {
1029 out.push(path.to_string_lossy().into_owned());
1030 }
1031 }
1032 }
1033 Ok(())
1034}
1035
1036fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1037 let mut out = Vec::new();
1038 for resource in resources {
1039 batch::check_interrupted()?;
1040 if resource == "-" || is_url(resource) {
1041 return Err("batch resources must be local files or directories".into());
1042 }
1043 if has_glob_meta(resource) {
1044 let (dir, pattern) = split_glob(resource);
1045 if pattern.contains('/') || pattern.contains('\\') {
1046 return Err(format!(
1047 "batch glob {resource:?} may only use * and ? in the final path segment"
1048 ));
1049 }
1050 let entries = std::fs::read_dir(&dir)
1051 .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1052 for entry in entries {
1053 batch::check_interrupted()?;
1054 let entry =
1055 entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1056 let path = entry.path();
1057 if !path.is_file() {
1058 continue;
1059 }
1060 if glob_match(&pattern, &entry.file_name().to_string_lossy()) {
1061 out.push(path.to_string_lossy().into_owned());
1062 }
1063 }
1064 continue;
1065 }
1066 let path = Path::new(resource);
1067 if path.is_file() {
1068 out.push(resource.clone());
1069 } else if path.is_dir() {
1070 collect_files(path, &mut out)?;
1071 } else {
1072 return Err(format!("batch resource does not exist: {resource}"));
1073 }
1074 }
1075 batch::check_interrupted()?;
1076 out.sort();
1077 out.dedup();
1078 batch::check_interrupted()?;
1079 if out.is_empty() {
1080 return Err("batch plan contains no files".into());
1081 }
1082 Ok(out)
1083}
1084
1085#[derive(Default, Clone)]
1091struct BatchOutputs {
1092 html: Option<String>,
1093 svg: Option<String>,
1094}
1095
1096fn batch_export_path(
1097 path: Option<&str>,
1098 input: &str,
1099 index: usize,
1100 total: usize,
1101) -> Option<String> {
1102 let path = path?;
1103 let p = Path::new(path);
1104 if total == 1 {
1105 return Some(path.into());
1106 }
1107 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or_default();
1108 let stem = Path::new(input)
1109 .file_stem()
1110 .and_then(|s| s.to_str())
1111 .unwrap_or("output");
1112 let name = if ext.is_empty() {
1113 format!("{stem}-{index}")
1114 } else {
1115 format!("{stem}.{ext}")
1116 };
1117 Some(if p.is_dir() {
1118 p.join(name).to_string_lossy().into_owned()
1119 } else {
1120 p.with_file_name(name).to_string_lossy().into_owned()
1121 })
1122}
1123
1124fn suffixed_path(path: &Path, suffix: usize) -> String {
1126 let stem = path
1127 .file_stem()
1128 .and_then(|s| s.to_str())
1129 .unwrap_or("output");
1130 let name = match path.extension().and_then(|s| s.to_str()) {
1131 Some(ext) => format!("{stem}-{suffix}.{ext}"),
1132 None => format!("{stem}-{suffix}"),
1133 };
1134 path.with_file_name(name).to_string_lossy().into_owned()
1135}
1136
1137fn resolve_destination(
1143 cli: &Cli,
1144 candidate: String,
1145 taken: &mut std::collections::BTreeSet<String>,
1146) -> Result<String, (ExitClass, String)> {
1147 let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1148 taken.contains(path) || (!cli.overwrite && Path::new(path).exists())
1149 };
1150 match cli.collision {
1151 CollisionPolicy::Overwrite => {
1152 taken.insert(candidate.clone());
1153 Ok(candidate)
1154 }
1155 CollisionPolicy::Suffix => {
1156 let mut chosen = candidate.clone();
1157 let mut suffix = 2;
1158 while occupied(&chosen, taken) {
1159 batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1160 chosen = suffixed_path(Path::new(&candidate), suffix);
1161 suffix += 1;
1162 }
1163 taken.insert(chosen.clone());
1164 Ok(chosen)
1165 }
1166 CollisionPolicy::Error => {
1167 if taken.contains(&candidate) {
1168 return Err((
1169 ExitClass::Usage,
1170 format!("batch output collision: {candidate}"),
1171 ));
1172 }
1173 if !cli.overwrite && Path::new(&candidate).exists() {
1174 return Err((
1175 ExitClass::Input,
1176 format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1177 ));
1178 }
1179 taken.insert(candidate.clone());
1180 Ok(candidate)
1181 }
1182 }
1183}
1184
1185fn worker_args(args: &[String]) -> Vec<String> {
1188 let mut result = vec!["--no-config".into()];
1189 let mut iter = args.iter();
1190 let mut seen_resource = false;
1191 let mut selected_mode = false;
1192 while let Some(arg) = iter.next() {
1193 if arg == "--" {
1194 break;
1195 }
1196 if VALUE_OPTIONS.contains(&arg.as_str()) {
1197 let value = iter.next();
1198 if !matches!(
1199 arg.as_str(),
1200 "--jobs"
1201 | "--batch-input-root"
1202 | "--batch-name-template"
1203 | "--collision"
1204 | "--report"
1205 | "-o"
1206 | "--export-html"
1207 | "--export-svg"
1208 | "--config"
1209 | "--profile"
1210 ) {
1211 result.push(arg.clone());
1212 if let Some(value) = value {
1213 result.push(value.clone());
1214 }
1215 }
1216 } else if arg.starts_with('-') && arg != "-" {
1217 selected_mode |= mode_flag_alias(arg).is_some();
1218 if !matches!(
1219 arg.as_str(),
1220 "--batch"
1221 | "--batch-preserve-dirs"
1222 | "--no-batch-preserve-dirs"
1223 | "--no-batch"
1224 | "--dry-run"
1225 | "--progress"
1226 | "--no-progress"
1227 | "--continue-on-error"
1228 | "--no-continue-on-error"
1229 | "--overwrite"
1230 | "--no-overwrite"
1231 | "--machine-json"
1232 ) {
1233 result.push(arg.clone());
1234 }
1235 } else if !seen_resource {
1236 if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1237 selected_mode = true;
1238 result.push(format!("--{}", mode_name(mode)));
1239 } else {
1240 seen_resource = true;
1241 }
1242 }
1243 }
1244 result
1245}
1246
1247fn wants_json_report(args: &[String]) -> bool {
1248 let mut format = ReportFormat::Human;
1249 let mut iter = args.iter();
1250 while let Some(arg) = iter.next() {
1251 match arg.as_str() {
1252 "--report" => {
1253 if let Some(value) = iter.next() {
1254 let _ = format.apply_option("--report", Some(value));
1255 }
1256 }
1257 "--machine-json" => {
1258 let _ = format.apply_option("--machine-json", None);
1259 }
1260 "--" => break,
1261 _ => {}
1262 }
1263 }
1264 format == ReportFormat::Json
1265}
1266
1267fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1276 Ok(Some(match name.to_ascii_lowercase().as_str() {
1277 "none" => return Ok(None),
1278 "ascii" => ASCII,
1279 "ascii2" => ASCII2,
1280 "square" => SQUARE,
1281 "rounded" => ROUNDED,
1282 "heavy" => HEAVY,
1283 "double" => DOUBLE,
1284 other => {
1285 return Err(format!(
1286 "unknown panel box {other:?} (use none/ascii/ascii2/square/rounded/heavy/double)"
1287 ))
1288 }
1289 }))
1290}
1291
1292fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1296 let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1297 let parts: Result<Vec<usize>, _> = value
1298 .split(',')
1299 .map(|p| p.trim().parse::<usize>())
1300 .collect();
1301 let parts = parts.map_err(|_| error())?;
1302 match parts.as_slice() {
1303 [p] => Ok((*p, *p, *p, *p)),
1304 [v, h] => Ok((*v, *h, *v, *h)),
1305 [t, r, b, l] => Ok((*t, *r, *b, *l)),
1306 _ => Err(error()),
1307 }
1308}
1309
1310fn mode_name(mode: Mode) -> &'static str {
1312 MODE_SPECS
1313 .iter()
1314 .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1315 .unwrap_or("unknown")
1316}
1317
1318fn command_mode(command: &str) -> Option<Mode> {
1319 MODE_SPECS
1320 .iter()
1321 .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1322}
1323
1324fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1325 if *current != Mode::Auto && *current != mode {
1326 return Err(format!(
1327 "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1328 ));
1329 }
1330 *current = mode;
1331 Ok(())
1332}
1333
1334fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1336 let roots = ConfigRoots::default();
1337 if authoring::dispatch(args)? {
1338 return Ok(None);
1339 }
1340 if tools::bench_dispatch(args)? {
1341 return Ok(None);
1342 }
1343 if let Some(output) = config::inspect(args, &roots)? {
1344 authoring::out(&format!("{output}\n"));
1345 return Ok(None);
1346 }
1347 let merged = config_args(args, &roots)?;
1348 parse_inner(&merged)
1349}
1350
1351fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1352 let mut mode = Mode::Auto;
1353 let mut resources: Vec<String> = Vec::new();
1354 let mut loops = None;
1355 let mut diff_threshold = None;
1356 let mut image_mode = ImageMode::Auto;
1357 let mut image_fit = None;
1358 let mut image_anchor = None;
1359 let mut image_background = None;
1360 let mut image_color = None;
1361 let mut image_dither = None;
1362 let mut image_color_distance = None;
1363 let mut image_rotate = None;
1364 let mut image_flip_horizontal = false;
1365 let mut image_flip_vertical = false;
1366 let mut image_grayscale = false;
1367 let mut image_max_width = None;
1368 let mut image_max_height = None;
1369 let mut image_brightness = None;
1370 let mut image_contrast = None;
1371 let mut image_gamma = None;
1372 let mut log_presentation = String::from("plain");
1373 let mut mermaid_backend = None;
1374 let mut highlighter: Option<String> = None;
1375 let mut code_theme: Option<String> = None;
1376 let mut theme_styles = std::collections::BTreeMap::new();
1377 let mut theme_file_styles = std::collections::BTreeMap::new();
1378 let mut height = None;
1379 let mut extensions = CliExtensions::default();
1380 let mut data = inspect::DataOptions::default();
1381 let mut tool_options = tools::ToolOptions::default();
1382 let mut viewer_options = viewers::ViewerOptions::default();
1383 let mut paging_explicit = false;
1385 let mut ansi_word = false;
1387 let mut width = None;
1388 let mut justify = None;
1389 let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
1390 let mut export_html: Option<String> = None;
1391 let mut export_svg: Option<String> = None;
1392 let mut panel = None;
1393 let mut padding = None;
1394 let mut title = None;
1395 let mut caption = None;
1396 let mut panel_style = None;
1397 let mut style = None;
1398 let mut expand = false;
1399 let mut pager = false;
1400 let mut auto_pager = false;
1401 let mut hyperlinks = false;
1402 let mut sanitize = None;
1405 let mut report_format = ReportFormat::Human;
1406 let mut watch = false;
1407 let mut watch_interval = 1.0;
1408 let mut watch_cache = false;
1409 let mut watch_debounce: Option<f64> = None;
1410 let mut watch_poll = false;
1411 let mut watch_exit_on_error = false;
1412 let mut batch = false;
1413 let mut progress = true;
1414 let mut continue_on_error = false;
1415 let mut jobs = 1usize;
1416 let mut dry_run = false;
1417 let mut batch_paths = batch_paths::BatchPathOptions::default();
1418 let mut overwrite = false;
1419 let mut collision = CollisionPolicy::Error;
1420 let mut end_of_options = false;
1425
1426 let mut iter = args.iter();
1427 while let Some(arg) = iter.next() {
1428 if end_of_options {
1429 resources.push(arg.to_string());
1430 continue;
1431 }
1432 if extensions.parse_option(arg, &mut iter)? {
1433 continue;
1434 }
1435 if data.parse_option(arg, &mut iter)? {
1436 continue;
1437 }
1438 if tool_options.parse_option(arg, &mut iter)? {
1439 continue;
1440 }
1441 if viewer_options.parse_option(arg, &mut iter)? {
1442 continue;
1443 }
1444 if matches!(
1445 arg.as_str(),
1446 "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
1447 ) {
1448 paging_explicit = true;
1449 }
1450 match arg.as_str() {
1451 "--" => end_of_options = true,
1452 "-h" | "--help" => {
1453 let no_color = no_color || cli_spec::no_color_requested(args);
1454 let help = command_word(args)
1456 .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
1457 match help {
1458 Some(help) => authoring::out(&format!("{help}\n")),
1459 None => print_help(no_color),
1460 }
1461 return Ok(None);
1462 }
1463 "-V" | "--version" => {
1464 println!("rich (rs-rich-cli) {VERSION}");
1465 return Ok(None);
1466 }
1467 "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
1468 "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
1469 "-j" | "--json" => set_mode(&mut mode, Mode::Json)?,
1470 "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
1471 "--csv" => set_mode(&mut mode, Mode::Csv)?,
1472 "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
1473 "--gif" => set_mode(&mut mode, Mode::Gif)?,
1474 "--diff" => set_mode(&mut mode, Mode::Diff)?,
1475 "--image" => set_mode(&mut mode, Mode::Image)?,
1476 "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
1477 "--log" => set_mode(&mut mode, Mode::Log)?,
1478 "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
1479 "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
1480 "--highlighter" => {
1481 let value = iter
1482 .next()
1483 .ok_or("--highlighter requires a code highlighter's name")?;
1484 highlighter = Some(value.clone());
1485 }
1486 "--code-theme" => {
1487 let value = iter.next().ok_or("--code-theme requires a theme name")?;
1488 code_theme = Some(value.clone());
1489 }
1490 "--mermaid-backend" => {
1491 let value = iter
1492 .next()
1493 .ok_or("--mermaid-backend requires text, mmdc or off")?;
1494 mermaid_backend = Some(MermaidBackend::parse(value)?);
1495 }
1496 "--log-presentation" => {
1497 let value = iter
1498 .next()
1499 .ok_or("--log-presentation requires plain or rich")?;
1500 if !matches!(value.as_str(), "plain" | "rich") {
1501 return Err("--log-presentation requires plain or rich".into());
1502 }
1503 log_presentation = value.clone();
1504 }
1505 "--report" => {
1506 let value = iter.next().map(String::as_str);
1507 report_format.apply_option("--report", value)?;
1508 }
1509 "--machine-json" => {
1510 report_format.apply_option("--machine-json", None)?;
1511 }
1512 "--progress" => progress = true,
1513 "--no-progress" => progress = false,
1514 "--theme-style" => {
1515 let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
1516 let (name, value) = binding
1517 .split_once('=')
1518 .ok_or("--theme-style requires NAME=STYLE")?;
1519 config::validate_theme_binding(name, value)?;
1520 let style = Style::parse(value)
1521 .map_err(|error| format!("invalid theme style {name}: {error}"))?;
1522 theme_styles.insert(name.to_owned(), style);
1523 }
1524 "--theme-file" => {
1525 let path = iter.next().ok_or("--theme-file requires PATH")?;
1526 theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
1527 }
1528 "--image-color" => {
1529 const USAGE: &str =
1530 "--image-color requires truecolor, ansi256, ansi16 or grayscale";
1531 let value = iter.next().ok_or(USAGE)?;
1532 if !matches!(
1533 value.as_str(),
1534 "truecolor" | "ansi256" | "ansi16" | "grayscale"
1535 ) {
1536 return Err(USAGE.into());
1537 }
1538 image_color = Some(value.clone());
1539 }
1540 flag @ ("--image-max-width" | "--image-max-height") => {
1541 let usage = format!("{flag} requires a positive integer");
1542 let value = iter
1543 .next()
1544 .and_then(|v| v.parse::<usize>().ok())
1545 .filter(|v| *v > 0)
1546 .ok_or(usage)?;
1547 if flag == "--image-max-width" {
1548 image_max_width = Some(value);
1549 } else {
1550 image_max_height = Some(value);
1551 }
1552 }
1553 flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
1554 let gamma = flag == "--image-gamma";
1555 let usage = if gamma {
1556 format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
1557 } else {
1558 format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
1559 };
1560 let value = iter
1561 .next()
1562 .and_then(|v| v.parse::<f32>().ok())
1563 .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
1564 .ok_or(usage)?;
1565 match flag {
1566 "--image-brightness" => image_brightness = Some(value),
1567 "--image-contrast" => image_contrast = Some(value),
1568 _ => image_gamma = Some(value),
1569 }
1570 }
1571 "--image-flip-horizontal" => image_flip_horizontal = true,
1572 "--no-image-flip-horizontal" => image_flip_horizontal = false,
1573 "--image-flip-vertical" => image_flip_vertical = true,
1574 "--no-image-flip-vertical" => image_flip_vertical = false,
1575 "--image-grayscale" => image_grayscale = true,
1576 "--no-image-grayscale" => image_grayscale = false,
1577 "--image-rotate" => {
1578 let value = iter
1579 .next()
1580 .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
1581 image_rotate = Some(match value.as_str() {
1582 "0" => 0,
1583 "90" => 90,
1584 "180" => 180,
1585 "270" => 270,
1586 _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
1587 });
1588 }
1589 "--image-dither" => {
1590 const USAGE: &str =
1591 "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
1592 let value = iter.next().ok_or(USAGE)?;
1593 if !matches!(
1594 value.as_str(),
1595 "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
1596 ) {
1597 return Err(USAGE.into());
1598 }
1599 image_dither = Some(value.clone());
1600 }
1601 "--image-color-distance" => {
1602 const USAGE: &str = "--image-color-distance requires rgb or oklab";
1603 let value = iter.next().ok_or(USAGE)?;
1604 if !matches!(value.as_str(), "rgb" | "oklab") {
1605 return Err(USAGE.into());
1606 }
1607 image_color_distance = Some(value.clone());
1608 }
1609 "--image-mode" => {
1610 let value = iter.next().ok_or(
1611 "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
1612 )?;
1613 image_mode = value.parse()?;
1614 }
1615 "--image-fit" => {
1616 let value = iter
1617 .next()
1618 .ok_or("--image-fit requires contain, cover, stretch or native")?;
1619 if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
1620 return Err("--image-fit requires contain, cover, stretch or native".into());
1621 }
1622 image_fit = Some(value.clone());
1623 }
1624 "--image-anchor" => {
1625 let value = iter.next().ok_or("--image-anchor requires a position")?;
1626 if !matches!(
1627 value.as_str(),
1628 "center"
1629 | "top"
1630 | "bottom"
1631 | "left"
1632 | "right"
1633 | "top-left"
1634 | "top-right"
1635 | "bottom-left"
1636 | "bottom-right"
1637 ) {
1638 return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
1639 }
1640 image_anchor = Some(value.clone());
1641 }
1642 "--image-background" => {
1643 const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
1644 let value = iter.next().ok_or(USAGE)?;
1645 image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
1646 }
1647 "--height" => {
1648 let value = iter.next().ok_or("--height requires a number")?;
1649 let rows: usize = value
1650 .parse()
1651 .map_err(|_| format!("invalid height '{value}'"))?;
1652 if rows == 0 {
1653 return Err("--height must be at least 1".into());
1654 }
1655 height = Some(rows);
1656 }
1657 "--threshold" => {
1658 let value = iter
1659 .next()
1660 .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
1661 let percent: f32 = value
1662 .parse()
1663 .map_err(|_| format!("invalid threshold '{value}'"))?;
1664 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
1669 return Err(format!(
1670 "threshold must be a percentage between 0 and 100, got '{value}'"
1671 ));
1672 }
1673 diff_threshold = Some(percent);
1674 }
1675 "--loop" => {
1676 let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
1677 loops = Some(
1678 value
1679 .parse()
1680 .map_err(|_| format!("invalid loop count {value:?}"))?,
1681 );
1682 }
1683 "--rule" => set_mode(&mut mode, Mode::Rule)?,
1684 "--left" => justify = Some(Justify::Left),
1685 "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
1686 "--right" => {}
1687 "--center" if justify.is_none() => justify = Some(Justify::Center),
1688 "--center" => {}
1689 "--no-color" => no_color = true,
1690 "--pager" => {
1691 pager = true;
1692 auto_pager = false;
1693 }
1694 "--auto-pager" => {
1695 auto_pager = true;
1696 pager = false;
1697 }
1698 "--no-pager" => {
1699 pager = false;
1700 auto_pager = false;
1701 }
1702 "--no-auto-pager" => auto_pager = false,
1703 "--color" => no_color = false,
1704 "--no-watch" => watch = false,
1705 "--no-watch-cache" => watch_cache = false,
1706 "--no-watch-poll" => watch_poll = false,
1707 "--no-watch-exit-on-error" => watch_exit_on_error = false,
1708 "--no-batch" => batch = false,
1709 "--no-continue-on-error" => continue_on_error = false,
1710 "--no-overwrite" => {
1711 overwrite = false;
1712 collision = CollisionPolicy::Error;
1713 }
1714 "--no-sanitize" => sanitize = Some(false),
1715 "--dry-run" => dry_run = true,
1716 "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
1717 "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
1718 "--batch-input-root" => {
1719 batch_paths.input_root = Some(PathBuf::from(
1720 iter.next().ok_or("--batch-input-root requires a path")?,
1721 ))
1722 }
1723 "--batch-name-template" => {
1724 batch_paths.template = Some(
1725 batch_paths::FilenameTemplate::parse(
1726 iter.next()
1727 .ok_or("--batch-name-template requires a template")?,
1728 )
1729 .map_err(|e| e.to_string())?,
1730 )
1731 }
1732 "--watch" => watch = true,
1733 "--watch-cache" => watch_cache = true,
1734 "--watch-poll" => watch_poll = true,
1735 "--watch-exit-on-error" => watch_exit_on_error = true,
1736 "--watch-debounce" => {
1737 let value = iter
1738 .next()
1739 .ok_or("--watch-debounce requires seconds (0 or more)")?;
1740 let seconds = value
1741 .parse::<f64>()
1742 .map_err(|_| format!("invalid watch debounce {value:?}"))?;
1743 if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
1744 return Err("--watch-debounce must be between 0 and 3600 seconds".into());
1745 }
1746 watch_debounce = Some(seconds);
1747 }
1748 "--watch-interval" | "--interval" => {
1749 let value = iter
1750 .next()
1751 .ok_or("--watch-interval requires seconds greater than zero")?;
1752 watch_interval = value
1753 .parse::<f64>()
1754 .map_err(|_| format!("invalid watch interval {value:?}"))?;
1755 if !watch_interval.is_finite() || watch_interval <= 0.0 {
1756 return Err("--watch-interval must be greater than zero".into());
1757 }
1758 }
1759 "--batch" => batch = true,
1760 "--continue-on-error" => continue_on_error = true,
1761 "--overwrite" => {
1762 overwrite = true;
1763 collision = CollisionPolicy::Overwrite;
1764 }
1765 "--collision" => {
1766 collision = match iter
1767 .next()
1768 .ok_or("--collision requires error, overwrite, or suffix")?
1769 .as_str()
1770 {
1771 "error" => CollisionPolicy::Error,
1772 "overwrite" => CollisionPolicy::Overwrite,
1773 "suffix" => CollisionPolicy::Suffix,
1774 other => return Err(format!("unknown collision policy {other:?}")),
1775 };
1776 overwrite = collision == CollisionPolicy::Overwrite;
1777 }
1778 "--jobs" => {
1779 jobs = iter
1780 .next()
1781 .ok_or("--jobs requires a positive number")?
1782 .parse()
1783 .map_err(|_| "--jobs requires a positive number".to_string())?;
1784 if jobs == 0 {
1785 return Err("--jobs must be at least 1".into());
1786 }
1787 }
1788 "--sanitize" => sanitize = Some(true),
1789 "-y" | "--hyperlinks" => hyperlinks = true,
1793 "--export-html" | "-o" => {
1797 export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
1798 }
1799 "--export-svg" => {
1800 export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
1801 }
1802 "--panel" => {
1803 let value = iter.next().ok_or("--panel requires a box name")?;
1804 panel = parse_box(value)?;
1805 }
1806 "--padding" => {
1807 let value = iter.next().ok_or("--padding requires a value")?;
1808 padding = Some(parse_padding(value)?);
1809 }
1810 "-e" | "--expand" => expand = true,
1811 "--title" => {
1812 title = Some(iter.next().ok_or("--title requires a value")?.clone());
1813 }
1814 "--caption" => {
1815 caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
1816 }
1817 "-s" | "--style" => {
1822 let value = iter.next().ok_or("--style requires a value")?;
1823 style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
1824 }
1825 "-S" | "--panel-style" => {
1826 let value = iter.next().ok_or("--panel-style requires a value")?;
1827 panel_style =
1828 Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
1829 }
1830 "-w" | "--width" => {
1831 let value = iter.next().ok_or("--width requires a number")?;
1832 let columns: usize = value.parse().map_err(|_| {
1833 if std::path::Path::new(value).exists() {
1837 format!("--width needs a number, but got the file '{value}' — a value is missing")
1838 } else {
1839 format!("invalid width '{value}'")
1840 }
1841 })?;
1842 if columns == 0 {
1843 return Err("--width must be at least 1".into());
1844 }
1845 width = Some(columns);
1846 }
1847 other if other.starts_with('-') && other != "-" => {
1848 return Err(format!("unknown option {other:?}"));
1849 }
1850 other
1851 if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
1852 {
1853 ansi_word = other == "ansi";
1854 set_mode(&mut mode, command_mode(other).expect("checked above"))?;
1855 }
1856 "explain" if ansi_word && resources.is_empty() => ansi_word = false,
1857 other => resources.push(other.to_string()),
1858 }
1859 }
1860
1861 if data.format.is_some() {
1864 if !matches!(mode, Mode::Auto | Mode::Inspect) {
1865 return Err(format!(
1866 "--format cannot be combined with --{}",
1867 mode_name(mode)
1868 ));
1869 }
1870 if mode == Mode::Auto && resources.is_empty() && !batch {
1871 resources.push("-".into());
1872 }
1873 }
1874 if let Some(flag) = data
1875 .inspect_only_option(mode == Mode::Capture)
1876 .filter(|_| mode != Mode::Inspect)
1877 {
1878 if flag == "--redact" {
1879 return Err("--redact only has an effect with --inspect or `rich capture`".into());
1880 }
1881 return Err(format!("{flag} only has an effect with --inspect"));
1882 }
1883 if let Some(flag) = data.transform_option() {
1884 if !transforms_apply(mode) {
1885 return Err(format!(
1886 "{flag} only has an effect on text, --print, --syntax or --inspect"
1887 ));
1888 }
1889 data.check_transforms(mode == Mode::Inspect)?;
1890 }
1891 if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
1892 return Err(format!("{flag} only has an effect with --diff"));
1893 }
1894 if let Some(flag) = tool_options
1895 .ansi_option()
1896 .filter(|_| mode != Mode::AnsiExplain)
1897 {
1898 return Err(format!("{flag} only has an effect with --ansi-explain"));
1899 }
1900 for (flag, commands) in viewer_options.given() {
1901 if !commands.contains(&mode_name(mode)) {
1902 return Err(format!(
1903 "{flag} only has an effect with `rich {}`",
1904 commands.join("` or `rich ")
1905 ));
1906 }
1907 }
1908 if mode == Mode::Capture && resources.is_empty() {
1909 return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
1910 }
1911 if mode == Mode::View && !paging_explicit {
1912 auto_pager = true;
1913 }
1914 data.validate()?;
1915
1916 if watch && (batch || pager || auto_pager) {
1917 return Err("--watch cannot be combined with --batch or paging".into());
1918 }
1919
1920 if batch {
1924 if mode.accepts_multiple_resources() {
1925 return Err(format!(
1926 "--batch cannot be combined with {}",
1927 if mode == Mode::Diff {
1928 "--diff"
1929 } else {
1930 "--gif"
1931 }
1932 ));
1933 }
1934 if resources.is_empty() {
1935 return Err("--batch needs at least one file, directory or glob".into());
1936 }
1937 if pager || auto_pager {
1939 return Err("--pager cannot be combined with --batch".into());
1940 }
1941 }
1942
1943 if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
1946 return Err(
1947 "--diff needs two images or files to compare, or one patch: --diff before after".into(),
1948 );
1949 }
1950 if mode == Mode::Image {
1951 if resources.is_empty() {
1952 return Err(
1953 "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
1954 );
1955 }
1956 if image_mode == ImageMode::None {
1957 return Err(
1958 "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
1959 .into(),
1960 );
1961 }
1962 }
1963 let quantized = matches!(
1964 image_color.as_deref(),
1965 Some("ansi256" | "ansi16" | "grayscale")
1966 );
1967 if quantized && image_mode == ImageMode::Braille {
1968 return Err(format!(
1969 "--image-color {} has no effect with --image-mode braille, which is monochrome",
1970 image_color.as_deref().unwrap()
1971 ));
1972 }
1973 for (flag, value, neutral) in [
1974 ("--image-dither", image_dither.as_deref(), "none"),
1975 (
1976 "--image-color-distance",
1977 image_color_distance.as_deref(),
1978 "rgb",
1979 ),
1980 ] {
1981 if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
1982 return Err(format!(
1983 "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
1984 ));
1985 }
1986 }
1987 if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
1988 return Err("--image-anchor requires --image-fit cover".into());
1989 }
1990 if (batch_paths.preserve_dirs
1991 || batch_paths.template.is_some()
1992 || batch_paths.input_root.is_some())
1993 && !batch
1994 {
1995 return Err("batch path options require --batch".into());
1996 }
1997 if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
1998 return Err("--batch-preserve-dirs requires --batch-input-root".into());
1999 }
2000 if dry_run && !batch {
2001 return Err("--dry-run requires --batch".into());
2002 }
2003 if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2004 return Err("--jobs greater than 1 requires --batch with a file export".into());
2005 }
2006 if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2008 return Err("--image-fit requires --height to define the target rectangle".into());
2009 }
2010 let effective_mode = if mode == Mode::Auto {
2015 detect_mode(resources.first().map(String::as_str))
2016 } else {
2017 mode
2018 };
2019 extensions.validate(
2020 effective_mode == Mode::Gif,
2021 effective_mode.allows_extensions()
2022 && !(mode == Mode::Auto && resources.is_empty())
2023 && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2024 )?;
2025 let exporting = export_html.is_some() || export_svg.is_some();
2026 if exporting {
2027 let unsupported = if effective_mode == Mode::Gif {
2028 Some("--gif")
2029 } else if mode == Mode::Auto && resources.is_empty() {
2030 Some("the capability demo")
2031 } else {
2032 None
2033 };
2034 if let Some(what) = unsupported {
2035 return Err(format!("--export-html/--export-svg cannot capture {what}"));
2036 }
2037 }
2038
2039 let orphans = [
2045 (
2046 "--threshold",
2047 diff_threshold.is_some(),
2048 "--diff",
2049 mode == Mode::Diff,
2050 ),
2051 (
2052 "--image-mode",
2053 image_mode != ImageMode::Auto,
2054 "--diff/--image",
2055 mode == Mode::Diff || mode == Mode::Image,
2056 ),
2057 ("--height", height.is_some(), "--image", mode == Mode::Image),
2058 (
2059 "--image-rotate",
2060 image_rotate.is_some(),
2061 "--image",
2062 mode == Mode::Image,
2063 ),
2064 (
2065 "--image-flip-horizontal",
2066 image_flip_horizontal,
2067 "--image",
2068 mode == Mode::Image,
2069 ),
2070 (
2071 "--image-flip-vertical",
2072 image_flip_vertical,
2073 "--image",
2074 mode == Mode::Image,
2075 ),
2076 (
2077 "--image-grayscale",
2078 image_grayscale,
2079 "--image",
2080 mode == Mode::Image,
2081 ),
2082 (
2083 "--image-color",
2084 image_color.is_some(),
2085 "--image or --gif",
2086 mode == Mode::Image || effective_mode == Mode::Gif,
2087 ),
2088 (
2089 "--image-max-width",
2090 image_max_width.is_some(),
2091 "--image",
2092 mode == Mode::Image,
2093 ),
2094 (
2095 "--image-max-height",
2096 image_max_height.is_some(),
2097 "--image",
2098 mode == Mode::Image,
2099 ),
2100 (
2101 "--image-brightness",
2102 image_brightness.is_some(),
2103 "--image",
2104 mode == Mode::Image,
2105 ),
2106 (
2107 "--image-contrast",
2108 image_contrast.is_some(),
2109 "--image",
2110 mode == Mode::Image,
2111 ),
2112 (
2113 "--image-gamma",
2114 image_gamma.is_some(),
2115 "--image",
2116 mode == Mode::Image,
2117 ),
2118 (
2119 "--image-dither",
2120 image_dither.is_some(),
2121 "--image or --gif",
2122 mode == Mode::Image || effective_mode == Mode::Gif,
2123 ),
2124 (
2125 "--image-color-distance",
2126 image_color_distance.is_some(),
2127 "--image or --gif",
2128 mode == Mode::Image || effective_mode == Mode::Gif,
2129 ),
2130 (
2131 "--image-fit",
2132 image_fit.is_some(),
2133 "--image",
2134 mode == Mode::Image,
2135 ),
2136 (
2137 "--image-background",
2138 image_background.is_some(),
2139 "--image",
2140 mode == Mode::Image,
2141 ),
2142 (
2143 "--loop",
2144 loops.is_some(),
2145 "--gif",
2146 effective_mode == Mode::Gif,
2147 ),
2148 (
2153 "--panel-style",
2154 panel_style.is_some(),
2155 "--panel",
2156 panel.is_some(),
2157 ),
2158 (
2159 "--expand",
2160 expand,
2161 "--panel/--padding",
2162 panel.is_some() || padding.is_some(),
2163 ),
2164 ("--watch-interval", watch_interval != 1.0, "--watch", watch),
2165 ("--watch-cache", watch_cache, "--watch", watch),
2166 (
2167 "--watch-debounce",
2168 watch_debounce.is_some(),
2169 "--watch",
2170 watch,
2171 ),
2172 ("--watch-poll", watch_poll, "--watch", watch),
2173 (
2174 "--watch-exit-on-error",
2175 watch_exit_on_error,
2176 "--watch",
2177 watch,
2178 ),
2179 ];
2180 if let Some((flag, _, needs, _)) = orphans
2181 .iter()
2182 .find(|(_, given, _, mode_present)| *given && !*mode_present)
2183 {
2184 return Err(format!("{flag} only has an effect with {needs}"));
2185 }
2186
2187 let demo = mode == Mode::Auto && resources.is_empty();
2190 if effective_mode.draws_directly() || demo {
2191 let mode_name = if effective_mode == Mode::Gif {
2192 "--gif"
2193 } else if effective_mode == Mode::Image {
2194 "--image"
2195 } else if effective_mode.streams_records() {
2196 mode_name(effective_mode)
2197 } else if demo {
2198 "the capability demo"
2199 } else {
2200 "--diff"
2201 };
2202 let unsupported = [
2203 (
2204 "--pager",
2205 (pager || auto_pager)
2206 && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
2207 ),
2208 (
2209 "--export-html/--export-svg",
2210 (export_html.is_some() || export_svg.is_some()) && effective_mode.streams_records(),
2211 ),
2212 ("--width", demo && width.is_some()),
2213 ("--theme-style", demo && !theme_styles.is_empty()),
2214 ("--theme-file", demo && !theme_file_styles.is_empty()),
2215 (
2216 "--hyperlinks",
2217 hyperlinks && (effective_mode.streams_records() || demo),
2218 ),
2219 ("--panel", panel.is_some()),
2220 ("--padding", padding.is_some()),
2221 ("--title", title.is_some()),
2222 ("--caption", caption.is_some()),
2223 ("--style", style.is_some()),
2224 ("--panel-style", panel_style.is_some()),
2225 ("--expand", expand),
2226 ("--left/--center/--right", justify.is_some()),
2227 ];
2228 if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
2229 return Err(format!("{flag} cannot be combined with {mode_name}"));
2230 }
2231 }
2232 if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
2235 return Err("only one resource may be given (except with --gif or --watch)".into());
2236 }
2237 let sanitize = sanitize.unwrap_or_else(|| {
2241 mode == Mode::View
2242 || (mode == Mode::Diff
2243 && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
2244 });
2245 let resource = resources.first().cloned();
2246
2247 code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
2249 Ok(Some(Cli {
2250 mode,
2251 resource,
2252 resources,
2253 loops,
2254 diff_threshold,
2255 image_mode,
2256 image_fit,
2257 image_anchor,
2258 image_background,
2259 image_color,
2260 image_dither,
2261 image_color_distance,
2262 image_rotate,
2263 image_flip_horizontal,
2264 image_flip_vertical,
2265 image_grayscale,
2266 image_max_width,
2267 image_max_height,
2268 image_brightness,
2269 image_contrast,
2270 image_gamma,
2271 log_presentation,
2272 mermaid_backend,
2273 highlighter,
2274 code_theme,
2275 theme_styles,
2276 theme_file_styles,
2277 height,
2278 extensions,
2279 width,
2280 justify,
2281 no_color,
2282 export_html,
2283 export_svg,
2284 panel,
2285 padding,
2286 title,
2287 caption,
2288 panel_style,
2289 style,
2290 expand,
2291 pager,
2292 auto_pager,
2293 hyperlinks,
2294 sanitize,
2295 report_format,
2296 watch,
2297 watch_interval,
2298 watch_cache,
2299 watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
2300 watch_poll,
2301 watch_exit_on_error,
2302 batch,
2303 progress,
2304 continue_on_error,
2305 jobs,
2306 dry_run,
2307 batch_paths,
2308 worker_args: worker_args(args),
2309 overwrite,
2310 collision,
2311 data,
2312 tools: tool_options,
2313 viewers: viewer_options,
2314 }))
2315}
2316
2317fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
2327 use std::io::{Seek, SeekFrom};
2328 let resource = cli.resource.as_deref();
2329 let name = resource.unwrap_or("<stdin>");
2330 let window = |bytes: Vec<u8>| {
2331 let start = usize::try_from(offset)
2332 .unwrap_or(usize::MAX)
2333 .min(bytes.len());
2334 let mut rest = bytes[start..].to_vec();
2335 let more = rest.len() as u64 > max;
2336 rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
2337 (rest, more)
2338 };
2339 if let Some(url) = resource.filter(|r| is_url(r)) {
2340 return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
2341 }
2342 if cli.extensions.encoding.is_some() {
2343 return read_resource_limited(
2344 resource,
2345 cli.extensions.encoding,
2346 Some(controls::INSPECT_LIMIT),
2347 )
2348 .map(|text| window(text.into_bytes()))
2349 .map_err(|err| format!("cannot read {name}: {err}"));
2350 }
2351 let error = |err: std::io::Error| format!("cannot read {name}: {err}");
2352 let take = max.saturating_add(1);
2354 let mut bytes = Vec::new();
2355 match resource.filter(|r| *r != "-") {
2356 Some(path) if fs_path(path).is_dir() => {
2357 return Err(format!("cannot read {path}: it is a directory"));
2358 }
2359 Some(path) => {
2360 let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
2361 if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
2363 std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
2364 .map_err(error)?;
2365 }
2366 file.take(take).read_to_end(&mut bytes).map_err(error)?;
2367 }
2368 None => {
2369 let mut stdin = std::io::stdin().lock();
2370 std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
2371 stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
2372 }
2373 }
2374 let more = bytes.len() as u64 > max;
2375 bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
2376 Ok((bytes, more))
2377}
2378
2379fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
2381 let mut bytes = Vec::new();
2382 match limit {
2383 None => {
2384 let mut reader = reader;
2385 reader.read_to_end(&mut bytes)?;
2386 }
2387 Some(limit) => {
2388 reader
2389 .take(limit.saturating_add(1))
2390 .read_to_end(&mut bytes)?;
2391 if bytes.len() as u64 > limit {
2392 return Err(std::io::Error::other(format!(
2393 "larger than {}, the most this command reads",
2394 controls::size(limit)
2395 )));
2396 }
2397 }
2398 }
2399 Ok(bytes)
2400}
2401
2402fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
2415 read_resource_limited(resource, encoding, None)
2416}
2417
2418fn read_resource_limited(
2422 resource: Option<&str>,
2423 encoding: Option<Encoding>,
2424 limit: Option<u64>,
2425) -> std::io::Result<String> {
2426 let content = match resource {
2427 Some(path) if path != "-" => {
2428 let file = &fs_path(path);
2429 if file.is_dir() {
2432 return Err(std::io::Error::new(
2433 std::io::ErrorKind::InvalidInput,
2434 "is a directory, not a file",
2435 ));
2436 }
2437 let bytes = match limit {
2438 None => std::fs::read(file)?,
2439 Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
2440 };
2441 if let Some(encoding) = encoding {
2442 return encoding.decode(&bytes).map(normalize_newlines);
2443 }
2444 if has_utf16_bom(&bytes) {
2445 eprintln!("rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)", controls::shown(path));
2446 }
2447 match String::from_utf8(bytes) {
2448 Ok(text) => text,
2449 Err(_) if looks_like_image(path) => {
2454 return Err(std::io::Error::new(
2455 std::io::ErrorKind::InvalidData,
2456 "looks like an image; use `rich --image <path>` to view it, or \
2457 `rich --diff <before> <after>` to compare two images",
2458 ))
2459 }
2460 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
2461 }
2462 }
2463 _ => {
2464 if std::io::stdin().is_terminal() {
2465 let eof = if cfg!(windows) {
2466 "Ctrl-Z then Enter"
2467 } else {
2468 "Ctrl-D"
2469 };
2470 eprintln!("rich: reading stdin; finish input with {eof}");
2471 }
2472 let buffer = read_limited(std::io::stdin(), limit)?;
2473 decode_strict(buffer, encoding)?
2474 }
2475 };
2476 Ok(normalize_newlines(if encoding.is_some() {
2477 content
2478 } else {
2479 strip_bom(content)
2480 }))
2481}
2482
2483fn sanitize_json_value(value: &mut serde_json::Value) {
2484 match value {
2485 serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
2486 serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
2487 serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
2488 _ => {}
2489 }
2490}
2491
2492fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
2493 let mut value = serde_json::from_str::<serde_json::Value>(content)?;
2494 sanitize_json_value(&mut value);
2495 serde_json::to_string(&value)
2496}
2497
2498fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
2500 if let Some(encoding) = encoding {
2501 return encoding.decode(&bytes);
2502 }
2503 String::from_utf8(bytes).map_err(|err| std::io::Error::new(
2504 std::io::ErrorKind::InvalidData,
2505 if has_utf16_bom(err.as_bytes()) {
2506 "input has a UTF-16 BOM; use --encoding utf-16"
2507 } else {
2508 "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
2509 },
2510 ))
2511}
2512
2513fn normalize_newlines(text: String) -> String {
2522 const CR: char = '\r';
2523 const LF: char = '\n';
2524 if !text.contains(CR) {
2525 return text;
2526 }
2527 let mut out = String::with_capacity(text.len());
2528 let mut chars = text.chars().peekable();
2529 while let Some(c) = chars.next() {
2530 if c == CR {
2531 if chars.peek() == Some(&LF) {
2534 chars.next();
2535 }
2536 out.push(LF);
2537 } else {
2538 out.push(c);
2539 }
2540 }
2541 out
2542}
2543
2544fn looks_like_image(path: &str) -> bool {
2554 std::path::Path::new(path)
2555 .extension()
2556 .and_then(|e| e.to_str())
2557 .map(|e| e.to_ascii_lowercase())
2558 .is_some_and(|e| {
2559 matches!(
2560 e.as_str(),
2561 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
2562 )
2563 })
2564}
2565
2566fn strip_bom(text: String) -> String {
2567 match text.strip_prefix('\u{feff}') {
2568 Some(rest) => rest.to_string(),
2569 None => text,
2570 }
2571}
2572
2573fn is_url(resource: &str) -> bool {
2576 let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
2577 head.starts_with("http://") || head.starts_with("https://")
2578}
2579
2580fn resource_ext(resource: &str) -> Option<String> {
2584 let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
2585 let basename = basename.split(['?', '#']).next().unwrap_or(basename);
2586 basename
2587 .rsplit_once('.')
2588 .map(|(_, ext)| ext.to_ascii_lowercase())
2589 .filter(|ext| !ext.is_empty())
2590}
2591
2592#[cfg(feature = "fetch")]
2595const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
2596
2597#[cfg(feature = "fetch")]
2605fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
2606 use std::time::Duration;
2607 let mut response = ureq::get(url)
2608 .config()
2609 .timeout_global(Some(Duration::from_secs(30)))
2610 .http_status_as_error(false)
2613 .build()
2614 .call()
2615 .map_err(|err| format!("cannot fetch {url}: {err}"))?;
2616 let content_type = response
2617 .headers()
2618 .get("content-type")
2619 .and_then(|value| value.to_str().ok())
2620 .map(str::to_string);
2621
2622 let mut buffer = Vec::new();
2628 response
2629 .body_mut()
2630 .with_config()
2631 .reader()
2632 .take(MAX_BODY_BYTES + 1)
2633 .read_to_end(&mut buffer)
2634 .map_err(|err| format!("cannot read {url}: {err}"))?;
2635 if buffer.len() as u64 > MAX_BODY_BYTES {
2636 return Err(format!(
2637 "{url} exceeds the {} MiB limit",
2638 MAX_BODY_BYTES / (1024 * 1024)
2639 ));
2640 }
2641 let body =
2642 decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
2643 Ok((body, content_type))
2644}
2645
2646#[cfg(not(feature = "fetch"))]
2648fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
2649 Err(format!(
2650 "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
2651 ))
2652}
2653
2654fn content_type_mode(content_type: &str) -> Option<Mode> {
2658 let mime = mime_of(content_type);
2659 match mime.as_str() {
2660 "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
2661 "application/json" | "text/json" => Some(Mode::Json),
2662 "text/csv" => Some(Mode::Csv),
2663 _ => None,
2664 }
2665}
2666
2667fn content_type_lexer(content_type: &str) -> Option<&'static str> {
2671 let mime = mime_of(content_type);
2672 Some(match mime.as_str() {
2673 "text/html" | "application/xhtml+xml" => "html",
2674 "text/css" => "css",
2675 "application/javascript" | "text/javascript" => "javascript",
2676 "application/xml" | "text/xml" => "xml",
2677 "text/x-python" | "application/x-python" | "text/x-python3" => "python",
2678 "text/x-rust" => "rust",
2679 "text/x-c" | "text/x-csrc" => "c",
2680 "application/x-sh" | "text/x-shellscript" => "bash",
2681 "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
2682 "application/toml" | "text/x-toml" => "toml",
2683 _ => return None,
2684 })
2685}
2686
2687fn is_uninformative_ext(ext: &str) -> bool {
2690 matches!(ext, "txt" | "text" | "log" | "dat" | "out")
2691}
2692
2693fn mime_of(content_type: &str) -> String {
2696 content_type
2697 .split(';')
2698 .next()
2699 .unwrap_or("")
2700 .trim()
2701 .to_ascii_lowercase()
2702}
2703
2704fn transforms_apply(mode: Mode) -> bool {
2708 matches!(
2709 mode,
2710 Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
2711 )
2712}
2713
2714fn transforms_misplaced(flag: &str, mode: Mode) -> String {
2715 format!(
2716 "{flag} only has an effect on text, --print, --syntax or --inspect, not {}",
2717 mode_name(mode)
2718 )
2719}
2720
2721fn detect_mode(resource: Option<&str>) -> Mode {
2722 match resource.and_then(resource_ext).as_deref() {
2723 Some("md") | Some("markdown") => Mode::Markdown,
2724 Some("json") => Mode::Json,
2725 Some("csv") | Some("tsv") => Mode::Csv,
2726 Some("ipynb") => Mode::Ipynb,
2727 Some("gif") => Mode::Gif,
2728 Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
2729 Some(_) => Mode::Syntax,
2737 None => Mode::Auto,
2741 }
2742}
2743
2744struct ForceWidth {
2752 child: Box<dyn Renderable>,
2753 width: usize,
2754}
2755
2756impl Renderable for ForceWidth {
2757 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
2758 self.child
2759 .rich_render(console, &options.update_width(self.width))
2760 }
2761
2762 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
2763 Measurement::new(self.width, self.width)
2764 }
2765}
2766
2767struct Aligned {
2777 child: Box<dyn Renderable>,
2778 width: usize,
2784 justify: Justify,
2785}
2786
2787impl Renderable for Aligned {
2788 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
2789 let width = self.width.clamp(1, options.max_width);
2790 let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
2800 let widths: Vec<usize> = lines
2801 .iter()
2802 .map(|line| line.iter().map(Segment::cell_length).sum())
2803 .collect();
2804 let shape = widths.iter().copied().max().unwrap_or(0);
2808
2809 let excess = options.max_width.saturating_sub(shape);
2810 let (left, right) = match self.justify {
2811 Justify::Right => (excess, 0),
2812 Justify::Center => (excess / 2, excess - excess / 2),
2813 _ => (0, excess),
2814 };
2815 let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
2816
2817 let mut segments = Vec::new();
2818 let last = lines.len().saturating_sub(1);
2819 for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
2820 if left > 0 {
2821 segments.push(blank(left));
2822 }
2823 segments.extend(line);
2824 let pad = shape - line_width + right;
2825 if pad > 0 {
2826 segments.push(blank(pad));
2827 }
2828 if index != last {
2829 segments.push(Segment::line());
2830 }
2831 }
2832 segments
2833 }
2834}
2835
2836fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
2848 console
2849 .render_lines(renderable, &console.options(), false)
2850 .iter()
2851 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
2852 .max()
2853 .unwrap_or(0)
2854}
2855
2856fn validate_labels(cli: &Cli) -> Result<(), String> {
2859 for label in cli.title.iter().chain(cli.caption.iter()) {
2860 Text::from_markup(label).map_err(|err| err.to_string())?;
2861 }
2862 Ok(())
2863}
2864
2865fn decorate_and_emit(
2871 cli: &Cli,
2872 console: &Console,
2873 export: &Export,
2874 renderable: Box<dyn Renderable>,
2875 fit: Option<usize>,
2876) -> ExitCode {
2877 decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
2878}
2879
2880fn decorate_and_emit_with(
2883 cli: &Cli,
2884 console: &Console,
2885 export: &Export,
2886 renderable: Box<dyn Renderable>,
2887 fit: Option<usize>,
2888 finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
2889) -> ExitCode {
2890 if cli.panel.is_some() {
2891 if let Err(err) = validate_labels(cli) {
2892 return fail(cli, ExitClass::Data, err);
2893 }
2894 }
2895 let max_width = console.width();
2896 let expand = cli.expand || cli.width.is_some();
2898 let mut renderable = renderable;
2899 let mut fit = fit;
2900
2901 if let Some((top, right, bottom, left)) = cli.padding {
2907 let padded: Box<dyn Renderable> =
2908 Box::new(Padding::new(renderable, (top, right, bottom, left)));
2909 fit = fit.map(|width| (width + left + right).min(max_width));
2912 renderable = if expand {
2913 padded
2914 } else {
2915 Box::new(Constrain::new(padded, fit))
2916 };
2917 }
2918
2919 if let Some(box_set) = cli.panel {
2920 let mut panel = Panel::new(renderable).box_set(box_set);
2921 if let Some(title) = cli.title.clone() {
2922 panel = panel.title(title);
2923 }
2924 if let Some(caption) = cli.caption.clone() {
2925 panel = panel.subtitle(caption);
2926 }
2927 if let Some(style) = cli.panel_style.clone() {
2928 panel = panel.border_style(style);
2929 }
2930 let inner = max_width.saturating_sub(4);
2939 let child = fit.unwrap_or(inner).min(inner);
2940 let title_width = cli
2941 .title
2942 .as_deref()
2943 .map(|title| {
2944 let mut title = Text::from_markup(&rich::emoji::replace(title).replace('\n', " "))
2945 .expect("title markup validated before rendering");
2946 title.expand_tabs(8);
2947 cell_len(title.plain()) + 2
2948 })
2949 .unwrap_or(0);
2950 fit = Some(child.max(title_width) + 4);
2951 let panel: Box<dyn Renderable> = Box::new(panel);
2952 renderable = if expand {
2953 panel
2954 } else {
2955 Box::new(Constrain::new(panel, fit))
2956 };
2957 }
2958
2959 if let Some(style) = cli.style.clone() {
2962 renderable = Box::new(Styled::new(renderable, style));
2963 }
2964
2965 if let Some(width) = cli.width.filter(|_| !cli.pager) {
2967 renderable = Box::new(ForceWidth {
2968 child: renderable,
2969 width,
2970 });
2971 fit = Some(width);
2972 }
2973
2974 if let Some(justify) = cli.justify {
2975 renderable = Box::new(Aligned {
2976 child: renderable,
2977 width: fit.unwrap_or(max_width),
2978 justify,
2979 });
2980 }
2981
2982 finish(cli, emit(console, export, |c| c.print(renderable.as_ref())))
2983}
2984
2985fn run_cli(cli: Cli) -> ExitCode {
2986 if cli.watch {
2987 return run_watch(cli);
2988 }
2989 run_once(cli)
2990}
2991
2992fn run_watch(mut cli: Cli) -> ExitCode {
2993 let Some(resource) = cli.resource.clone() else {
2994 return fail(
2995 &cli,
2996 ExitClass::Usage,
2997 "--watch requires a file path or URL",
2998 );
2999 };
3000 let several = cli.resources.len() > 1;
3001 for resource in &cli.resources {
3002 if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
3003 return fail(
3004 &cli,
3005 ExitClass::Usage,
3006 "--watch requires a local file or URL, not stdin or literal markup",
3007 );
3008 }
3009 if several && is_url(resource) {
3010 return fail(
3011 &cli,
3012 ExitClass::Usage,
3013 "--watch with several resources requires local files; watch a URL on its own",
3014 );
3015 }
3016 let effective_mode = if cli.mode == Mode::Auto {
3017 detect_mode(Some(resource))
3018 } else {
3019 cli.mode
3020 };
3021 if effective_mode.draws_directly() || effective_mode == Mode::Rule {
3022 return fail(
3023 &cli,
3024 ExitClass::Usage,
3025 "--watch requires a resource-backed render mode",
3026 );
3027 }
3028 }
3029 if several && (cli.export_html.is_some() || cli.export_svg.is_some()) {
3030 return fail(
3031 &cli,
3032 ExitClass::Usage,
3033 "--export-html/--export-svg cannot be combined with watching several files",
3034 );
3035 }
3036 if !std::io::stdout().is_terminal() {
3040 cli.watch = false;
3041 if !several {
3042 return run_once(cli);
3043 }
3044 let mut first_failure = None;
3045 for resource in cli.resources.clone() {
3046 let mut single = cli.clone();
3047 single.resource = Some(resource.clone());
3048 single.resources = vec![resource];
3049 let status = run_once(single);
3050 if status != ExitClass::Success.exit_code() && first_failure.is_none() {
3051 first_failure = Some(status);
3052 }
3053 }
3054 return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
3055 }
3056 let resource = resource.as_str();
3057 if !is_url(resource) {
3058 return watch::watch_files(cli);
3059 }
3060 if is_url(resource) {
3062 #[cfg(not(feature = "fetch"))]
3063 {
3064 cli.watch = false;
3065 return run_once(cli);
3066 }
3067 }
3068
3069 let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
3070 let mut previous = None;
3071 loop {
3072 #[cfg(feature = "fetch")]
3073 let cached = if is_url(resource) && cli.watch_cache {
3074 Some(fetch_url(resource, cli.extensions.encoding))
3075 } else {
3076 None
3077 };
3078 #[cfg(not(feature = "fetch"))]
3079 let cached: Option<Result<(String, Option<String>), String>> = None;
3080 let fingerprint = match cached.as_ref() {
3081 Some(Ok((body, content_type))) => {
3082 use std::hash::{Hash, Hasher};
3083 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3084 body.hash(&mut hasher);
3085 content_type.hash(&mut hasher);
3086 format!("url:{:x}", hasher.finish())
3087 }
3088 Some(Err(error)) => format!("url-error:{error}"),
3089 None => watch_fingerprint(resource, false, cli.extensions.encoding),
3090 };
3091 let changed = previous.as_ref() != Some(&fingerprint);
3092 if changed || (is_url(resource) && !cli.watch_cache) {
3093 previous = Some(fingerprint);
3094 let console = Console::new();
3097 console.clear();
3098 console.control(&Control::home());
3099 let _ = std::io::stdout().flush();
3100 let mut iteration = cli.clone();
3101 iteration.watch = false;
3102 let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
3103 if status != ExitClass::Success.exit_code() {
3104 eprintln!("rich: watch will retry after the next change");
3107 }
3108 }
3109 std::thread::sleep(interval);
3110 }
3111}
3112
3113fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
3114 if is_url(resource) {
3115 #[cfg(feature = "fetch")]
3116 if cache_url {
3117 return match fetch_url(resource, encoding) {
3118 Ok((body, content_type)) => {
3119 use std::hash::{Hash, Hasher};
3120 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3121 body.hash(&mut hasher);
3122 content_type.hash(&mut hasher);
3123 format!("url:{:x}", hasher.finish())
3124 }
3125 Err(error) => format!("url-error:{error}"),
3126 };
3127 }
3128 #[cfg(not(feature = "fetch"))]
3129 let _ = (cache_url, encoding);
3130 return "url".to_string();
3131 }
3132 match std::fs::metadata(fs_path(resource)) {
3133 Ok(metadata) if metadata.is_file() => {
3134 use std::hash::Hasher;
3135 let mut file = match std::fs::File::open(fs_path(resource)) {
3139 Ok(file) => file,
3140 Err(error) => return format!("file-error:{error}"),
3141 };
3142 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3143 let mut buffer = [0u8; 64 * 1024];
3144 loop {
3145 match file.read(&mut buffer) {
3146 Ok(0) => break,
3147 Ok(count) => hasher.write(&buffer[..count]),
3148 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
3149 Err(error) => return format!("file-error:{error}"),
3150 }
3151 }
3152 format!("file:{:x}", hasher.finish())
3153 }
3154 Ok(_) => "not-file".to_string(),
3155 Err(_) => "missing".to_string(),
3156 }
3157}
3158
3159fn run_once(cli: Cli) -> ExitCode {
3160 run_once_with_fetch(cli, None)
3161}
3162
3163fn cli_theme(cli: &Cli) -> rich::Theme {
3166 let mut theme = rich::Theme::default_theme();
3167 for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
3168 theme.insert(name.clone(), style.clone());
3169 }
3170 theme
3171}
3172
3173impl Cli {
3174 fn themed(&self) -> bool {
3176 !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
3177 }
3178}
3179
3180fn read_theme_file(
3188 path: &str,
3189 label: &str,
3190) -> Result<std::collections::BTreeMap<String, Style>, String> {
3191 let limit = controls::THEME_FILE_LIMIT;
3192 let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
3194 if !metadata.is_file() {
3195 return Err(format!("{label}: not a regular file"));
3196 }
3197 let too_large = || {
3198 format!(
3199 "{label}: larger than {}, the most read from a theme file",
3200 controls::size(limit)
3201 )
3202 };
3203 if metadata.len() > limit {
3204 return Err(too_large());
3205 }
3206 let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
3207 let mut bytes = Vec::new();
3208 file.take(limit + 1)
3209 .read_to_end(&mut bytes)
3210 .map_err(|err| format!("{label}: {err}"))?;
3211 if bytes.len() as u64 > limit {
3212 return Err(too_large());
3213 }
3214 let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
3215 let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
3216 rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
3218 _ => match bad_style_line(&text) {
3219 Some(line) => format!("{label}: line {line}: {err}"),
3220 None => format!("{label}: {err}"),
3221 },
3222 })?;
3223 Ok(theme
3224 .names()
3225 .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
3226 .collect())
3227}
3228
3229fn bad_style_line(text: &str) -> Option<usize> {
3232 let mut in_styles = false;
3233 for (index, line) in text.lines().enumerate() {
3234 let trimmed = line.trim();
3235 if trimmed.starts_with('[') {
3236 in_styles = trimmed.eq_ignore_ascii_case("[styles]");
3237 continue;
3238 }
3239 if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
3240 continue;
3241 }
3242 let Some((_, value)) = trimmed.split_once(['=', ':']) else {
3243 continue;
3244 };
3245 if Style::parse(value.trim()).is_err() {
3246 return Some(index + 1);
3247 }
3248 }
3249 None
3250}
3251
3252fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
3253 if cli.sanitize {
3254 cli.title = cli
3255 .title
3256 .take()
3257 .map(|title| sanitize_terminal_controls(&title));
3258 cli.caption = cli
3259 .caption
3260 .take()
3261 .map(|caption| sanitize_terminal_controls(&caption));
3262 }
3263
3264 if cli.batch {
3265 return run_batch(&cli);
3266 }
3267
3268 if cli.mode == Mode::Auto && cli.resource.is_none() {
3270 run_demo(cli.no_color, std::time::Duration::ZERO);
3271 return success(&cli);
3272 }
3273
3274 let mut mode = match cli.mode {
3275 Mode::Auto => detect_mode(cli.resource.as_deref()),
3276 other => other,
3277 };
3278 if let Some(flag) = cli
3279 .data
3280 .transform_option()
3281 .filter(|_| !transforms_apply(mode))
3282 {
3283 return fail(&cli, ExitClass::Usage, transforms_misplaced(flag, mode));
3284 }
3285
3286 let mut prefetched = prefetched;
3290 let mut view_as = None;
3291 let mut view_bytes = Vec::new();
3292 if mode == Mode::View {
3293 let ext = cli
3294 .resource
3295 .as_deref()
3296 .filter(|r| *r != "-")
3297 .and_then(resource_ext);
3298 let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
3301 if !matches!(
3302 by_name,
3303 viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
3304 ) {
3305 let limit = if by_name == viewers::ViewAs::Notebook {
3308 controls::INSPECT_LIMIT
3309 } else {
3310 controls::VIEW_LIMIT
3311 };
3312 let more;
3313 (view_bytes, more) = match read_window(&cli, 0, limit) {
3314 Ok(read) => read,
3315 Err(err) => return fail(&cli, ExitClass::Input, err),
3316 };
3317 let name = cli.resource.as_deref().unwrap_or("<stdin>");
3318 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
3319 if kind == viewers::ViewAs::Binary {
3320 if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
3321 view_bytes.truncate(controls::BYTES_LIMIT as usize);
3322 viewers::notice(
3323 name,
3324 &controls::size(controls::BYTES_LIMIT),
3325 "use rich hex with --offset and --length for the rest",
3326 );
3327 }
3328 } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
3329 .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
3330 {
3331 view_bytes.truncate(end);
3333 viewers::notice(
3334 name,
3335 &format!("{} lines", controls::LINE_LIMIT),
3336 "use a pager or rich --syntax for the whole file",
3337 );
3338 } else if more {
3339 viewers::notice(
3340 name,
3341 &controls::size(limit),
3342 "use a pager or rich --syntax for the whole file",
3343 );
3344 }
3345 }
3346 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
3347 mode = match kind {
3348 viewers::ViewAs::Markdown => Mode::Markdown,
3349 viewers::ViewAs::Csv => Mode::Csv,
3350 viewers::ViewAs::Notebook => Mode::Ipynb,
3351 viewers::ViewAs::Image => Mode::Image,
3352 viewers::ViewAs::Gif => Mode::Gif,
3353 viewers::ViewAs::Log => Mode::Log,
3354 _ => Mode::View,
3355 };
3356 let text = String::from_utf8_lossy(&view_bytes).into_owned();
3357 prefetched = Some((text, None));
3358 view_as = Some(kind);
3359 }
3360
3361 let mut builder = Console::builder().no_color(cli.no_color);
3364 if cli.themed() {
3365 builder = builder.theme(cli_theme(&cli));
3366 }
3367 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
3370 builder = builder.force_terminal(terminal == "1");
3371 }
3372 if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
3373 builder = builder.width(width);
3374 }
3375 if let Some(width) = watch::capture_width() {
3378 builder = builder.width(width);
3379 }
3380 let mut console = builder.build();
3381 render_target::attach(&mut console);
3382 console.install_extensions();
3383 apply_code_highlighting(&mut console, &cli);
3384
3385 if mode == Mode::Rule {
3386 if let Some(title) = &cli.resource {
3387 if let Err(err) = Text::from_markup(title) {
3388 return fail(&cli, ExitClass::Data, err.to_string());
3389 }
3390 }
3391 }
3392
3393 let literal = mode == Mode::Rule
3399 || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
3400 let mut svg_title = cli
3401 .resource
3402 .as_deref()
3403 .filter(|r| *r != "-" && !literal)
3404 .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
3405 .unwrap_or_else(|| "rich".to_string());
3406 if cli.sanitize {
3407 svg_title = sanitize_terminal_controls(&svg_title);
3408 }
3409 let export = Export {
3410 html_path: cli.export_html.as_deref(),
3411 svg_path: cli.export_svg.as_deref(),
3412 svg_title: &svg_title,
3413 pager: cli.pager,
3414 auto_pager: cli.auto_pager,
3415 };
3416
3417 if mode == Mode::Gif {
3420 return play_gifs(&cli, &console);
3421 }
3422
3423 if mode == Mode::Diff {
3426 let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
3427 if !images {
3428 return run_text_diff(&cli, &console, &export);
3429 }
3430 if let Some(flag) = cli.tools.diff_option() {
3431 return fail(
3432 &cli,
3433 ExitClass::Usage,
3434 format!("{flag} applies to text diffs, not images"),
3435 );
3436 }
3437 #[cfg(feature = "art")]
3438 return run_diff(&cli, &console, &export);
3439 #[cfg(not(feature = "art"))]
3440 return fail(
3441 &cli,
3442 ExitClass::Usage,
3443 "this build has no image support (rebuild with the `art` feature)",
3444 );
3445 }
3446
3447 #[cfg(feature = "art")]
3450 if mode == Mode::Image {
3451 return run_image(&cli, &console, &export);
3452 }
3453 #[cfg(not(feature = "art"))]
3454 if mode == Mode::Image {
3455 return fail(
3456 &cli,
3457 ExitClass::Usage,
3458 "this build has no image support (rebuild with the `art` feature)",
3459 );
3460 }
3461
3462 if mode.streams_records() {
3463 return run_json_lines(&cli, &console, mode == Mode::Log);
3464 }
3465
3466 if mode == Mode::Rule {
3470 let rule = match cli.resource.as_deref() {
3471 Some(title) if title != "-" && cli.sanitize => {
3472 Rule::new(sanitize_terminal_controls(title))
3473 }
3474 Some(title) if title != "-" => Rule::new(title),
3475 _ => Rule::line(),
3476 };
3477 return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
3480 }
3481
3482 if mode == Mode::Capture {
3484 let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
3485 Ok(redactor) => redactor,
3486 Err(err) => return fail(&cli, ExitClass::Usage, err),
3487 };
3488 let width = cli
3491 .width
3492 .unwrap_or_else(|| console.width())
3493 .saturating_sub(4)
3494 .max(1);
3495 let cast = match viewers::cast_path(&cli.viewers) {
3498 Some(path) => match std::fs::File::create(path) {
3499 Ok(file) => Some((path, file)),
3500 Err(err) => {
3501 return fail(
3502 &cli,
3503 ExitClass::Input,
3504 format!("cannot write {path}: {err}"),
3505 )
3506 }
3507 },
3508 None => None,
3509 };
3510 let mut captured = match viewers::capture(&cli.resources, width) {
3511 Ok(captured) => captured,
3512 Err(err) => return fail(&cli, ExitClass::Input, err),
3513 };
3514 if let Some(notice) = &captured.notice {
3515 eprintln!("rich: capture: {notice}");
3516 }
3517 if let Some(redactor) = &redactor {
3519 captured.redact(redactor);
3520 }
3521 if cli.sanitize {
3522 captured.sanitize();
3523 }
3524 if let Some((path, mut file)) = cast {
3525 let cast = viewers::asciicast(&captured, width, console.height());
3526 if let Err(err) = file.write_all(cast.as_bytes()) {
3527 return fail(
3528 &cli,
3529 ExitClass::Input,
3530 format!("cannot write {path}: {err}"),
3531 );
3532 }
3533 }
3534 let view = viewers::capture_view(&captured);
3535 let fit = view.measure(&console, &console.options()).maximum;
3536 let code = captured.exit_code();
3539 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
3540 if code == 0 || shown.is_err() {
3541 return emit_exit_code(cli, shown);
3542 }
3543 if cli.report_format == ReportFormat::Json {
3545 eprintln!("{}", viewers::capture_report(&captured));
3546 }
3547 ExitCode::from(code)
3548 });
3549 }
3550 if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
3551 return fail(
3552 &cli,
3553 ExitClass::Usage,
3554 "this build has no Mermaid support (the mermaid feature)",
3555 );
3556 }
3557 if mode == Mode::Env {
3558 let view = viewers::env(&cli.viewers, &cli.resources);
3559 let fit = view.measure(&console, &console.options()).maximum;
3560 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
3561 }
3562 if matches!(mode, Mode::Hex | Mode::Unicode) {
3563 let hex = mode == Mode::Hex;
3566 let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
3567 let length = cli.viewers.length().filter(|_| hex);
3568 let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
3569 let (bytes, more) = match read_window(&cli, offset, max) {
3570 Ok(read) => read,
3571 Err(err) => return fail(&cli, ExitClass::Input, err),
3572 };
3573 if more && length.is_none() {
3574 let hint = if hex {
3575 "use --offset and --length for the rest"
3576 } else {
3577 "pass a shorter input"
3578 };
3579 viewers::notice(
3580 cli.resource.as_deref().unwrap_or("<stdin>"),
3581 &controls::size(controls::BYTES_LIMIT),
3582 hint,
3583 );
3584 }
3585 let view = if hex {
3586 viewers::hex(&cli.viewers, bytes, offset)
3587 } else {
3588 viewers::unicode(&cli.viewers, &bytes)
3589 };
3590 let view = match view {
3591 Ok(view) => view,
3592 Err(err) => return fail(&cli, ExitClass::Usage, err),
3593 };
3594 let fit = view.measure(&console, &console.options()).maximum;
3595 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
3596 }
3597
3598 let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
3602 let (mut content, content_type) = if let Some(fetched) = prefetched {
3603 fetched
3604 } else if resource_is_url {
3605 match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
3606 Ok(fetched) => fetched,
3607 Err(err) => {
3608 return fail(&cli, ExitClass::Input, err);
3609 }
3610 }
3611 } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
3612 (cli.resource.clone().unwrap(), None)
3613 } else {
3614 let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
3616 match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
3617 Ok(content) => (content, None),
3618 Err(err) => {
3619 let name = cli.resource.as_deref().unwrap_or("<stdin>");
3620 return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
3621 }
3622 }
3623 };
3624 if cli.sanitize && mode != Mode::AnsiExplain {
3627 content = sanitize_terminal_controls(&content);
3628 }
3629
3630 let mut routed_language = None;
3633 let mut routed = false;
3634 if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto) {
3635 if mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)) {
3636 routed = true;
3637 mode = match inspect::route(format, &content, cli.resource.as_deref()) {
3638 inspect::Route::Json => Mode::Json,
3639 inspect::Route::Syntax(lexer) => {
3640 routed_language = Some(lexer);
3641 Mode::Syntax
3642 }
3643 inspect::Route::Text => Mode::Auto,
3644 };
3645 }
3646 }
3647
3648 if resource_is_url && mode == Mode::Auto && !routed {
3652 mode = content_type
3653 .as_deref()
3654 .and_then(content_type_mode)
3655 .unwrap_or(Mode::Syntax);
3656 }
3657
3658 let json_content = if mode == Mode::Json && cli.sanitize {
3660 match sanitize_json_source(content.trim()) {
3661 Ok(content) => Some(content),
3662 Err(err) => {
3663 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
3664 }
3665 }
3666 } else {
3667 None
3668 };
3669
3670 let json = if mode == Mode::Json {
3671 let source = json_content.as_deref().unwrap_or_else(|| content.trim());
3672 match Json::new(source) {
3673 Ok(json) => Some(json),
3674 Err(err) => {
3675 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
3676 }
3677 }
3678 } else {
3679 None
3680 };
3681
3682 if mode == Mode::Ipynb {
3687 let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
3688 Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
3689 if cli.sanitize {
3690 sanitize_json_value(&mut value);
3691 }
3692 value
3693 }
3694 Ok(_) => {
3695 return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
3696 }
3697 Err(err) => {
3698 return fail(
3699 &cli,
3700 ExitClass::Data,
3701 format!("invalid notebook JSON: {err}"),
3702 );
3703 }
3704 };
3705 let renderable = build_ipynb(¬ebook, cli.hyperlinks, cli.justify);
3706 let fit = renderable.measure(&console, &console.options()).maximum;
3707 return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
3708 }
3709
3710 if mode == Mode::Print {
3714 if let Err(err) = console.try_build_text(&content) {
3715 return fail(&cli, ExitClass::Data, err.to_string());
3716 }
3717 }
3718
3719 let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
3724 .filter(|ext| !is_uninformative_ext(ext));
3725 let language = routed_language
3726 .map(str::to_string)
3727 .or(extension)
3728 .or_else(|| {
3729 content_type
3730 .as_deref()
3731 .and_then(content_type_lexer)
3732 .map(str::to_string)
3733 })
3734 .unwrap_or_default();
3735
3736 if let Some(flag) = cli
3738 .data
3739 .transform_option()
3740 .filter(|_| !transforms_apply(mode))
3741 {
3742 return fail(&cli, ExitClass::Usage, transforms_misplaced(flag, mode));
3743 }
3744
3745 let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
3748 Mode::Markdown => {
3751 let mut markdown = build_markdown(&content, cli.hyperlinks);
3752 if let Some(fences) =
3754 plugin_registry(cli.mermaid_backend.unwrap_or(MermaidBackend::Text)).fences()
3755 {
3756 markdown = markdown.fence_renderer(fences);
3757 }
3758 (Box::new(markdown), None)
3759 }
3760 #[cfg(feature = "mermaid")]
3761 Mode::Mermaid => {
3762 let default = if cfg!(feature = "mmdc") {
3764 MermaidBackend::Mmdc
3765 } else {
3766 MermaidBackend::Text
3767 };
3768 let backend = match cli.mermaid_backend.unwrap_or(default) {
3769 MermaidBackend::Off => MermaidBackend::Text,
3770 chosen => chosen,
3771 };
3772 let diagram =
3773 rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
3774 (Box::new(diagram), None)
3775 }
3776 Mode::Json => {
3777 let nested = cli.padding.is_some()
3794 || cli.panel.is_some()
3795 || cli.style.is_some()
3796 || cli.width.is_some()
3797 || cli.pager
3798 || cli.auto_pager;
3799 let json = json.expect("json parsed above").no_wrap(nested);
3800 #[cfg(feature = "json-escape-safe")]
3801 let json = json.escape_safe(true);
3802 let fit = measure_rendered(&console, &json);
3803 (Box::new(json), Some(fit))
3804 }
3805 Mode::Csv => {
3806 if let Err(err) = validate_labels(&cli) {
3807 return fail(&cli, ExitClass::Data, err);
3808 }
3809 let fallback = csv_fallback_delimiter(cli.resource.as_deref());
3812 let table = match build_csv_table(
3813 &content,
3814 fallback,
3815 cli.title.as_deref(),
3816 cli.caption.as_deref(),
3817 ) {
3818 Some(table) => table,
3819 None => {
3822 return fail(&cli, ExitClass::Data, "Could not determine delimiter");
3823 }
3824 };
3825 if cli.panel.is_none()
3829 && cli.padding.is_none()
3830 && cli.style.is_none()
3831 && cli.justify.is_none()
3832 && !cli.pager
3833 && !cli.auto_pager
3834 && cli.export_html.is_none()
3835 && cli.export_svg.is_none()
3836 && !watch::capturing()
3837 {
3838 let mut options = console.options();
3839 options.max_width = cli.width.unwrap_or_else(|| {
3840 table
3841 .measure(&console, &options)
3842 .maximum
3843 .min(options.max_width)
3844 .max(1)
3845 });
3846 let stdout = std::io::stdout();
3847 let mut output = std::io::BufWriter::new(stdout.lock());
3848 let result = table
3849 .try_for_each_line(&console, &options, |line| {
3850 let line = Segment::crop_lines(&line, console.width());
3851 writeln!(output, "{}", console.segments_to_string(&line))
3852 })
3853 .and_then(|()| output.flush());
3854 if let Err(error) = result {
3855 if error.kind() == std::io::ErrorKind::BrokenPipe {
3858 return success(&cli);
3859 }
3860 return fail(
3861 &cli,
3862 ExitClass::Input,
3863 format!("could not write CSV output: {error}"),
3864 );
3865 }
3866 return success(&cli);
3867 }
3868 let fit = measure_rendered(&console, &table);
3869 (Box::new(table), Some(fit))
3870 }
3871 Mode::Syntax if cli.data.transform_option().is_some() => {
3873 let text = Syntax::new(content.as_str(), language.as_str()).highlight_for(&console);
3874 let text = match cli
3875 .data
3876 .text_pipeline()
3877 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
3878 {
3879 Ok(text) => text,
3880 Err(err) => return fail(&cli, ExitClass::Data, err),
3881 };
3882 let fit = text.measurement().1;
3883 let background = match text.base_style() {
3886 rich::style::StyleType::Style(style) => style.clone(),
3887 rich::style::StyleType::Name(_) => rich::Style::new(),
3888 };
3889 let padded =
3892 rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
3893 (Box::new(padded), Some(fit))
3894 }
3895 Mode::Syntax => {
3896 let fit = content.lines().map(cell_len).max().unwrap_or(0);
3899 (
3900 Box::new(Syntax::new(content.as_str(), language.as_str()).word_wrap(true)),
3901 Some(fit),
3902 )
3903 }
3904 Mode::View => {
3905 let view: Box<dyn Renderable> = match view_as {
3906 Some(viewers::ViewAs::Patch) => {
3907 let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
3908 match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
3909 Ok(outcome) => outcome.renderable,
3910 Err(err) => return fail(&cli, ExitClass::Data, err),
3911 }
3912 }
3913 Some(viewers::ViewAs::Binary) => {
3914 match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
3915 Ok(view) => view,
3916 Err(err) => return fail(&cli, ExitClass::Usage, err),
3917 }
3918 }
3919 Some(viewers::ViewAs::Source(ref lexer)) => {
3920 if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
3921 eprintln!("{summary}");
3922 }
3923 viewers::source(&cli.viewers, &content, lexer)
3924 }
3925 _ => viewers::source(&cli.viewers, &content, "txt"),
3926 };
3927 let fit = view.measure(&console, &console.options()).maximum;
3928 (view, Some(fit))
3929 }
3930 Mode::AnsiExplain => {
3931 let view = tools::ansi_explain(&cli.tools, &content);
3932 let fit = view.measure(&console, &console.options()).maximum;
3933 (view, Some(fit))
3934 }
3935 Mode::Inspect => {
3936 let encoding = cli.extensions.encoding;
3937 let read = |path: &str| {
3938 read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
3939 .map_err(|err| format!("cannot read {path}: {err}"))
3940 };
3941 let view = match inspect::build(&cli.data, &content, cli.resource.as_deref(), read) {
3942 Ok(view) => view,
3943 Err(err) => return fail(&cli, ExitClass::Data, err),
3944 };
3945 let fit = view.measure(&console, &console.options()).maximum;
3946 (view, Some(fit))
3947 }
3948 _ => {
3951 let text = if mode == Mode::Print {
3952 console.build_text(&content)
3953 } else {
3954 Text::new(content.as_str())
3955 };
3956 let text = match cli
3957 .data
3958 .text_pipeline()
3959 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
3960 {
3961 Ok(text) => text,
3962 Err(err) => return fail(&cli, ExitClass::Data, err),
3963 };
3964 let fit = text.measurement().1;
3965 (Box::new(text), Some(fit))
3966 }
3967 };
3968 decorate_and_emit(&cli, &console, &export, renderable, fit)
3969}
3970
3971#[allow(dead_code)]
3974fn exit_code(ok: bool) -> ExitCode {
3975 if ok {
3976 ExitCode::SUCCESS
3977 } else {
3978 ExitCode::FAILURE
3979 }
3980}
3981
3982fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
3983 match result {
3984 Ok(()) => success(cli),
3985 Err(message) => fail(cli, ExitClass::Input, message),
3986 }
3987}
3988
3989fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
3990 let output = console.render_export(renderable);
3991 let stdout = std::io::stdout();
3992 let mut lock = stdout.lock();
3993 lock.write_all(output.as_bytes())
3994}
3995
3996fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
3997 if cli.extensions.encoding.is_some() {
3998 return fail(
3999 cli,
4000 ExitClass::Usage,
4001 "--encoding is not supported with streaming JSONL/log mode",
4002 );
4003 }
4004 if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
4005 return fail(
4006 cli,
4007 ExitClass::Usage,
4008 "streaming JSONL/log mode reads files or stdin, not URLs",
4009 );
4010 }
4011
4012 let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
4013 Some(path) if path != "-" => {
4014 let file = &fs_path(path);
4015 if file.is_dir() {
4016 return fail(
4017 cli,
4018 ExitClass::Input,
4019 format!("cannot read {path}: is a directory, not a file"),
4020 );
4021 }
4022 match std::fs::File::open(file) {
4023 Ok(file) => Box::new(std::io::BufReader::new(file)),
4024 Err(err) => {
4025 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
4026 }
4027 }
4028 }
4029 _ => {
4030 if std::io::stdin().is_terminal() {
4031 let eof = if cfg!(windows) {
4032 "Ctrl-Z then Enter"
4033 } else {
4034 "Ctrl-D"
4035 };
4036 eprintln!("rich: reading stdin; finish input with {eof}");
4037 }
4038 Box::new(std::io::BufReader::new(std::io::stdin()))
4039 }
4040 };
4041
4042 let mut line = String::new();
4043 let mut line_number = 0usize;
4044 loop {
4045 line.clear();
4046 let read = match input.read_line(&mut line) {
4047 Ok(read) => read,
4048 Err(err) => {
4049 return fail(
4050 cli,
4051 ExitClass::Input,
4052 format!("cannot read JSONL stream: {err}"),
4053 );
4054 }
4055 };
4056 if read == 0 {
4057 break;
4058 }
4059 line_number += 1;
4060 let record = line.trim_end_matches(['\r', '\n']);
4061 let mut value = match serde_json::from_str::<serde_json::Value>(record) {
4062 Ok(value) => value,
4063 Err(err) => {
4064 return fail(
4065 cli,
4066 ExitClass::Data,
4067 format!("invalid JSONL at line {line_number}: {err}"),
4068 );
4069 }
4070 };
4071 if cli.sanitize {
4072 sanitize_json_value(&mut value);
4073 }
4074 let wrote = if log_mode {
4075 if cli.log_presentation == "rich" {
4076 write_rendered_stdout(console, &structured_log::event(&value))
4077 } else {
4078 let text = Text::new(format_log_record(&value));
4079 write_rendered_stdout(console, &text)
4080 }
4081 } else {
4082 let source = serde_json::to_string(&value).expect("JSON value serializes");
4083 let json = Json::new(&source).expect("serialized JSON parses");
4084 write_rendered_stdout(console, &json)
4085 };
4086 if let Err(error) = wrote {
4087 if error.kind() == std::io::ErrorKind::BrokenPipe {
4088 return success(cli);
4089 }
4090 return fail(
4091 cli,
4092 ExitClass::Input,
4093 format!("could not write JSONL output: {error}"),
4094 );
4095 }
4096 }
4097 success(cli)
4098}
4099
4100fn format_log_record(value: &serde_json::Value) -> String {
4101 let Some(object) = value.as_object() else {
4102 return serde_json::to_string(value).expect("JSON value serializes");
4103 };
4104 let take_value = |keys: &[&'static str]| {
4105 keys.iter().find_map(|key| {
4106 object.get(*key).map(|value| {
4107 let display = value.as_str().map(str::to_string).unwrap_or_else(|| {
4108 serde_json::to_string(value).expect("JSON value serializes")
4109 });
4110 (*key, display, value.is_string())
4111 })
4112 })
4113 };
4114 let timestamp = take_value(&["timestamp", "time", "@timestamp"]);
4115 let level = take_value(&["level", "severity"]);
4116 let message = take_value(&["message", "msg"]);
4117 let mut rest = serde_json::Map::new();
4118 for (key, value) in object {
4119 let consumed = timestamp
4120 .as_ref()
4121 .is_some_and(|(consumed, _, _)| consumed == key)
4122 || level
4123 .as_ref()
4124 .is_some_and(|(consumed, _, _)| consumed == key)
4125 || message
4126 .as_ref()
4127 .is_some_and(|(consumed, _, _)| consumed == key);
4128 if !consumed {
4129 rest.insert(key.clone(), value.clone());
4130 }
4131 }
4132 let mut parts = Vec::new();
4133 if let Some((_, timestamp, _)) = timestamp {
4134 parts.push(timestamp);
4135 }
4136 if let Some((_, level, is_string)) = level {
4137 parts.push(if is_string {
4138 level.to_ascii_uppercase()
4139 } else {
4140 level
4141 });
4142 }
4143 if let Some((_, message, _)) = message {
4144 parts.push(message);
4145 }
4146 if !rest.is_empty() {
4147 parts.push(
4148 serde_json::to_string(&serde_json::Value::Object(rest)).expect("JSON value serializes"),
4149 );
4150 }
4151 if parts.is_empty() {
4152 serde_json::to_string(value).expect("JSON value serializes")
4153 } else {
4154 parts.join(" ")
4155 }
4156}
4157
4158#[derive(Debug, Clone, Copy)]
4160struct Dialect {
4161 delimiter: char,
4162 quotechar: char,
4163 doublequote: bool,
4164 skipinitialspace: bool,
4165}
4166
4167impl Dialect {
4168 fn excel(delimiter: char) -> Self {
4171 Dialect {
4172 delimiter,
4173 quotechar: '"',
4174 doublequote: true,
4175 skipinitialspace: false,
4176 }
4177 }
4178}
4179
4180fn is_word_char(c: char) -> bool {
4182 c.is_alphanumeric() || c == '_'
4183}
4184
4185fn is_delimiter_char(c: char) -> bool {
4187 !is_word_char(c) && c != '\n' && c != '"' && c != '\''
4188}
4189
4190fn is_quote_char(c: char) -> bool {
4192 c == '"' || c == '\''
4193}
4194
4195struct QuoteHit {
4199 quote: char,
4200 delim: Option<char>,
4201 space: bool,
4202}
4203
4204fn sniff(sample: &str, delimiters: Option<&[char]>) -> Option<Dialect> {
4217 let data: Vec<char> = sample.chars().collect();
4218 let (quote, mut delimiter, mut skipinitialspace) = guess_quote_and_delimiter(&data, delimiters);
4219 if delimiter.is_none() {
4220 let (guessed, spaced) = guess_delimiter(sample, delimiters);
4221 delimiter = guessed;
4222 skipinitialspace = spaced;
4223 }
4224 Some(Dialect {
4225 delimiter: delimiter?,
4226 quotechar: quote.unwrap_or('"'),
4229 doublequote: true,
4230 skipinitialspace,
4231 })
4232}
4233
4234fn guess_quote_and_delimiter(
4242 data: &[char],
4243 delimiters: Option<&[char]>,
4244) -> (Option<char>, Option<char>, bool) {
4245 let hits = quote_hits(data);
4246 if hits.is_empty() {
4247 return (None, None, false);
4248 }
4249
4250 let mut quotes: Vec<(char, usize)> = Vec::new();
4254 let mut delims: Vec<(char, usize)> = Vec::new();
4255 let mut spaces = 0usize;
4256 fn bump(table: &mut Vec<(char, usize)>, key: char) {
4257 match table.iter_mut().find(|(existing, _)| *existing == key) {
4258 Some(entry) => entry.1 += 1,
4259 None => table.push((key, 1)),
4260 }
4261 }
4262 for hit in &hits {
4263 bump(&mut quotes, hit.quote);
4264 let Some(delim) = hit.delim else { continue };
4267 if delimiters.is_none_or(|allowed| allowed.contains(&delim)) {
4268 bump(&mut delims, delim);
4269 }
4270 if hit.space {
4271 spaces += 1;
4272 }
4273 }
4274
4275 let first_max = |table: &[(char, usize)]| -> Option<(char, usize)> {
4276 let mut best: Option<(char, usize)> = None;
4277 for &(key, count) in table {
4278 if best.is_none_or(|(_, seen)| count > seen) {
4279 best = Some((key, count));
4280 }
4281 }
4282 best
4283 };
4284 let quotechar = first_max("es).map(|(key, _)| key);
4285 match first_max(&delims) {
4286 Some((delim, count)) => (quotechar, Some(delim), count == spaces),
4287 None => (quotechar, None, false),
4289 }
4290}
4291
4292type QuoteScan = fn(&[char]) -> Vec<QuoteHit>;
4294
4295fn quote_hits(data: &[char]) -> Vec<QuoteHit> {
4298 let scans: [QuoteScan; 4] = [
4299 scan_delim_quote_delim,
4300 scan_line_quote_delim,
4301 scan_delim_quote_line,
4302 scan_line_quote_line,
4303 ];
4304 for scan in scans {
4305 let hits = scan(data);
4306 if !hits.is_empty() {
4307 return hits;
4308 }
4309 }
4310 Vec::new()
4311}
4312
4313fn scan_delim_quote_delim(data: &[char]) -> Vec<QuoteHit> {
4318 scan_delim_quote(data, |data, quote, delim, from| {
4319 let mut k = from;
4320 while k + 1 < data.len() {
4321 if data[k] == quote && data[k + 1] == delim {
4322 return Some(k + 2);
4323 }
4324 k += 1;
4325 }
4326 None
4327 })
4328}
4329
4330fn scan_delim_quote_line(data: &[char]) -> Vec<QuoteHit> {
4333 scan_delim_quote(data, |data, quote, _delim, from| {
4334 let mut k = from;
4335 while k < data.len() {
4336 if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
4337 return Some(k + 1);
4340 }
4341 k += 1;
4342 }
4343 None
4344 })
4345}
4346
4347fn scan_delim_quote(
4350 data: &[char],
4351 close: fn(&[char], char, char, usize) -> Option<usize>,
4352) -> Vec<QuoteHit> {
4353 let mut hits = Vec::new();
4354 let mut i = 0;
4355 while i < data.len() {
4356 if is_delimiter_char(data[i]) {
4357 let delim = data[i];
4358 let mut j = i + 1;
4359 let mut space = false;
4360 if j < data.len() && data[j] == ' ' {
4361 space = true;
4362 j += 1;
4363 }
4364 if j < data.len() && is_quote_char(data[j]) {
4365 let quote = data[j];
4366 if let Some(end) = close(data, quote, delim, j + 1) {
4367 hits.push(QuoteHit {
4368 quote,
4369 delim: Some(delim),
4370 space,
4371 });
4372 i = end;
4373 continue;
4374 }
4375 }
4376 }
4377 i += 1;
4378 }
4379 hits
4380}
4381
4382fn scan_line_quote_delim(data: &[char]) -> Vec<QuoteHit> {
4385 scan_line_quote(data, |data, quote, from| {
4386 let mut k = from;
4387 while k + 1 < data.len() {
4388 if data[k] == quote && is_delimiter_char(data[k + 1]) {
4389 let space = k + 2 < data.len() && data[k + 2] == ' ';
4390 return Some((Some(data[k + 1]), space, if space { k + 3 } else { k + 2 }));
4391 }
4392 k += 1;
4393 }
4394 None
4395 })
4396}
4397
4398fn scan_line_quote_line(data: &[char]) -> Vec<QuoteHit> {
4401 scan_line_quote(data, |data, quote, from| {
4402 let mut k = from;
4403 while k < data.len() {
4404 if data[k] == quote && (k + 1 == data.len() || data[k + 1] == '\n') {
4405 return Some((None, false, k + 1));
4406 }
4407 k += 1;
4408 }
4409 None
4410 })
4411}
4412
4413#[allow(clippy::type_complexity)]
4416fn scan_line_quote(
4417 data: &[char],
4418 close: fn(&[char], char, usize) -> Option<(Option<char>, bool, usize)>,
4419) -> Vec<QuoteHit> {
4420 let mut hits = Vec::new();
4421 let mut i = 0;
4422 while i < data.len() {
4423 let mut starts: Vec<usize> = Vec::new();
4427 if i == 0 || data[i - 1] == '\n' {
4428 starts.push(i);
4429 }
4430 if data[i] == '\n' {
4431 starts.push(i + 1);
4432 }
4433 let mut advanced = false;
4434 for quote_at in starts {
4435 if quote_at >= data.len() || !is_quote_char(data[quote_at]) {
4436 continue;
4437 }
4438 let quote = data[quote_at];
4439 if let Some((delim, space, end)) = close(data, quote, quote_at + 1) {
4440 hits.push(QuoteHit {
4441 quote,
4442 delim,
4443 space,
4444 });
4445 i = end;
4446 advanced = true;
4447 break;
4448 }
4449 }
4450 if !advanced {
4451 i += 1;
4452 }
4453 }
4454 hits
4455}
4456
4457fn guess_delimiter(sample: &str, delimiters: Option<&[char]>) -> (Option<char>, bool) {
4460 let data: Vec<&str> = sample.split('\n').filter(|line| !line.is_empty()).collect();
4462 if data.is_empty() {
4463 return (None, false);
4464 }
4465 const ASCII: usize = 127;
4467
4468 let chunk_length = 10.min(data.len());
4469 let mut iteration = 0usize;
4470 let mut char_frequency: Vec<Vec<(usize, usize)>> = vec![Vec::new(); ASCII];
4473 let mut modes: Vec<Option<(usize, isize)>> = vec![None; ASCII];
4476 let mut delims: Vec<(char, (usize, isize))> = Vec::new();
4477
4478 let (mut start, mut end) = (0usize, chunk_length);
4479 while start < data.len() {
4480 iteration += 1;
4481 for line in &data[start..end.min(data.len())] {
4482 for (code, table) in char_frequency.iter_mut().enumerate() {
4483 let c = code as u8 as char;
4484 let freq = line.matches(c).count();
4487 match table.iter_mut().find(|(seen, _)| *seen == freq) {
4488 Some(entry) => entry.1 += 1,
4489 None => table.push((freq, 1)),
4490 }
4491 }
4492 }
4493
4494 for (code, items) in char_frequency.iter().enumerate() {
4495 if items.len() == 1 && items[0].0 == 0 {
4496 continue;
4497 }
4498 if items.len() > 1 {
4499 let mut best = 0usize;
4502 for (index, item) in items.iter().enumerate() {
4503 if item.1 > items[best].1 {
4504 best = index;
4505 }
4506 }
4507 let others: usize = items
4508 .iter()
4509 .enumerate()
4510 .filter(|(index, _)| *index != best)
4511 .map(|(_, item)| item.1)
4512 .sum();
4513 modes[code] = Some((items[best].0, items[best].1 as isize - others as isize));
4514 } else if let Some(&(freq, count)) = items.first() {
4515 modes[code] = Some((freq, count as isize));
4516 }
4517 }
4518
4519 let total = (chunk_length * iteration).min(data.len()) as f64;
4520 let mut consistency = 1.0f64;
4521 let threshold = 0.9f64;
4522 while delims.is_empty() && consistency >= threshold {
4523 for (code, mode) in modes.iter().enumerate() {
4524 let Some((freq, count)) = *mode else { continue };
4525 let c = code as u8 as char;
4526 if freq > 0
4527 && count > 0
4528 && (count as f64 / total) >= consistency
4529 && delimiters.is_none_or(|allowed| allowed.contains(&c))
4530 {
4531 delims.push((c, (freq, count)));
4532 }
4533 }
4534 consistency -= 0.01;
4535 }
4536
4537 if delims.len() == 1 {
4538 let delim = delims[0].0;
4539 return (Some(delim), saw_space_after(data[0], delim));
4540 }
4541
4542 start = end;
4543 end += chunk_length;
4544 }
4545
4546 if delims.is_empty() {
4547 return (None, false);
4548 }
4549 if delims.len() > 1 {
4550 for preferred in [',', '\t', ';', ' ', ':'] {
4551 if delims.iter().any(|(c, _)| *c == preferred) {
4552 return (Some(preferred), saw_space_after(data[0], preferred));
4553 }
4554 }
4555 }
4556 let best = delims
4558 .iter()
4559 .max_by_key(|(c, mode)| (*mode, *c))
4560 .expect("delims is non-empty");
4561 (Some(best.0), saw_space_after(data[0], best.0))
4562}
4563
4564fn saw_space_after(line: &str, delimiter: char) -> bool {
4567 line.matches(delimiter).count() == line.matches(&format!("{delimiter} ")).count()
4568}
4569
4570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4572enum ColumnType {
4573 Number,
4575 Length(usize),
4577}
4578
4579#[derive(Debug, Clone, Copy)]
4581enum ColumnVote {
4582 Untyped,
4584 Typed(ColumnType),
4585 Dropped,
4587}
4588
4589fn has_header(sample: &str) -> Option<bool> {
4601 let dialect = sniff(sample, None)?;
4604 let mut rows = read_csv_rows(sample, &dialect).into_iter();
4605 let header = rows.next()?;
4606 let columns = header.len();
4607 let mut votes = vec![ColumnVote::Untyped; columns];
4608
4609 for (checked, row) in rows.enumerate() {
4610 if checked > 20 {
4613 break;
4614 }
4615 if row.len() != columns {
4616 continue;
4617 }
4618 for (col, vote) in votes.iter_mut().enumerate() {
4619 if matches!(vote, ColumnVote::Dropped) {
4620 continue;
4621 }
4622 let this = if parses_as_complex(&row[col]) {
4623 ColumnType::Number
4624 } else {
4625 ColumnType::Length(row[col].chars().count())
4626 };
4627 match vote {
4628 ColumnVote::Untyped => *vote = ColumnVote::Typed(this),
4629 ColumnVote::Typed(known) if *known != this => *vote = ColumnVote::Dropped,
4630 _ => {}
4631 }
4632 }
4633 }
4634
4635 let mut tally = 0isize;
4636 for (col, vote) in votes.iter().enumerate() {
4637 match vote {
4638 ColumnVote::Dropped => {}
4639 ColumnVote::Untyped => tally += 1,
4642 ColumnVote::Typed(ColumnType::Length(len)) => {
4643 if header[col].chars().count() == *len {
4644 tally -= 1;
4645 } else {
4646 tally += 1;
4647 }
4648 }
4649 ColumnVote::Typed(ColumnType::Number) => {
4650 if parses_as_complex(&header[col]) {
4651 tally -= 1;
4652 } else {
4653 tally += 1;
4654 }
4655 }
4656 }
4657 }
4658 Some(tally > 0)
4659}
4660
4661fn parses_as_complex(value: &str) -> bool {
4671 let trimmed = value.trim();
4672 let body = match trimmed.strip_prefix('(') {
4673 Some(inner) => match inner.strip_suffix(')') {
4674 Some(inner) => inner.trim(),
4675 None => return false,
4676 },
4677 None if trimmed.ends_with(')') => return false,
4678 None => trimmed,
4679 };
4680 if body.is_empty() {
4681 return false;
4682 }
4683 let chars: Vec<char> = body.chars().collect();
4686 let split = (1..chars.len())
4687 .find(|&i| matches!(chars[i], '+' | '-') && !matches!(chars[i - 1], 'e' | 'E'));
4688 match split {
4689 Some(index) => {
4690 let at: usize = chars[..index].iter().map(|c| c.len_utf8()).sum();
4691 let (real, imaginary) = body.split_at(at);
4692 is_float_literal(real) && is_imaginary_literal(imaginary)
4693 }
4694 None => is_float_literal(body) || is_imaginary_literal(body),
4695 }
4696}
4697
4698fn is_float_literal(value: &str) -> bool {
4701 let body = value.strip_prefix(['+', '-']).unwrap_or(value);
4702 if matches!(
4703 body.to_ascii_lowercase().as_str(),
4704 "inf" | "infinity" | "nan"
4705 ) {
4706 return true;
4707 }
4708 let chars: Vec<char> = body.chars().collect();
4709 let mut index = 0usize;
4710 let mut digits = 0usize;
4711 let take_digits = |index: &mut usize, digits: &mut usize| {
4712 while *index < chars.len() && (chars[*index].is_ascii_digit() || chars[*index] == '_') {
4713 if chars[*index].is_ascii_digit() {
4714 *digits += 1;
4715 }
4716 *index += 1;
4717 }
4718 };
4719 take_digits(&mut index, &mut digits);
4720 if index < chars.len() && chars[index] == '.' {
4721 index += 1;
4722 take_digits(&mut index, &mut digits);
4723 }
4724 if digits == 0 {
4725 return false;
4726 }
4727 if index < chars.len() && (chars[index] == 'e' || chars[index] == 'E') {
4728 index += 1;
4729 if index < chars.len() && matches!(chars[index], '+' | '-') {
4730 index += 1;
4731 }
4732 let mut exponent = 0usize;
4733 take_digits(&mut index, &mut exponent);
4734 if exponent == 0 {
4735 return false;
4736 }
4737 }
4738 index == chars.len()
4739}
4740
4741fn is_imaginary_literal(value: &str) -> bool {
4743 let Some(body) = value.strip_suffix(['j', 'J']) else {
4744 return false;
4745 };
4746 matches!(body, "" | "+" | "-") || is_float_literal(body)
4747}
4748
4749enum State {
4752 StartRecord,
4753 StartField,
4754 InField,
4755 InQuotedField,
4756 QuoteInQuotedField,
4757}
4758
4759fn read_csv_rows(content: &str, dialect: &Dialect) -> Vec<Vec<String>> {
4768 let content = content.strip_prefix('\u{feff}').unwrap_or(content);
4770 let mut rows: Vec<Vec<String>> = Vec::new();
4771 let mut row: Vec<String> = Vec::new();
4772 let mut field = String::new();
4773 let mut state = State::StartRecord;
4774
4775 for c in content.chars() {
4776 match state {
4777 State::StartRecord => {
4778 if c == '\n' {
4779 rows.push(Vec::new());
4780 } else {
4781 state = State::StartField;
4782 read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows);
4784 }
4785 }
4786 _ => read_csv_char(c, dialect, &mut state, &mut field, &mut row, &mut rows),
4787 }
4788 }
4789 if !matches!(state, State::StartRecord) {
4790 row.push(std::mem::take(&mut field));
4791 row.shrink_to_fit();
4792 rows.push(std::mem::take(&mut row));
4793 }
4794 rows
4795}
4796
4797fn read_csv_char(
4800 c: char,
4801 dialect: &Dialect,
4802 state: &mut State,
4803 field: &mut String,
4804 row: &mut Vec<String>,
4805 rows: &mut Vec<Vec<String>>,
4806) {
4807 let end_record = |field: &mut String, row: &mut Vec<String>, rows: &mut Vec<Vec<String>>| {
4808 row.push(std::mem::take(field));
4809 row.shrink_to_fit();
4811 rows.push(std::mem::take(row));
4812 };
4813 match state {
4814 State::StartRecord => unreachable!("handled by the caller"),
4815 State::StartField => {
4816 if c == '\n' {
4817 end_record(field, row, rows);
4818 *state = State::StartRecord;
4819 } else if c == dialect.quotechar {
4820 *state = State::InQuotedField;
4821 } else if c == ' ' && dialect.skipinitialspace {
4822 } else if c == dialect.delimiter {
4824 row.push(std::mem::take(field));
4825 } else {
4826 field.push(c);
4827 *state = State::InField;
4828 }
4829 }
4830 State::InField => {
4831 if c == '\n' {
4832 end_record(field, row, rows);
4833 *state = State::StartRecord;
4834 } else if c == dialect.delimiter {
4835 row.push(std::mem::take(field));
4836 *state = State::StartField;
4837 } else {
4838 field.push(c);
4839 }
4840 }
4841 State::InQuotedField => {
4842 if c == dialect.quotechar {
4843 *state = if dialect.doublequote {
4844 State::QuoteInQuotedField
4845 } else {
4846 State::InField
4849 };
4850 } else {
4851 field.push(c);
4852 }
4853 }
4854 State::QuoteInQuotedField => {
4855 if c == dialect.quotechar {
4856 field.push(c);
4857 *state = State::InQuotedField;
4858 } else if c == dialect.delimiter {
4859 row.push(std::mem::take(field));
4860 *state = State::StartField;
4861 } else if c == '\n' {
4862 end_record(field, row, rows);
4863 *state = State::StartRecord;
4864 } else {
4865 field.push(c);
4866 *state = State::InField;
4867 }
4868 }
4869 }
4870}
4871
4872fn is_number(value: &str) -> bool {
4878 let body = value.strip_prefix('-').unwrap_or(value);
4879 let (integer, fraction) = match body.split_once('.') {
4880 Some((integer, fraction)) => (integer, Some(fraction)),
4881 None => (body, None),
4882 };
4883 let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
4884 digits(integer) && fraction.is_none_or(digits)
4885}
4886
4887fn build_csv_table(
4902 content: &str,
4903 fallback_delimiter: Option<char>,
4904 title: Option<&str>,
4905 caption: Option<&str>,
4906) -> Option<Table> {
4907 let sample: String = content.chars().take(1024).collect();
4910 let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
4911 let (dialect, header) = match sniffed {
4912 Some(sniffed) => sniffed,
4913 None => (Dialect::excel(fallback_delimiter?), true),
4914 };
4915 Some(render_csv(
4916 read_csv_rows(content, &dialect),
4917 header,
4918 title,
4919 caption,
4920 ))
4921}
4922
4923fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
4930 let name = resource.unwrap_or_default().to_lowercase();
4931 if name.ends_with(".csv") {
4932 Some(',')
4933 } else if name.ends_with(".tsv") {
4934 Some('\t')
4935 } else {
4936 None
4937 }
4938}
4939
4940fn render_csv(
4945 rows: Vec<Vec<String>>,
4946 has_header: bool,
4947 title: Option<&str>,
4948 caption: Option<&str>,
4949) -> Table {
4950 let mut table = Table::new()
4951 .border_style(Style::parse("blue").expect("valid style"))
4952 .show_header(has_header)
4953 .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
4954 if let Some(title) = title {
4955 table = table.title(title);
4956 }
4957 if let Some(caption) = caption {
4958 table = table.caption(caption);
4959 }
4960
4961 let empty: Vec<String> = Vec::new();
4962 let (header, body) = if has_header {
4963 match rows.split_first() {
4964 Some((header, body)) => (header, body),
4965 None => return table,
4966 }
4967 } else {
4968 (&empty, rows.as_slice())
4969 };
4970 let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
4972
4973 let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
4978 let columns = header.len().max(widest);
4979
4980 for index in 0..columns {
4981 let numeric = data.iter().all(|row| match row.get(index) {
4986 Some(value) => value.is_empty() || is_number(value),
4987 None => false,
4988 });
4989 let name = header.get(index).map(String::as_str).unwrap_or("");
4990 if numeric {
4991 table.add_column_justify(name, Justify::Right);
4992 table.column_style(Style::parse("bold green").expect("valid style"));
4993 table.column_header_fill(Style::parse("bold green").expect("valid style"));
4994 } else {
4995 table.add_column(name);
4996 }
4997 }
4998 drop(data);
5001 table.extend_owned_rows(
5002 rows.into_iter()
5003 .skip(usize::from(has_header))
5004 .filter(|row| !row.is_empty())
5005 .collect(),
5006 );
5007 table
5008}
5009
5010fn join_source(value: &serde_json::Value) -> String {
5013 match value {
5014 serde_json::Value::String(s) => s.clone(),
5015 serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5016 _ => String::new(),
5017 }
5018}
5019
5020fn join_traceback(value: &serde_json::Value) -> String {
5024 match value {
5025 serde_json::Value::String(s) => s.clone(),
5026 serde_json::Value::Array(a) => a
5027 .iter()
5028 .filter_map(|v| v.as_str())
5029 .collect::<Vec<_>>()
5030 .join(
5031 "
5032",
5033 ),
5034 _ => String::new(),
5035 }
5036}
5037
5038fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5042 let base_style = Style::parse(base).ok();
5043 let number_style = Style::parse(number).ok();
5044 let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5045 let mut text = Text::new("");
5046 text.append(&format!("{word}["), base_style.clone().map(Into::into));
5047 text.append(&n, number_style.map(Into::into));
5048 text.append("]:", base_style.map(Into::into));
5049 text
5050}
5051
5052struct Notebook {
5055 items: Vec<Box<dyn Renderable>>,
5056 justify: Option<Justify>,
5059}
5060
5061impl Renderable for Notebook {
5062 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5063 let mut options = options.clone();
5064 if let Some(justify) = self.justify {
5065 options.justify = justify;
5066 }
5067 let mut segments = Vec::new();
5068 for item in &self.items {
5069 let rendered = item.rich_render(console, &options);
5070 if !rendered.is_empty() {
5071 segments.extend(rendered);
5072 segments.push(Segment::line());
5073 }
5074 }
5075 if !segments.is_empty() {
5077 segments.pop();
5078 }
5079 segments
5080 }
5081
5082 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5083 self.items
5084 .iter()
5085 .map(|item| item.measure(console, options))
5086 .fold(Measurement::new(0, 0), |width, item| {
5087 Measurement::new(
5088 width.minimum.max(item.minimum),
5089 width.maximum.max(item.maximum),
5090 )
5091 })
5092 }
5093}
5094
5095fn notebook_ansi(source: &str) -> Text {
5097 let mut result = Text::new("");
5098 for (index, line) in AnsiDecoder::new().decode(source).iter().enumerate() {
5099 if index > 0 {
5100 result.append("\n", None);
5101 }
5102 result = result.append_text(line);
5103 }
5104 result
5105}
5106
5107fn build_ipynb(
5110 notebook: &serde_json::Value,
5111 hyperlinks: bool,
5112 justify: Option<Justify>,
5113) -> Notebook {
5114 let language = notebook["metadata"]["kernelspec"]["language"]
5115 .as_str()
5116 .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5117 .unwrap_or("python");
5118 let empty = Vec::new();
5119 let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5120 let mut new_line = true;
5121 for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5122 if new_line {
5123 items.push(text(""));
5124 }
5125 if cell.get("execution_count").is_some() {
5126 items.push(Box::new(io_label(
5127 "In ",
5128 cell["execution_count"].as_i64().filter(|n| *n != 0),
5129 "green",
5130 "#66ff00",
5131 )));
5132 }
5133 let source = join_source(&cell["source"]);
5134 items.push(match cell["cell_type"].as_str().unwrap_or("") {
5135 "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5136 "code" => Box::new(
5137 Panel::new(Box::new(Syntax::new(&source, language)))
5138 .border_style(Style::parse("dim").expect("valid style")),
5139 ),
5140 _ => text(&source),
5141 });
5142 new_line = true;
5143 for output in cell["outputs"].as_array().unwrap_or(&empty) {
5144 let rendered = match output["output_type"].as_str().unwrap_or("") {
5145 "stream" => {
5146 new_line = false;
5147 notebook_ansi(&join_source(&output["text"]))
5148 }
5149 "error" => {
5150 new_line = true;
5151 notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5152 }
5153 "execute_result" => {
5154 new_line = true;
5155 let mut label = io_label(
5156 "Out",
5157 output["execution_count"].as_i64().filter(|n| *n != 0),
5158 "red",
5159 "#ee4b2b",
5160 );
5161 label.append("\n", None);
5162 label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
5163 }
5164 _ => continue,
5165 };
5166 items.push(Box::new(rendered));
5167 }
5168 }
5169 Notebook { items, justify }
5170}
5171
5172fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5173 let displayed = |value: f32| {
5176 format!("{value:.1}")
5177 .parse::<f32>()
5178 .expect("formatted percentage is a number")
5179 };
5180 displayed(changed) > displayed(limit)
5181}
5182
5183fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5186 let mut contents = Vec::with_capacity(cli.resources.len());
5187 for resource in &cli.resources {
5188 let content = if is_url(resource) {
5189 fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
5190 } else {
5191 read_resource(Some(resource), cli.extensions.encoding)
5192 .map_err(|err| format!("cannot read {resource}: {err}"))
5193 };
5194 match content {
5195 Ok(content) => contents.push(content),
5196 Err(err) => return fail(cli, ExitClass::Input, err),
5197 }
5198 }
5199 let names: Vec<String> = cli
5200 .resources
5201 .iter()
5202 .map(|r| {
5203 if r == "-" {
5204 "<stdin>".to_string()
5205 } else {
5206 r.clone()
5207 }
5208 })
5209 .collect();
5210 let outcome = match tools::text_diff(
5211 &cli.tools,
5212 &names,
5213 &contents,
5214 cli.diff_threshold,
5215 cli.sanitize,
5216 ) {
5217 Ok(outcome) => outcome,
5218 Err(err) => return fail(cli, ExitClass::Data, err),
5219 };
5220 let fit = outcome
5221 .renderable
5222 .measure(console, &console.options())
5223 .maximum;
5224 let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
5225 if code != ExitCode::SUCCESS || !outcome.failed {
5226 return code;
5227 }
5228 fail(cli, ExitClass::Gate, "diff threshold exceeded")
5229}
5230
5231#[cfg(feature = "art")]
5247fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
5248 if fs_path(path).is_dir() {
5249 return Err(format!("cannot read {path}: is a directory, not a file"));
5250 }
5251 match rich_art::image::open(fs_path(path)) {
5252 Ok(image) => Ok(image),
5253 Err(err) => {
5254 use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
5255 let hint = match &err {
5256 rich_art::image::ImageError::Unsupported(error) => match error.kind() {
5257 UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
5258 Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
5259 _ => None,
5260 },
5261 _ => None,
5262 };
5263 Err(format!(
5264 "cannot read {path}: {}",
5265 hint.unwrap_or_else(|| err.to_string())
5266 ))
5267 }
5268 }
5269}
5270
5271#[cfg(feature = "art")]
5277fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
5278 match mode {
5279 ImageMode::Auto => rich_art::ImageMode::Auto,
5280 ImageMode::Sixel => rich_art::ImageMode::Sixel,
5281 ImageMode::Blocks => rich_art::ImageMode::Blocks,
5282 ImageMode::Braille => rich_art::ImageMode::Braille,
5283 ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
5284 ImageMode::Ascii => rich_art::ImageMode::Ascii,
5285 ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
5286 }
5287}
5288
5289#[cfg(feature = "art")]
5296fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5297 use rich_art::ImageArt;
5298
5299 let Some(resource) = cli.resource.as_deref() else {
5300 return fail(
5301 cli,
5302 ExitClass::Usage,
5303 "--image requires an image file: rich --image photo.png (or `-` for stdin)",
5304 );
5305 };
5306
5307 let image = if resource == "-" {
5308 let mut buffer = Vec::new();
5309 if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
5310 return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
5311 }
5312 match rich_art::image::load_from_memory(&buffer) {
5313 Ok(image) => image,
5314 Err(err) => {
5315 return fail(
5316 cli,
5317 ExitClass::Input,
5318 format!("cannot decode image from stdin: {err}"),
5319 )
5320 }
5321 }
5322 } else {
5323 match open_image_path(resource) {
5324 Ok(image) => image,
5325 Err(message) => return fail(cli, ExitClass::Input, message),
5326 }
5327 };
5328
5329 let width = cli.width.unwrap_or_else(|| console.width());
5330 let mut art = ImageArt::new(image)
5331 .transforms(rich_art::ImageTransforms {
5332 rotation: match cli.image_rotate {
5333 Some(90) => rich_art::Rotation::Clockwise90,
5334 Some(180) => rich_art::Rotation::Clockwise180,
5335 Some(270) => rich_art::Rotation::Clockwise270,
5336 _ => rich_art::Rotation::None,
5337 },
5338 flip_horizontal: cli.image_flip_horizontal,
5339 flip_vertical: cli.image_flip_vertical,
5340 grayscale: cli.image_grayscale,
5341 brightness: cli.image_brightness.unwrap_or(1.0),
5342 contrast: cli.image_contrast.unwrap_or(1.0),
5343 gamma: cli.image_gamma.unwrap_or(1.0),
5344 })
5345 .mode(to_art_image_mode(cli.image_mode))
5346 .width(width)
5347 .color(!cli.no_color);
5348 if let Some(height) = cli.height {
5349 art = art.height(height);
5350 }
5351 if let Some(columns) = cli.image_max_width {
5352 art = art.max_width(columns);
5353 }
5354 if let Some(rows) = cli.image_max_height {
5355 art = art.max_height(rows);
5356 }
5357
5358 if let Some(fit) = cli.image_fit.as_deref() {
5359 art = art.fit(match fit {
5360 "cover" => rich_art::ImageFit::Cover,
5361 "stretch" => rich_art::ImageFit::Stretch,
5362 "native" => rich_art::ImageFit::Native,
5363 _ => rich_art::ImageFit::Contain,
5364 });
5365 }
5366 if let Some(anchor) = cli.image_anchor.as_deref() {
5367 use rich_art::ImageAnchor;
5368 art = art.anchor(match anchor {
5369 "top" => ImageAnchor::Top,
5370 "bottom" => ImageAnchor::Bottom,
5371 "left" => ImageAnchor::Left,
5372 "right" => ImageAnchor::Right,
5373 "top-left" => ImageAnchor::TopLeft,
5374 "top-right" => ImageAnchor::TopRight,
5375 "bottom-left" => ImageAnchor::BottomLeft,
5376 "bottom-right" => ImageAnchor::BottomRight,
5377 _ => ImageAnchor::Center,
5378 });
5379 }
5380 match cli.image_background {
5381 Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
5382 Some(ImageBackdrop::Terminal) => {
5383 art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
5384 }
5385 Some(ImageBackdrop::Checkerboard) => {
5386 art = art.background_mode(rich_art::ImageBackground::Checkerboard)
5387 }
5388 None => {}
5389 }
5390 let (color_mode, dither, distance) = image_color_processing(cli);
5391 art = art
5392 .color_mode(color_mode)
5393 .dither(dither)
5394 .color_distance(distance);
5395
5396 let options = console.options();
5401 if let Err(err) = art.render(console, &options) {
5402 let message = match err {
5406 rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
5407 "this terminal (TERM={}) is not known to support Sixel graphics; set \
5408 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
5409 --image-mode blocks, braille or ascii",
5410 std::env::var("TERM").unwrap_or_default()
5411 ),
5412 err => err.to_string(),
5413 };
5414 return fail(cli, ExitClass::Input, message);
5415 }
5416
5417 let terminal_only = Export {
5418 html_path: None,
5419 svg_path: None,
5420 ..*export
5421 };
5422 if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
5423 return fail(cli, ExitClass::Input, message);
5424 }
5425 if export.html_path.is_some() || export.svg_path.is_some() {
5426 use rich::protocol::RenderEnvironment;
5427 use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
5428 let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
5429 caps.color_system = if cli.no_color {
5430 None
5431 } else {
5432 Some(ColorSystem::Truecolor)
5433 };
5434 let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
5435 let export_console = target.console();
5436 if let Err(err) =
5437 art.render_with_environment(&export_console, &export_console.options(), &target)
5438 {
5439 return fail(cli, ExitClass::Input, err.to_string());
5440 }
5441 let segments = target.segments(&art);
5442 debug_assert!(!target.capabilities().interactive);
5443 if let Err(message) = save_exports(&export_console, export, &segments) {
5444 return fail(cli, ExitClass::Input, message);
5445 }
5446 }
5447 success(cli)
5448}
5449
5450#[cfg(feature = "art")]
5451fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5452 use rich::Table;
5453 use rich_art::imagediff::{diff, DiffSettings};
5454 use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
5455
5456 let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
5457 let before = match open_image_path(before_path) {
5458 Ok(image) => image,
5459 Err(message) => return fail(cli, ExitClass::Input, message),
5460 };
5461 let after = match open_image_path(after_path) {
5462 Ok(image) => image,
5463 Err(message) => return fail(cli, ExitClass::Input, message),
5464 };
5465
5466 let report = match diff(&before, &after, &DiffSettings::default()) {
5467 Ok(report) => report,
5468 Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
5469 };
5470
5471 let width = cli.width.unwrap_or_else(|| console.width());
5472 let changed = report.changed_fraction * 100.0;
5473 let naive = report.naive_changed_fraction * 100.0;
5474
5475 let failed = cli
5484 .diff_threshold
5485 .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
5486
5487 let render_report = |c: &Console, for_export: bool| {
5488 let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
5491
5492 let has_color = c.color_system().is_some() && !cli.no_color;
5499 let is_terminal = c.is_terminal();
5500 let mut mode = cli.image_mode;
5501 if mode == ImageMode::Auto {
5502 if !has_color && !for_export && cli.report_format == ReportFormat::Human {
5503 eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
5504 }
5505 mode = if !has_color {
5506 ImageMode::Ascii
5507 } else if is_terminal && rich_art::sixel::is_probably_supported() {
5508 ImageMode::Sixel
5509 } else {
5510 ImageMode::Blocks
5511 };
5512 }
5513
5514 if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
5518 if !for_export && cli.report_format == ReportFormat::Human {
5519 eprintln!("rich: no colour available, drawing the diff as ASCII art");
5520 }
5521 mode = ImageMode::Ascii;
5522 }
5523 if mode == ImageMode::Sixel && !is_terminal {
5524 if cli.image_mode != ImageMode::Sixel
5525 && !for_export
5526 && cli.report_format == ReportFormat::Human
5527 {
5528 eprintln!(
5529 "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
5530 if has_color { "blocks" } else { "ASCII art" }
5531 );
5532 }
5533 mode = if has_color {
5534 ImageMode::Blocks
5535 } else {
5536 ImageMode::Ascii
5537 };
5538 }
5539
5540 match mode {
5541 ImageMode::None => {}
5542 ImageMode::Ascii => c.print(
5543 &AsciiArt::new(report.heatmap())
5544 .width(width)
5545 .color(has_color),
5546 ),
5547 ImageMode::Blocks => c.print(
5548 &BlockArt::new(report.heatmap())
5549 .width(width)
5550 .height(rows_cap),
5551 ),
5552 ImageMode::Quadrants => c.print(
5553 &rich_art::QuadrantArt::new(report.heatmap())
5554 .width(width)
5555 .height(rows_cap),
5556 ),
5557 ImageMode::Braille => c.print(
5558 &BrailleArt::new(report.heatmap())
5559 .width(width)
5560 .height(rows_cap),
5561 ),
5562 ImageMode::Sixel => {
5563 let art = SixelArt::new(report.heatmap())
5564 .width(width)
5565 .height(rows_cap);
5566 if art.encode(width).is_some() {
5569 c.print(&art);
5570 } else {
5571 if cli.report_format == ReportFormat::Human {
5572 eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
5573 }
5574 c.print(
5575 &BlockArt::new(report.heatmap())
5576 .width(width)
5577 .height(rows_cap),
5578 );
5579 }
5580 }
5581 ImageMode::Auto => unreachable!("resolved above"),
5582 }
5583 if mode != ImageMode::None {
5587 c.print_str("");
5588 }
5589 c.print_str(&format!(
5590 "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
5591 [dim](a plain pixel diff would say {naive:.1}%)[/]"
5592 ));
5593
5594 if report.regions.is_empty() {
5595 c.print_str("[dim]No region large enough to report.[/]");
5596 } else {
5597 let mut table = Table::new().title("Where it changed");
5598 table.add_column("#");
5599 table.add_column_justify("Share", rich::Justify::Right);
5600 table.add_column_justify("Mean ΔE", rich::Justify::Right);
5601 table.add_column_justify("Area px", rich::Justify::Right);
5602 table.add_column("Box (x,y w×h)");
5603 for (rank, r) in report.regions.iter().enumerate() {
5604 table.add_row(&[
5605 &format!("{}", rank + 1),
5606 &format!("{:.0}%", r.share_of_change * 100.0),
5607 &format!("{:.1}", r.mean_delta_e),
5608 &format!("{}", r.area_px),
5609 &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
5610 ]);
5611 }
5612 c.print(&table);
5613 }
5614
5615 if let Some(limit) = cli.diff_threshold {
5619 if failed {
5620 c.print_str(&format!(
5621 "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
5622 ));
5623 } else {
5624 c.print_str(&format!(
5625 "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
5626 ));
5627 }
5628 }
5629 };
5630
5631 let terminal_only = Export {
5632 html_path: None,
5633 svg_path: None,
5634 ..*export
5635 };
5636 let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
5637 if export.html_path.is_some() || export.svg_path.is_some() {
5638 let mut export_console = Console::builder()
5642 .force_terminal(false)
5643 .color_system(Some(ColorSystem::Truecolor))
5644 .no_color(cli.no_color)
5645 .width(console.width())
5646 .height(console.height())
5647 .theme(console.theme().clone())
5648 .build();
5649 export_console.install_extensions();
5650 let segments = export_console.record_output(|c| render_report(c, true));
5651 wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
5652 }
5653
5654 if let Err(message) = wrote {
5655 return fail(cli, ExitClass::Input, message);
5656 }
5657 if failed {
5658 return fail(cli, ExitClass::Gate, "diff threshold exceeded");
5659 }
5660 success(cli)
5661}
5662
5663#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5666#[cfg_attr(not(feature = "art"), allow(dead_code))]
5667enum ImageBackdrop {
5668 Color([u8; 3]),
5670 Terminal,
5672 Checkerboard,
5674}
5675
5676impl ImageBackdrop {
5677 fn parse(value: &str) -> Option<Self> {
5678 match value {
5679 "default" => return Some(ImageBackdrop::Terminal),
5680 "checkerboard" => return Some(ImageBackdrop::Checkerboard),
5681 _ => {}
5682 }
5683 let hex = value.strip_prefix('#')?;
5684 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
5685 return None;
5686 }
5687 let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
5688 Some(ImageBackdrop::Color([
5689 channel(0)?,
5690 channel(1)?,
5691 channel(2)?,
5692 ]))
5693 }
5694}
5695
5696#[cfg(feature = "art")]
5699fn image_color_processing(
5700 cli: &Cli,
5701) -> (
5702 rich_art::ImageColorMode,
5703 rich_art::Dither,
5704 rich_art::ColorDistance,
5705) {
5706 use rich_art::{ColorDistance, Dither, ImageColorMode};
5707 let mode = match cli.image_color.as_deref() {
5708 Some("ansi256") => ImageColorMode::Ansi256,
5709 Some("ansi16") => ImageColorMode::Ansi16,
5710 Some("grayscale") => ImageColorMode::Grayscale,
5711 _ => ImageColorMode::TrueColor,
5712 };
5713 let dither = match cli.image_dither.as_deref() {
5714 Some("floyd-steinberg") => Dither::FloydSteinberg,
5715 Some("bayer4x4") => Dither::Bayer4x4,
5716 Some("atkinson") => Dither::Atkinson,
5717 _ => Dither::None,
5718 };
5719 let distance = match cli.image_color_distance.as_deref() {
5720 Some("oklab") => ColorDistance::Oklab,
5721 _ => ColorDistance::Rgb,
5722 };
5723 (mode, dither, distance)
5724}
5725
5726#[cfg(feature = "art")]
5727fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
5728 use rich_art::{AnimatedArt, Repeat, Stage};
5729
5730 if cli.resources.is_empty() {
5731 return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
5732 }
5733 let count = cli.resources.len();
5734 const GAP: usize = 2;
5735
5736 let total = cli.width.unwrap_or_else(|| console.width());
5738 let per_gif = total
5739 .saturating_sub(GAP * count.saturating_sub(1))
5740 .checked_div(count)
5741 .unwrap_or(total)
5742 .max(8);
5743
5744 let repeat = match cli.loops {
5745 Some(0) => Repeat::Forever,
5746 Some(n) => Repeat::Times(n),
5747 None => Repeat::Times(1),
5748 };
5749
5750 let mut stage = Stage::new().gap(GAP);
5751 for path in &cli.resources {
5752 match AnimatedArt::from_path(path) {
5753 Ok(art) => {
5754 let (color_mode, dither, distance) = image_color_processing(cli);
5755 stage = stage.with(
5756 art.width(per_gif)
5757 .blocks(cli.extensions.gif_blocks())
5758 .color(!cli.no_color)
5759 .color_mode(color_mode)
5760 .dither(dither)
5761 .color_distance(distance)
5762 .repeat(repeat)
5763 .max_fps(30.0),
5765 );
5766 }
5767 Err(err) => {
5768 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
5769 }
5770 }
5771 }
5772
5773 if !console.is_terminal() && cli.report_format == ReportFormat::Human {
5774 eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
5775 }
5776
5777 let mut builder = Console::builder().no_color(cli.no_color);
5779 if cli.themed() {
5780 builder = builder.theme(cli_theme(cli));
5781 }
5782 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
5785 builder = builder.force_terminal(terminal == "1");
5786 }
5787 if let Some(width) = cli.width {
5788 builder = builder.width(width);
5789 }
5790 match stage.play_stdout(builder.build()) {
5791 Ok(()) => success(cli),
5792 Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
5793 }
5794}
5795
5796#[cfg(not(feature = "art"))]
5798fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
5799 fail(
5800 _cli,
5801 ExitClass::Usage,
5802 "this build has no GIF support (rebuild with the `art` feature)",
5803 )
5804}
5805
5806struct Export<'a> {
5809 html_path: Option<&'a str>,
5811 svg_path: Option<&'a str>,
5813 svg_title: &'a str,
5815 pager: bool,
5817 auto_pager: bool,
5818}
5819
5820fn should_page(
5831 explicit: bool,
5832 automatic: bool,
5833 terminal: bool,
5834 lines: usize,
5835 height: usize,
5836) -> bool {
5837 terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
5838}
5839
5840fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
5841 if watch::capturing() {
5842 watch::capture_segments(console.record_output(render));
5844 return Ok(());
5845 }
5846 if export.html_path.is_none()
5847 && export.svg_path.is_none()
5848 && !export.pager
5849 && !export.auto_pager
5850 {
5851 render(console);
5852 return Ok(());
5853 }
5854
5855 let segments = console.record_output(render);
5856 let mut first_error = None;
5857
5858 let terminal = console.segments_to_string(&segments);
5860 if should_page(
5861 export.pager,
5862 export.auto_pager,
5863 std::io::stdout().is_terminal(),
5864 terminal.lines().count(),
5865 console.height(),
5866 ) {
5867 if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
5870 first_error = Some(format!("cannot page output: {err}"));
5871 }
5872 } else {
5873 let stdout = std::io::stdout();
5874 let mut lock = stdout.lock();
5875 if let Err(err) = lock.write_all(terminal.as_bytes()) {
5876 first_error = Some(format!("could not write output: {err}"));
5877 }
5878 }
5879
5880 save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
5881}
5882
5883fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
5884 let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
5885 let segments = filtered.as_slice();
5886 let mut first_error = None;
5887 if let Some(path) = export.html_path {
5888 let html = rich::export::export_html_classes(segments, &DEFAULT_TERMINAL_THEME);
5890 if let Err(err) = std::fs::write(path, html) {
5891 first_error = Some(format!("failed to save HTML: {err}"));
5892 }
5893 }
5894 if let Some(path) = export.svg_path {
5895 let svg = rich::svg::export_svg(
5896 segments,
5897 &rich::SVG_EXPORT_THEME,
5898 export.svg_title,
5899 "rich-cli",
5902 console.width(),
5903 );
5904 if let Err(err) = std::fs::write(path, svg) {
5905 first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
5906 }
5907 }
5908 first_error.map_or(Ok(()), Err)
5909}
5910
5911fn print_help(no_color: bool) {
5913 cli_spec::print_help(no_color);
5914}
5915
5916fn build_demo_console(no_color: bool) -> Console {
5917 let to_terminal = std::io::stdout().is_terminal() && !no_color;
5922 let mut console = Console::builder()
5923 .force_terminal(to_terminal)
5924 .no_color(no_color)
5925 .color_system(if to_terminal {
5926 Some(ColorSystem::Truecolor)
5927 } else {
5928 None
5929 })
5930 .theme(rich_ext::extended_theme())
5933 .build();
5934 console.install_extensions();
5935 console
5936}
5937
5938fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
5939 let to_terminal = std::io::stdout().is_terminal() && !no_color;
5940 let console = build_demo_console(no_color);
5941
5942 demo::section(&console, delay, "rs-rich-cli");
5943 console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
5944 console.print_str(
5945 "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
5946 );
5947
5948 demo::section(&console, delay, "markup · color · theme · extension");
5950 console.print_str(
5951 "Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
5952 );
5953 console.print_str(
5954 "Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
5955 );
5956 console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
5957 let mut themed = build_demo_console(no_color);
5959 let release =
5960 rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
5961 themed.use_theme(release).print_str(
5962 "Stack: [release]release[/] from a pushed theme, popped when its guard drops",
5963 );
5964 console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
5965 let hl = Console::builder()
5971 .force_terminal(to_terminal)
5972 .no_color(no_color)
5973 .color_system(if to_terminal {
5974 Some(ColorSystem::Truecolor)
5975 } else {
5976 None
5977 })
5978 .highlight(true)
5979 .build();
5980 hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
5981 let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
5983 ISO8601Highlighter::new().highlight(&mut stamp);
5984 console.print(&Text::new("ISO 8601: ").append_text(&stamp));
5985 console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
5986
5987 demo::section(&console, delay, "rule");
5989 console.print(&Rule::line());
5990 demo::section(&console, delay, "centered");
5991 console.print(&Rule::new("left").align(HorizontalAlign::Left));
5992 console.print(&Rule::new("right").align(HorizontalAlign::Right));
5993
5994 demo::section(&console, delay, "panel");
5996 console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
5997 console.print(
5998 &Panel::new(text("square box"))
5999 .box_set(SQUARE)
6000 .title("square"),
6001 );
6002 console.print(
6003 &Panel::new(text("double box"))
6004 .box_set(DOUBLE)
6005 .title("double"),
6006 );
6007 console.print(
6008 &Panel::new(text("title + subtitle"))
6009 .title("top")
6010 .subtitle("bottom"),
6011 );
6012 let legacy = Console::builder()
6014 .force_terminal(to_terminal)
6015 .no_color(no_color)
6016 .color_system(if to_terminal {
6017 Some(ColorSystem::Truecolor)
6018 } else {
6019 None
6020 })
6021 .width(console.width())
6022 .legacy_windows(true)
6023 .build();
6024 legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6025
6026 demo::section(&console, delay, "padding");
6028 console.print(
6029 &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6030 );
6031
6032 demo::section(&console, delay, "styled");
6034 console.print(&Styled::new(
6035 Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6036 Style::parse("green").unwrap(),
6037 ));
6038
6039 demo::section(&console, delay, "align");
6041 console.print(&Align::left(text("← left")));
6042 console.print(&Align::center(text("center")));
6043 console.print(&Align::right(text("right →")));
6044
6045 demo::section(&console, delay, "justify");
6047 console.print_justified("left justified", Justify::Left);
6048 console.print_justified("centered", Justify::Center);
6049 console.print_justified("right justified", Justify::Right);
6050
6051 demo::section(&console, delay, "constrain (width 24)");
6053 console.print(&Constrain::new(
6054 Box::new(Panel::new(text("constrained")).title("≤24")),
6055 Some(24),
6056 ));
6057
6058 demo::section(&console, delay, "table");
6060 let mut table = Table::new().title("ported renderables");
6061 table
6063 .add_column("Renderable")
6064 .column_style(Style::parse("cyan").unwrap());
6065 table.add_column("Module").column_width(12);
6066 table.add_column_justify("Parity", Justify::Center);
6067 for (renderable, module, parity) in [
6068 ("Text / markup", "text, markup", "✓"),
6069 ("Rule", "rule", "✓"),
6070 ("Panel", "panel", "✓"),
6071 ("Padding", "padding", "✓"),
6072 ("Align", "align", "✓"),
6073 ("Constrain", "constrain", "✓"),
6074 ("Columns", "columns", "✓"),
6075 ("Table", "table", "✓"),
6076 ("Tree", "tree", "✓"),
6077 ("ProgressBar", "progress_bar", "✓"),
6078 ] {
6079 table.add_row(&[renderable, module, parity]);
6080 }
6081 console.print(&table);
6082
6083 demo::section(&console, delay, "columns");
6085 let months = [
6086 "January",
6087 "February",
6088 "March",
6089 "April",
6090 "May",
6091 "June",
6092 "July",
6093 "August",
6094 "September",
6095 "October",
6096 "November",
6097 "December",
6098 ];
6099 console.print(&Columns::new(
6100 months.iter().map(|m| m.to_string()).collect(),
6101 ));
6102
6103 demo::section(&console, delay, "tree");
6105 let mut tree = Tree::new("rs-rich-cli");
6106 let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
6107 core.add("color · style · text · console");
6108 core.add("box · rule · panel · padding · align");
6109 core.add("table · tree · wrap · filesize");
6110 tree.add("crates/rich-ext (our extensions)");
6111 tree.add("crates/rich-cli (this binary)");
6112 console.print(&tree);
6113
6114 demo::section(&console, delay, "progress bar");
6116 for pct in [0.0, 33.0, 66.0, 100.0] {
6117 console.print(&ProgressBar::new(100.0, pct).width(48));
6118 }
6119
6120 demo::section(&console, delay, "progress");
6124 let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
6125 let set_time =
6126 |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
6127 let reader = clock.clone();
6128 let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
6129 let mut progress = Progress::new().clock(now.clone());
6130 let downloading = progress.add_task("Downloading", 100.0, 0.0);
6131 let processing = progress.add_task("Processing", 100.0, 0.0);
6132 progress.add_task("Waiting", 100.0, 0.0);
6133 set_time(4.0);
6134 progress.advance(downloading, 20.0);
6135 progress.advance(processing, 60.0);
6136 set_time(8.0);
6137 progress.advance(downloading, 30.0);
6138 progress.advance(processing, 40.0);
6139 console.print(&progress);
6140
6141 let mut transfer = Progress::new().clock(now).columns(vec![
6143 ProgressColumn::spinner(),
6144 ProgressColumn::Description,
6145 ProgressColumn::Bar,
6146 ProgressColumn::Download,
6147 ProgressColumn::TransferSpeed,
6148 ProgressColumn::TimeElapsed,
6149 ProgressColumn::time_remaining(),
6150 ]);
6151 let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
6152 let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
6153 set_time(12.0);
6154 transfer.advance(iso, 1_200_000_000.0);
6155 transfer.advance(notes, 20_000.0);
6156 set_time(20.0);
6157 transfer.advance(iso, 900_000_000.0);
6158 transfer.advance(notes, 22_000.0);
6159 console.print(&transfer);
6160
6161 let mut mofn = Progress::new().columns(vec![
6163 ProgressColumn::Description,
6164 ProgressColumn::Bar,
6165 ProgressColumn::MofN,
6166 ]);
6167 mofn.add_task("Files", 8.0, 3.0);
6168 mofn.add_task("Chunks", 128.0, 128.0);
6169 console.print(&mofn);
6170
6171 let mut download = Progress::new().columns(vec![
6173 ProgressColumn::Description,
6174 ProgressColumn::Bar,
6175 ProgressColumn::Download,
6176 ]);
6177 download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
6178 console.print(&download);
6179
6180 demo::section(&console, delay, "json");
6182 if let Ok(json) = Json::new(
6183 r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
6184 ) {
6185 console.print(&json);
6186 }
6187
6188 demo::section(&console, delay, "pretty");
6190 let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
6191 console.print(&Pretty::new(&value));
6192
6193 demo::section(&console, delay, "log");
6195 let log = LogRender::new().show_level(true);
6196 for (level, message) in [
6197 (LogLevel::Info, "server started on port 8080"),
6198 (LogLevel::Debug, "cache warm: 128 entries"),
6199 (LogLevel::Warn, "disk usage at 85%"),
6200 (LogLevel::Error, "connection reset by peer"),
6201 ] {
6202 console.print(&log.render(
6203 &console,
6204 Text::new(message),
6205 Some(Text::new("[12:00:00]")),
6206 level.text(),
6207 Some("main.rs"),
6208 Some(42),
6209 None,
6210 ));
6211 }
6212
6213 demo::section(&console, delay, "traceback");
6215 let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
6216 let err = std::io::Error::other(cause);
6217 console.print(&Traceback::new(&err));
6218
6219 demo::section(&console, delay, "bar (range)");
6221 for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
6222 console.print(&Bar::new(100.0, begin, end).width(48));
6223 }
6224
6225 demo::section(&console, delay, "markdown");
6227 console.print(&Markdown::new(
6228 "# 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---",
6229 ));
6230
6231 demo::section(&console, delay, "syntax");
6233 console.print(&Syntax::new(
6234 "fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
6235 "rust",
6236 ));
6237
6238 demo::section(&console, delay, "control codes");
6240 let show_escape = |label: &str, control: &Control| {
6241 let escaped = control
6243 .as_str()
6244 .replace('\x1b', "\\x1b")
6245 .replace('\x07', "\\a")
6246 .replace('\r', "\\r");
6247 console.print(&Text::new(format!(" {label:>14}: {escaped}")));
6248 };
6249 show_escape("clear screen", &Control::clear());
6250 show_escape("home", &Control::home());
6251 show_escape("move (2,-1)", &Control::move_(2, -1));
6252 show_escape("move_to (3,4)", &Control::move_to(3, 4));
6253 show_escape("hide cursor", &Control::show_cursor(false));
6254 show_escape("bell", &Control::bell());
6255 let live_render = LiveRender::new(text("a\nb\nc"));
6257 let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
6259 let live_console = Console::builder()
6262 .force_terminal(true)
6263 .color_system(Some(ColorSystem::Truecolor))
6264 .width(20)
6265 .build();
6266 let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
6267 live.start();
6268 live.update(text("frame two"));
6269 live.stop();
6270 let stream = String::from_utf8_lossy(live.writer())
6271 .replace('\x1b', "\\x1b")
6272 .replace('\r', "\\r")
6273 .replace('\n', "\\n");
6274 console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
6275
6276 demo::section(&console, delay, "ansi decoder");
6278 let raw =
6279 "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
6280 console.print(&Text::new(format!(
6281 " input: {}",
6282 raw.replace('\x1b', "\\x1b")
6283 )));
6284 let mut decoder = rich::AnsiDecoder::new();
6285 for line in decoder.decode(raw) {
6286 console.print(&Text::new(" output: ").append_text(&line));
6287 }
6288
6289 demo::section(&console, delay, "capture · export_text");
6291 let plain = console.export_text(|c| {
6292 c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
6293 });
6294 console.print(&Text::new(" export_text (styles stripped):"));
6295 for line in plain.lines() {
6296 console.print(&Text::new(format!(" {line}")));
6297 }
6298 let html_rule = Style::parse("bold red")
6300 .unwrap()
6301 .get_html_style(&DEFAULT_TERMINAL_THEME);
6302 console.print(&Text::new(format!(
6303 " export_html: [bold red] → <span style=\"{html_rule}\">"
6304 )));
6305
6306 demo::section(&console, delay, "layout");
6309 let panel_leaf = |title: &str, body: &str| {
6310 Layout::with_renderable(Box::new(
6311 Panel::new(text(body))
6312 .box_set(SQUARE)
6313 .title(title.to_string()),
6314 ))
6315 };
6316 let mut header = Layout::new();
6317 header.split_row(vec![
6318 panel_leaf("left", "pane A"),
6319 panel_leaf("right", "pane B"),
6320 ]);
6321 let mut root = Layout::new();
6322 root.split_column(vec![
6323 header,
6324 Layout::with_renderable(text("footer (fixed size 3)")).size(3),
6325 ]);
6326 let grid = Console::builder()
6327 .force_terminal(true)
6328 .color_system(Some(ColorSystem::Truecolor))
6329 .width(48)
6330 .height(7)
6331 .build();
6332 for line in grid.export_text(|c| c.print(&root)).lines() {
6333 console.print(&Text::new(line));
6334 }
6335
6336 demo::section(&console, delay, "spinner (frames)");
6338 for name in ["dots", "line", "arrow", "simpleDots"] {
6339 let spinner = Spinner::new(name);
6340 let frames: String = (0..6)
6341 .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
6342 .collect::<Vec<_>>()
6343 .join(" ");
6344 console.print_str(&format!(" {name:>11}: {frames}"));
6345 }
6346 let status = Status::new("Loading…").renderable().render(0.0);
6348 console.print(&Text::new(" status: ").append_text(&status));
6349
6350 demo::section(&console, delay, "csv");
6352 let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
6353 if let Some(table) = build_csv_table(csv, Some(','), None, None) {
6354 console.print(&table);
6355 }
6356
6357 demo::section(&console, delay, "filesize");
6359 for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
6360 console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
6361 }
6362 console.print(&Rule::line());
6363 console
6364}
6365
6366#[cfg(test)]
6367mod tests {
6368 use super::*;
6369 use rich::ColorSystem;
6370
6371 #[cfg(feature = "art")]
6372 #[test]
6373 fn diff_threshold_uses_the_reports_tie_rounding() {
6374 assert!(!diff_threshold_exceeded(0.25, 0.24));
6376 assert!(diff_threshold_exceeded(0.75, 0.74));
6377 assert!(!diff_threshold_exceeded(0.75, 0.76));
6378 }
6379
6380 #[test]
6381 fn read_csv_rows_handles_quotes_and_delimiters() {
6382 let comma = Dialect::excel(',');
6383 let rows = read_csv_rows("a,\"b,c\",d\n\"he said \"\"hi\"\"\",2\n", &comma);
6385 assert_eq!(
6386 rows,
6387 vec![
6388 vec!["a".to_string(), "b,c".to_string(), "d".to_string()],
6389 vec!["he said \"hi\"".to_string(), "2".to_string()],
6390 ]
6391 );
6392 assert_eq!(read_csv_rows("x\ny\n", &comma), vec![vec!["x"], vec!["y"]]);
6395 assert_eq!(
6397 read_csv_rows("a\tb", &Dialect::excel('\t')),
6398 vec![vec!["a", "b"]]
6399 );
6400 assert_eq!(read_csv_rows("\u{feff}a,b", &comma), vec![vec!["a", "b"]]);
6402 assert_eq!(
6405 read_csv_rows("a,b\n\nc,d\n", &comma),
6406 vec![vec!["a", "b"], vec![], vec!["c", "d"]]
6407 );
6408 let padded = Dialect {
6410 skipinitialspace: true,
6411 ..Dialect::excel(',')
6412 };
6413 assert_eq!(
6414 read_csv_rows("a, b , c", &padded),
6415 vec![vec!["a", "b ", "c"]]
6416 );
6417 }
6418
6419 #[test]
6420 fn the_sniffer_finds_the_delimiter_and_the_header() {
6421 let candidates = [',', '\t', '|', ';'];
6423 let sniffed =
6424 |sample: &str| sniff(sample, Some(&candidates)).map(|dialect| dialect.delimiter);
6425 assert_eq!(
6426 sniffed("name;age;city\nAlice;30;Paris\nBob;25;Lyon\n"),
6427 Some(';')
6428 );
6429 assert_eq!(sniffed("a|b|c\n1|2|3\n4|5|6\n"), Some('|'));
6430 assert_eq!(sniffed("a\tb\tc\n1\t2\t3\n4\t5\t6\n"), Some('\t'));
6431 assert_eq!(sniffed("a, b, c\n1, 2, 3\n4, 5, 6\n"), Some(','));
6432 assert_eq!(
6435 sniffed("name,bio\nAlice,\"line one\nline two\"\nBob,short\n"),
6436 Some(',')
6437 );
6438 assert_eq!(sniffed("a,b,c\n1,2\n3,4,5,6\n"), None);
6441 assert_eq!(sniffed("alpha\nbeta\ngamma\n"), None);
6442
6443 assert_eq!(has_header("name,age\nAlice,30\nBob,25\n"), Some(true));
6444 assert_eq!(has_header("price,note\n10,aa\n9.5,bbb\n"), Some(true));
6448 assert_eq!(has_header("1,2,3\n4,5,6\n7,8,9\n"), Some(false));
6449 }
6450
6451 #[test]
6452 fn detects_urls() {
6453 assert!(is_url("http://example.com"));
6454 assert!(is_url("https://example.com/x"));
6455 assert!(is_url("HTTPS://example.com"));
6457 assert!(is_url("Http://example.com"));
6458 assert!(!is_url("example.com"));
6459 assert!(!is_url("./file.md"));
6460 assert!(!is_url("-"));
6461 assert!(!is_url("")); assert!(!is_url("ht"));
6463 assert!(!is_url("ftp://example.com"));
6464 }
6465
6466 #[test]
6467 fn uninformative_extensions_defer_to_content_type() {
6468 assert!(is_uninformative_ext("txt"));
6469 assert!(is_uninformative_ext("log"));
6470 assert!(!is_uninformative_ext("py"));
6471 assert!(!is_uninformative_ext("rs"));
6472 }
6473
6474 #[test]
6475 fn resource_ext_uses_basename_without_query() {
6476 assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
6477 assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
6478 assert_eq!(
6479 resource_ext("https://x.com/c.py?raw=1").as_deref(),
6480 Some("py")
6481 );
6482 assert_eq!(resource_ext("https://api.example.com/data"), None);
6484 assert_eq!(resource_ext("https://x.com/"), None);
6485 assert_eq!(resource_ext("noext"), None);
6486 }
6487
6488 #[test]
6489 fn content_type_maps_mode_and_lexer() {
6490 assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
6491 assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
6492 assert_eq!(
6493 content_type_mode("application/json; charset=utf-8"),
6494 Some(Mode::Json)
6495 );
6496 assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
6497 assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
6499 assert_eq!(content_type_lexer("text/x-python"), Some("python"));
6500 assert_eq!(content_type_lexer("text/plain"), None);
6501 }
6502
6503 #[test]
6504 fn join_source_handles_string_and_array() {
6505 use serde_json::json;
6506 assert_eq!(join_source(&json!("a\nb")), "a\nb");
6507 assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
6509 assert_eq!(join_source(&json!(null)), "");
6510 }
6511
6512 #[test]
6513 fn ipynb_extension_auto_detects() {
6514 assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
6515 }
6516
6517 #[test]
6518 fn io_label_builds_execution_count() {
6519 assert_eq!(
6521 io_label("In ", Some(1), "green", "#66ff00").plain(),
6522 "In [1]:"
6523 );
6524 assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
6525 }
6526
6527 #[test]
6528 fn parse_padding_unpacks_like_upstream() {
6529 assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
6530 assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
6531 assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
6532 assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
6533 assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
6535 }
6536
6537 #[test]
6538 fn parse_box_maps_names() {
6539 assert!(matches!(parse_box("rounded"), Ok(Some(_))));
6540 assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
6541 assert!(matches!(parse_box("none"), Ok(None)));
6543 assert!(parse_box("bogus").is_err());
6544 }
6545
6546 #[test]
6547 fn paging_policy_respects_viewport_and_redirection() {
6548 assert!(!should_page(true, true, false, 100, 24));
6549 assert!(!should_page(false, true, true, 23, 24));
6550 assert!(should_page(false, true, true, 24, 24));
6551 assert!(should_page(true, false, true, 1, 24));
6552 assert!(!should_page(false, false, true, 100, 24));
6553 }
6554
6555 #[test]
6556 fn worker_arguments_preserve_resources_named_like_commands() {
6557 for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
6558 let args: Vec<String> = source.into_iter().map(str::to_string).collect();
6559 assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
6560 }
6561 }
6562
6563 #[test]
6564 fn worker_arguments_keep_option_values_and_remove_batch_operands() {
6565 let args: Vec<String> = [
6566 "--batch",
6567 "--title",
6568 "--batch",
6569 "--jobs",
6570 "2",
6571 "json",
6572 "--export-html",
6573 "out.html",
6574 "--",
6575 "--pager",
6576 ]
6577 .into_iter()
6578 .map(str::to_string)
6579 .collect();
6580 assert_eq!(
6581 worker_args(&args),
6582 ["--no-config", "--title", "--batch", "--json"]
6583 );
6584 }
6585
6586 #[test]
6588 fn worker_arguments_keep_the_code_highlighter() {
6589 let args: Vec<String> = [
6590 "--batch",
6591 "--highlighter",
6592 "syntect",
6593 "--code-theme",
6594 "ansi_dark",
6595 "a.py",
6596 ]
6597 .into_iter()
6598 .map(str::to_string)
6599 .collect();
6600 assert_eq!(
6601 worker_args(&args),
6602 [
6603 "--no-config",
6604 "--highlighter",
6605 "syntect",
6606 "--code-theme",
6607 "ansi_dark"
6608 ]
6609 );
6610 }
6611
6612 #[test]
6613 fn parses_pager_flag() {
6614 let s = |v: &str| v.to_string();
6615 assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
6616 assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
6617 }
6618
6619 #[test]
6620 fn parses_watch_options_and_rejects_orphans() {
6621 let s = |v: &str| v.to_string();
6622 let cli = parse(&[
6623 s("--watch"),
6624 s("--watch-interval"),
6625 s("0.25"),
6626 s("--watch-cache"),
6627 s("file.md"),
6628 ])
6629 .unwrap()
6630 .unwrap();
6631 assert!(cli.watch);
6632 assert_eq!(cli.watch_interval, 0.25);
6633 assert!(cli.watch_cache);
6634 assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
6635 assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
6636 }
6637
6638 #[test]
6639 fn parses_multi_file_watch_options() {
6640 let s = |v: &str| v.to_string();
6641 let parse = parse_inner;
6642 let cli = parse(&[
6643 s("--watch"),
6644 s("--watch-debounce"),
6645 s("0.3"),
6646 s("--watch-poll"),
6647 s("--watch-exit-on-error"),
6648 s("a.md"),
6649 s("b.json"),
6650 ])
6651 .unwrap()
6652 .unwrap();
6653 assert_eq!(cli.resources, ["a.md", "b.json"]);
6654 assert_eq!(cli.watch_debounce, 0.3);
6655 assert!(cli.watch_poll && cli.watch_exit_on_error);
6656 let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
6657 assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
6658 assert!(!default.watch_poll && !default.watch_exit_on_error);
6659 for orphan in [
6660 vec![s("--watch-debounce"), s("0.2"), s("a.md")],
6661 vec![s("--watch-poll"), s("a.md")],
6662 vec![s("--watch-exit-on-error"), s("a.md")],
6663 ] {
6664 assert!(parse(&orphan)
6665 .err()
6666 .unwrap()
6667 .contains("only has an effect with --watch"));
6668 }
6669 for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
6670 assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
6671 }
6672 assert!(parse(&[s("a.md"), s("b.md")]).is_err());
6674 }
6675
6676 #[test]
6677 fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
6678 let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
6679 let _ = std::fs::remove_file(&path);
6680 let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
6681 std::fs::write(&path, "first").unwrap();
6682 let first = watch_fingerprint(path.to_str().unwrap(), false, None);
6683 std::fs::write(&path, "second content").unwrap();
6684 let second = watch_fingerprint(path.to_str().unwrap(), false, None);
6685 let _ = std::fs::remove_file(&path);
6686 assert_eq!(missing, "missing");
6687 assert_ne!(first, missing);
6688 assert_ne!(first, second);
6689 }
6690
6691 #[test]
6692 fn parses_batch_controls_and_cli_values_override_config_defaults() {
6693 let s = |v: &str| v.to_string();
6694 let cli = parse(&[
6695 s("--no-config"),
6696 s("--batch"),
6697 s("--jobs"),
6698 s("3"),
6699 s("-o"),
6700 s("out.html"),
6701 s("--continue-on-error"),
6702 s("--collision"),
6703 s("suffix"),
6704 s("input"),
6705 ])
6706 .unwrap()
6707 .unwrap();
6708 assert!(cli.batch);
6709 assert_eq!(cli.jobs, 3);
6710 assert!(cli.continue_on_error);
6711 assert_eq!(cli.collision, CollisionPolicy::Suffix);
6712 }
6713
6714 #[test]
6715 fn batch_expansion_is_recursive_sorted_and_deduplicated() {
6716 let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
6717 let _ = std::fs::remove_dir_all(&root);
6718 let nested = root.join("nested");
6719 std::fs::create_dir_all(&nested).unwrap();
6720 std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
6721 std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
6722
6723 let root_arg = root.to_string_lossy().into_owned();
6724 let glob_arg = root
6725 .join("*richbatchonly.md")
6726 .to_string_lossy()
6727 .into_owned();
6728 let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
6729 assert_eq!(inputs.len(), 2);
6730 assert!(inputs[0].ends_with("richbatchnested.md"));
6731 assert!(inputs[1].ends_with("richbatchonly.md"));
6732
6733 std::fs::remove_dir_all(root).unwrap();
6734 }
6735
6736 #[test]
6737 fn batch_output_suffixes_are_deterministic_for_collisions() {
6738 let inputs = ["one.md", "two.md"];
6739 let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
6740 let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
6741 assert_eq!(first, "one.html");
6742 assert_eq!(second, "two.html");
6743 assert_eq!(
6744 Path::new("one.html")
6745 .with_file_name("one-2.html")
6746 .to_string_lossy(),
6747 "one-2.html"
6748 );
6749 }
6750
6751 #[test]
6752 fn config_profiles_support_profiles_section_and_source_errors() {
6753 let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
6754 std::fs::create_dir_all(&root).unwrap();
6755 let config = root.join("config.toml");
6756 std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
6757 let args = vec![
6758 "--config".to_string(),
6759 config.to_string_lossy().into_owned(),
6760 "--profile".to_string(),
6761 "ci".to_string(),
6762 ];
6763 let merged = config_args(
6764 &args,
6765 &ConfigRoots {
6766 home: None,
6767 cwd: root.clone(),
6768 no_color_env: false,
6769 },
6770 )
6771 .unwrap();
6772 assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
6773 assert!(merged
6774 .windows(2)
6775 .any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
6776
6777 let bad = root.join("bad.toml");
6778 std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
6779 let error = config_args(
6780 &["--config".to_string(), bad.to_string_lossy().into_owned()],
6781 &ConfigRoots {
6782 home: None,
6783 cwd: root.clone(),
6784 no_color_env: false,
6785 },
6786 )
6787 .unwrap_err();
6788 assert!(error.contains("config "));
6789 assert!(error.contains("unknown key"));
6790 std::fs::remove_dir_all(root).unwrap();
6791 }
6792
6793 #[test]
6794 fn batch_rejects_modes_that_consume_their_whole_resource_list() {
6795 let s = |v: &str| v.to_string();
6796 let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
6797 .err()
6798 .unwrap();
6799 assert!(error.contains("--batch cannot be combined with --diff"));
6800 let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
6801 .err()
6802 .unwrap();
6803 assert!(error.contains("--batch cannot be combined with --gif"));
6804 let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
6806 .err()
6807 .unwrap();
6808 assert!(error.contains("--pager cannot be combined with --batch"));
6809 }
6810
6811 #[test]
6812 fn glob_matches_metacharacters_rather_than_substrings() {
6813 assert!(glob_match("*.md", "notes.md"));
6814 assert!(!glob_match("*.md", "notes.md.bak"));
6816 assert!(glob_match("a?c.md", "abc.md"));
6817 assert!(!glob_match("a?c.md", "ac.md"));
6818 assert!(glob_match("*", "anything"));
6819 assert!(glob_match("a*b*c", "axxbyyc"));
6820 assert!(!glob_match("a*b*c", "axxbyy"));
6821 }
6822
6823 #[test]
6824 fn batch_expansion_skips_symlinked_directories() {
6825 let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
6826 let _ = std::fs::remove_dir_all(&root);
6827 std::fs::create_dir_all(&root).unwrap();
6828 std::fs::write(root.join("a.md"), "# a").unwrap();
6829 #[cfg(unix)]
6831 let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
6832 #[cfg(windows)]
6833 let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
6834 let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
6835 if linked {
6836 assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
6837 }
6838 assert!(inputs[0].ends_with("a.md"));
6839 let _ = std::fs::remove_dir_all(&root);
6840 }
6841
6842 #[test]
6843 fn subcommand_mode_wins_over_config_mode_default() {
6844 let s = |v: &str| v.to_string();
6845 assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
6846 assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
6847 assert!(!selects_mode_explicitly(&[
6849 s("--title"),
6850 s("json"),
6851 s("file")
6852 ]));
6853 assert!(!selects_mode_explicitly(&[s("file.md")]));
6854
6855 let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
6856 let _ = std::fs::remove_dir_all(&root);
6857 std::fs::create_dir_all(&root).unwrap();
6858 let config = root.join("config.toml");
6859 std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
6860 let merged = config_args(
6861 &[
6862 "--config".to_string(),
6863 config.to_string_lossy().into_owned(),
6864 "json".to_string(),
6865 "file.json".to_string(),
6866 ],
6867 &ConfigRoots {
6868 home: None,
6869 cwd: root.clone(),
6870 no_color_env: false,
6871 },
6872 )
6873 .unwrap();
6874 assert!(
6875 !merged.iter().any(|arg| arg == "--markdown"),
6876 "config mode must not override an explicit subcommand: {merged:?}"
6877 );
6878 let _ = std::fs::remove_dir_all(&root);
6879 }
6880
6881 #[test]
6882 fn suffix_collisions_step_past_files_already_on_disk() {
6883 let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
6884 let _ = std::fs::remove_dir_all(&root);
6885 std::fs::create_dir_all(&root).unwrap();
6886 let target = root.join("out.html");
6887 std::fs::write(&target, "existing").unwrap();
6888 let target = target.to_string_lossy().into_owned();
6889
6890 let cli = parse(&[
6891 "--no-config".to_string(),
6892 "--batch".to_string(),
6893 "--collision".to_string(),
6894 "suffix".to_string(),
6895 "x".to_string(),
6896 ])
6897 .unwrap()
6898 .unwrap();
6899 let mut taken = std::collections::BTreeSet::new();
6900 let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
6901 assert!(
6902 first.ends_with("out-2.html"),
6903 "existing file must be kept: {first}"
6904 );
6905 let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
6906 assert!(second.ends_with("out-3.html"));
6907
6908 let mut cli_error = cli.clone();
6910 cli_error.collision = CollisionPolicy::Error;
6911 let mut taken = std::collections::BTreeSet::new();
6912 let (class, message) =
6913 resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
6914 assert_eq!(class, ExitClass::Input);
6915 assert!(message.contains("--overwrite"));
6916
6917 let mut cli_overwrite = cli.clone();
6919 cli_overwrite.overwrite = true;
6920 let mut taken = std::collections::BTreeSet::new();
6921 assert_eq!(
6922 resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
6923 target
6924 );
6925 let _ = std::fs::remove_dir_all(&root);
6926 }
6927
6928 #[test]
6929 fn batch_outputs_keep_their_export_kind() {
6930 let outputs = BatchOutputs {
6932 html: batch_export_path(Some("result.out"), "a.md", 0, 1),
6933 ..BatchOutputs::default()
6934 };
6935 assert_eq!(outputs.html.as_deref(), Some("result.out"));
6936 assert!(outputs.svg.is_none());
6937 }
6938
6939 #[test]
6940 fn parses_export_flags() {
6941 let s = |v: &str| v.to_string();
6942 let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
6944 .unwrap()
6945 .unwrap();
6946 assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
6947 assert!(cli.export_html.is_none());
6948 assert_eq!(cli.resource.as_deref(), Some("x"));
6949
6950 let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
6951 assert_eq!(cli.export_html.as_deref(), Some("out.html"));
6952
6953 let cli = parse(&[
6955 s("--export-html"),
6956 s("a.html"),
6957 s("--export-svg"),
6958 s("b.svg"),
6959 s("x"),
6960 ])
6961 .unwrap()
6962 .unwrap();
6963 assert_eq!(cli.export_html.as_deref(), Some("a.html"));
6964 assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
6965
6966 assert!(parse(&[s("--export-html")]).is_err());
6968 }
6970
6971 #[test]
6972 fn is_number_matches_pattern() {
6973 for ok in [
6978 "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
6979 ] {
6980 assert!(is_number(ok), "{ok:?} should be numeric");
6981 }
6982 for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
6983 assert!(!is_number(no), "{no:?} should not be numeric");
6984 }
6985 }
6986
6987 #[test]
6988 fn render_csv_matches_upstream() {
6989 let table = build_csv_table(
6993 "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
6994 Some(','),
6995 None,
6996 None,
6997 )
6998 .expect("the sniffer reads this one");
6999 let out = Console::builder()
7000 .force_terminal(true)
7001 .color_system(Some(ColorSystem::Truecolor))
7002 .width(40)
7003 .no_color(false)
7004 .build()
7005 .render_to_string(&table);
7006 let expected = concat!(
7007 "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
7008 "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
7009 "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
7010 "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
7011 "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
7012 "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
7013 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
7014 "\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
7015 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
7016 "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
7017 );
7018 assert_eq!(out, expected);
7019 }
7020}