1use std::io::{BufRead, IsTerminal, Read, Write};
32use std::path::{Path, PathBuf};
33use std::process::ExitCode;
34
35mod authoring;
36mod batch;
37mod batch_output;
38mod batch_paths;
39#[cfg(test)]
40mod batch_races;
41mod chart;
42mod cli_spec;
43mod config;
44mod controls;
45mod demo;
46mod doctor;
47mod inspect;
48#[cfg(feature = "interact")]
49mod interactive;
50#[cfg(feature = "art")]
51mod micro;
52mod plugins;
53mod profile;
54#[cfg(feature = "record")]
55mod record;
56mod render_target;
57#[cfg(feature = "serve")]
58mod serve;
59mod sources;
60mod structured_log;
61mod tools;
62mod viewers;
63mod watch;
64use batch::run_batch;
65use config::{config_args, ConfigRoots};
66
67use rich::cells::cell_len;
68use rich::markdown::Markdown;
69use rich::measure::Measurement;
70use rich::protocol::{LineRenderable, OwnedTableRows};
71use rich::r#box::{Box as BoxSet, DOUBLE, HEAVY_HEAD, SQUARE};
72use rich::text::Text;
73use rich::{
74 filesize, Align, AnsiDecoder, Bar, ColorSystem, Columns, Console, ConsoleOptions, Constrain,
75 Control, Highlighter, HorizontalAlign, ISO8601Highlighter, Json, Justify, Layout, Live,
76 LiveRender, LogLevel, LogRender, Padding, Panel, Pretty, Progress, ProgressBar, ProgressColumn,
77 Renderable, Rule, Segment, Spinner, Status, Style, Styled, Syntax, Table, Traceback, Tree,
78 DEFAULT_TERMINAL_THEME,
79};
80use rich_data::csv::{has_header, read_rows, sniff, Dialect};
81use rich_data::infer::{InferredType, Inferrer};
82use rich_ext::cli::CliExtensions;
83use rich_ext::encoding::{has_utf16_bom, Encoding};
84use rich_ext::sanitize_terminal_controls;
85use rich_ext::ConsoleExt;
86
87fn text(content: &str) -> Box<dyn Renderable> {
89 Box::new(Text::new(content))
90}
91
92const VERSION: &str = env!("CARGO_PKG_VERSION");
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum Mode {
97 Auto,
99 Print,
101 Markdown,
103 Json,
105 Syntax,
107 Csv,
109 Ipynb,
111 Rst,
113 Gif,
115 Diff,
117 Image,
119 Rule,
121 JsonLines,
123 Log,
125 Inspect,
127 AnsiExplain,
129 View,
132 Hex,
134 Unicode,
136 Env,
138 Capture,
140 Mermaid,
142 Dot,
144 Deps,
146 Schema,
149 Chart,
151 Profile,
153}
154
155impl Mode {
156 fn streams_records(self) -> bool {
157 matches!(self, Self::JsonLines | Self::Log)
158 }
159
160 fn draws_directly(self) -> bool {
161 matches!(self, Self::Gif | Self::Diff | Self::Image) || self.streams_records()
162 }
163
164 fn allows_extensions(self) -> bool {
165 !matches!(self, Self::Gif | Self::Diff | Self::Image | Self::Rule)
166 && !self.streams_records()
167 }
168
169 fn accepts_multiple_resources(self) -> bool {
170 matches!(
171 self,
172 Self::Gif | Self::Diff | Self::Env | Self::Capture | Self::Schema
173 )
174 }
175}
176
177struct ModeSpec {
178 mode: Mode,
179 primary: &'static str,
180 aliases: &'static [&'static str],
181}
182
183const MODE_SPECS: &[ModeSpec] = &[
184 ModeSpec {
185 mode: Mode::Auto,
186 primary: "auto",
187 aliases: &[],
188 },
189 ModeSpec {
190 mode: Mode::Print,
191 primary: "print",
192 aliases: &["print"],
193 },
194 ModeSpec {
195 mode: Mode::Markdown,
196 primary: "markdown",
197 aliases: &["markdown", "md"],
198 },
199 ModeSpec {
200 mode: Mode::Json,
201 primary: "json",
202 aliases: &["json"],
203 },
204 ModeSpec {
205 mode: Mode::Syntax,
206 primary: "syntax",
207 aliases: &["syntax", "code"],
208 },
209 ModeSpec {
210 mode: Mode::Csv,
211 primary: "csv",
212 aliases: &["csv", "tsv"],
213 },
214 ModeSpec {
215 mode: Mode::Ipynb,
216 primary: "ipynb",
217 aliases: &["ipynb", "notebook"],
218 },
219 ModeSpec {
220 mode: Mode::Rst,
221 primary: "rst",
222 aliases: &["rst"],
223 },
224 ModeSpec {
225 mode: Mode::Gif,
226 primary: "gif",
227 aliases: &["gif"],
228 },
229 ModeSpec {
230 mode: Mode::Diff,
231 primary: "diff",
232 aliases: &["diff"],
233 },
234 ModeSpec {
235 mode: Mode::Image,
236 primary: "image",
237 aliases: &["image"],
238 },
239 ModeSpec {
240 mode: Mode::Rule,
241 primary: "rule",
242 aliases: &["rule"],
243 },
244 ModeSpec {
245 mode: Mode::JsonLines,
246 primary: "jsonl",
247 aliases: &["jsonl", "ndjson"],
248 },
249 ModeSpec {
250 mode: Mode::Log,
251 primary: "log",
252 aliases: &["log", "logs"],
253 },
254 ModeSpec {
255 mode: Mode::Inspect,
256 primary: "inspect",
257 aliases: &["inspect"],
258 },
259 ModeSpec {
260 mode: Mode::AnsiExplain,
261 primary: "ansi",
262 aliases: &["ansi", "ansi-explain"],
263 },
264 ModeSpec {
265 mode: Mode::View,
266 primary: "view",
267 aliases: &["view"],
268 },
269 ModeSpec {
270 mode: Mode::Hex,
271 primary: "hex",
272 aliases: &["hex", "hexdump"],
273 },
274 ModeSpec {
275 mode: Mode::Unicode,
276 primary: "unicode",
277 aliases: &["unicode"],
278 },
279 ModeSpec {
280 mode: Mode::Env,
281 primary: "env",
282 aliases: &["env"],
283 },
284 ModeSpec {
285 mode: Mode::Capture,
286 primary: "capture",
287 aliases: &["capture"],
288 },
289 ModeSpec {
290 mode: Mode::Mermaid,
291 primary: "mermaid",
292 aliases: &["mermaid", "mmd"],
293 },
294 ModeSpec {
295 mode: Mode::Dot,
296 primary: "dot",
297 aliases: &["dot", "graphviz"],
298 },
299 ModeSpec {
300 mode: Mode::Deps,
301 primary: "deps",
302 aliases: &["deps"],
303 },
304 ModeSpec {
305 mode: Mode::Schema,
306 primary: "schema",
307 aliases: &["schema"],
308 },
309 ModeSpec {
310 mode: Mode::Chart,
311 primary: "chart",
312 aliases: &["chart"],
313 },
314 ModeSpec {
315 mode: Mode::Profile,
316 primary: "profile",
317 aliases: &["profile"],
318 },
319];
320
321const RENDER_MODE_FLAGS: &str =
322 "--print/--markdown/--rst/--json/--syntax/--csv/--ipynb/--rule/--gif/--diff/--image/--jsonl/--log/--inspect/--ansi-explain";
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325enum ReportFormat {
326 Human,
327 Json,
328}
329
330impl ReportFormat {
331 fn apply_option(&mut self, option: &str, value: Option<&str>) -> Result<(), String> {
332 match option {
333 "--report" => {
334 *self = match value.ok_or("--report requires one of: human, json")? {
335 "human" => Self::Human,
336 "json" => Self::Json,
337 other => return Err(format!("unknown report format {other:?} (human, json)")),
338 };
339 }
340 "--machine-json" => *self = Self::Json,
341 _ => {}
342 }
343 Ok(())
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348enum ExitClass {
349 Success,
350 Usage,
351 Input,
352 Data,
353 #[cfg_attr(not(feature = "art"), allow(dead_code))]
354 Gate,
355}
356
357impl ExitClass {
358 fn code(self) -> u8 {
359 match self {
360 Self::Success => 0,
361 Self::Usage => 2,
362 Self::Input => 3,
363 Self::Data => 4,
364 Self::Gate => 5,
365 }
366 }
367
368 fn name(self) -> &'static str {
369 match self {
370 Self::Success => "success",
371 Self::Usage => "usage",
372 Self::Input => "input",
373 Self::Data => "data",
374 Self::Gate => "gate",
375 }
376 }
377
378 fn exit_code(self) -> ExitCode {
379 ExitCode::from(self.code())
380 }
381}
382
383#[derive(Clone, Copy, PartialEq, Eq, Debug)]
390enum ImageMode {
391 Auto,
393 Sixel,
395 Blocks,
397 Braille,
399 Quadrants,
401 Ascii,
403 None,
405}
406
407impl std::str::FromStr for ImageMode {
408 type Err = String;
409
410 fn from_str(value: &str) -> Result<Self, Self::Err> {
411 match value.to_ascii_lowercase().as_str() {
412 "auto" => Ok(Self::Auto),
413 "sixel" => Ok(Self::Sixel),
414 "blocks" | "block" | "half-block" | "half-blocks" => Ok(Self::Blocks),
415 "braille" => Ok(Self::Braille),
416 "quadrants" | "quadrant" => Ok(Self::Quadrants),
417 "ascii" | "art" => Ok(Self::Ascii),
418 "none" | "off" => Ok(Self::None),
419 other => Err(format!(
420 "unknown image mode {other:?} (auto, sixel, blocks, quadrants, braille, ascii, none)"
421 )),
422 }
423 }
424}
425
426#[derive(Clone, Debug, Default, PartialEq)]
430struct SourceOptions {
431 head: Option<usize>,
433 tail: Option<usize>,
434 line_numbers: bool,
436 guides: bool,
438 lexer: Option<String>,
440 no_wrap: bool,
442 infer: bool,
446}
447
448impl SourceOptions {
449 fn wants_syntax(&self) -> bool {
451 self.head.is_some()
452 || self.tail.is_some()
453 || self.line_numbers
454 || self.guides
455 || self.lexer.is_some()
456 || self.no_wrap
457 }
458
459 fn line_range(&self, code: &str) -> Result<Option<(i64, i64)>, String> {
464 let lines = python_line_count(code) as i64;
465 match (self.head, self.tail) {
466 (Some(_), Some(_)) => Err("cannot specify both head and tail".into()),
467 (Some(head), None) => Ok(Some((1, head as i64))),
468 (None, Some(tail)) => Ok(Some((lines - tail as i64 + 2, lines + 1))),
469 (None, None) => Ok(None),
470 }
471 }
472
473 fn syntax(&self, code: &str, language: &str) -> Result<Syntax, String> {
475 let mut syntax = Syntax::new(code, self.lexer.as_deref().unwrap_or(language))
476 .line_numbers(self.line_numbers)
477 .indent_guides(self.guides)
478 .word_wrap(!self.no_wrap);
479 if let Some((start, end)) = self.line_range(code)? {
480 syntax = syntax.line_range(Some(start), Some(end));
481 }
482 Ok(syntax)
483 }
484}
485
486fn python_line_count(text: &str) -> usize {
490 let mut count = 0;
491 let mut chars = text.chars().peekable();
492 let mut open = false;
493 while let Some(c) = chars.next() {
494 match c {
495 '\r' => {
496 if chars.peek() == Some(&'\n') {
497 chars.next();
498 }
499 count += 1;
500 open = false;
501 }
502 '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}'
503 | '\u{2029}' => {
504 count += 1;
505 open = false;
506 }
507 _ => open = true,
508 }
509 }
510 count + usize::from(open)
511}
512
513#[derive(Clone)]
515struct Cli {
516 mode: Mode,
517 resource: Option<String>,
518 #[cfg_attr(not(feature = "art"), allow(dead_code))]
522 resources: Vec<String>,
523 #[cfg_attr(not(feature = "art"), allow(dead_code))]
525 loops: Option<usize>,
526 #[cfg_attr(not(feature = "art"), allow(dead_code))]
529 diff_threshold: Option<f32>,
530 #[cfg_attr(not(feature = "art"), allow(dead_code))]
532 image_mode: ImageMode,
533 #[cfg_attr(not(feature = "art"), allow(dead_code))]
534 image_fit: Option<String>,
535 #[cfg_attr(not(feature = "art"), allow(dead_code))]
536 image_anchor: Option<String>,
537 #[cfg_attr(not(feature = "art"), allow(dead_code))]
538 image_background: Option<ImageBackdrop>,
539 #[cfg_attr(not(feature = "art"), allow(dead_code))]
540 image_color: Option<String>,
541 #[cfg_attr(not(feature = "art"), allow(dead_code))]
542 image_dither: Option<String>,
543 #[cfg_attr(not(feature = "art"), allow(dead_code))]
544 image_color_distance: Option<String>,
545 #[cfg_attr(not(feature = "art"), allow(dead_code))]
546 image_rotate: Option<u16>,
547 #[cfg_attr(not(feature = "art"), allow(dead_code))]
548 image_flip_horizontal: bool,
549 #[cfg_attr(not(feature = "art"), allow(dead_code))]
550 image_flip_vertical: bool,
551 #[cfg_attr(not(feature = "art"), allow(dead_code))]
552 image_grayscale: bool,
553 #[cfg_attr(not(feature = "art"), allow(dead_code))]
554 image_max_width: Option<usize>,
555 #[cfg_attr(not(feature = "art"), allow(dead_code))]
556 image_max_height: Option<usize>,
557 #[cfg_attr(not(feature = "art"), allow(dead_code))]
559 image_brightness: Option<f32>,
560 #[cfg_attr(not(feature = "art"), allow(dead_code))]
561 image_contrast: Option<f32>,
562 #[cfg_attr(not(feature = "art"), allow(dead_code))]
563 image_gamma: Option<f32>,
564 log_presentation: String,
565 mermaid_backend: Option<MermaidBackend>,
568 dot_backend: Option<sources::DotBackend>,
571 graph_sources: sources::GraphSourceOptions,
573 chart: chart::ChartOptions,
575 schema: sources::SchemaOptions,
577 profile: profile::ProfileOptions,
579 highlighter: Option<String>,
581 code_theme: Option<String>,
583 plugins: Vec<String>,
586 #[cfg_attr(not(feature = "art"), allow(dead_code))]
589 micro_project: bool,
590 theme_styles: std::collections::BTreeMap<String, Style>,
591 theme_file_styles: std::collections::BTreeMap<String, Style>,
594 #[cfg_attr(not(feature = "art"), allow(dead_code))]
597 height: Option<usize>,
598 extensions: CliExtensions,
599 width: Option<usize>,
600 justify: Option<Justify>,
601 text_justify: Option<Justify>,
605 emoji: bool,
608 soft: bool,
610 max_width: Option<usize>,
612 rule_style: Option<Style>,
614 rule_char: Option<String>,
615 force_terminal: bool,
617 source: SourceOptions,
619 no_color: bool,
620 export_html: Option<String>,
623 export_svg: Option<String>,
625 panel: Option<BoxSet>,
629 padding: Option<(usize, usize, usize, usize)>,
631 title: Option<String>,
633 caption: Option<String>,
635 panel_style: Option<Style>,
637 style: Option<Style>,
640 expand: bool,
643 pager: bool,
645 auto_pager: bool,
646 hyperlinks: bool,
650 sanitize: bool,
652 report_format: ReportFormat,
655 watch: bool,
657 watch_interval: f64,
659 watch_cache: bool,
661 watch_debounce: f64,
664 watch_poll: bool,
666 watch_exit_on_error: bool,
668 batch: bool,
669 progress: bool,
670 continue_on_error: bool,
671 jobs: usize,
672 dry_run: bool,
673 batch_paths: batch_paths::BatchPathOptions,
674 worker_args: Vec<String>,
675 overwrite: bool,
676 collision: CollisionPolicy,
677 data: inspect::DataOptions,
678 tools: tools::ToolOptions,
679 viewers: viewers::ViewerOptions,
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684enum MermaidBackend {
685 Text,
687 Mmdc,
690 Off,
692}
693
694impl MermaidBackend {
695 fn parse(value: &str) -> Result<Self, String> {
696 match value {
697 "text" => Ok(Self::Text),
698 "off" => Ok(Self::Off),
699 "mmdc" if cfg!(feature = "mmdc") => Ok(Self::Mmdc),
700 "mmdc" => Err(
701 "--mermaid-backend mmdc needs a build with the mmdc feature \
702 (cargo install rs-rich-cli --features mmdc)"
703 .into(),
704 ),
705 other => Err(format!(
706 "unknown Mermaid backend {other:?} (text, mmdc, off)"
707 )),
708 }
709 }
710}
711
712#[cfg(feature = "mermaid")]
714fn mermaid_options(backend: MermaidBackend) -> rich_mermaid::MermaidOptions {
715 rich_mermaid::MermaidOptions {
716 backend: match backend {
717 MermaidBackend::Mmdc => rich_mermaid::Backend::Mmdc,
718 MermaidBackend::Text | MermaidBackend::Off => rich_mermaid::Backend::Text,
719 },
720 ..rich_mermaid::MermaidOptions::default()
721 }
722}
723
724fn plugin_registry(mermaid: MermaidBackend) -> rich_ext::ExtensionRegistry {
727 plugin_registry_with(mermaid, true)
728}
729
730fn plugin_registry_with(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
733 let mut registry = builtin_registry(mermaid, dot);
734 plugins::add_to(&mut registry);
735 registry
736}
737
738fn builtin_registry(mermaid: MermaidBackend, dot: bool) -> rich_ext::ExtensionRegistry {
741 let mut registry = rich_ext::ExtensionRegistry::with_defaults();
742 if dot {
743 registry
744 .add_plugin(&rich_diagram::plugin::DotPlugin::default())
745 .expect("the DOT plugin registers cleanly");
746 }
747 #[cfg(feature = "mermaid")]
748 if mermaid != MermaidBackend::Off {
749 registry
750 .add_plugin(&rich_mermaid::MermaidPlugin::new(mermaid_options(mermaid)))
751 .expect("the Mermaid plugin registers cleanly");
752 }
753 #[cfg(not(feature = "mermaid"))]
754 let _ = mermaid;
755 #[cfg(feature = "lumis")]
756 registry
757 .add_plugin(&rich_lumis::LumisPlugin)
758 .expect("the lumis plugin registers cleanly");
759 registry
760}
761
762fn code_highlighting(
766 highlighter: Option<&str>,
767 theme: Option<&str>,
768) -> Result<Option<rich::CodeHighlighting>, String> {
769 if highlighter.is_none() && theme.is_none() {
770 return Ok(None);
771 }
772 let mut registry = plugin_registry(MermaidBackend::Off);
773 let name = highlighter.unwrap_or("syntect");
774 if let Err(error) = registry.set_default_code_highlighter(name, theme) {
775 return Err(match error {
776 rich_ext::HighlighterChoiceError::UnknownHighlighter { .. }
777 if name == "lumis" && !cfg!(feature = "lumis") =>
778 {
779 format!(
780 "{error} (lumis needs a build with the lumis feature: cargo install \
781 rs-rich-cli --features lumis)"
782 )
783 }
784 rich_ext::HighlighterChoiceError::UnknownTheme { .. } => {
785 let themes = registry
786 .code_highlighter(name)
787 .map(|engine| engine.themes())
788 .unwrap_or_default();
789 if themes.len() <= 40 {
790 format!("{error}; its themes: {}", themes.join(", "))
791 } else {
792 format!(
793 "{error}; it has {} themes, such as {}; `rich doctor --report json` \
794 lists them all",
795 themes.len(),
796 themes[..12].join(", ")
797 )
798 }
799 }
800 other => other.to_string(),
801 });
802 }
803 Ok(registry.code_highlighting())
804}
805
806fn apply_code_highlighting(console: &mut Console, cli: &Cli) {
808 use rich::ConsoleCodeHighlighting;
809 if let Ok(Some(highlighting)) =
810 code_highlighting(cli.highlighter.as_deref(), cli.code_theme.as_deref())
811 {
812 console.set_code_highlighting(Some(highlighting));
813 }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817enum CollisionPolicy {
818 Error,
819 Overwrite,
820 Suffix,
821}
822
823fn build_markdown(source: &str, hyperlinks: bool) -> Markdown {
835 Markdown::new(source).hyperlinks(hyperlinks)
836}
837
838pub fn run(args: Vec<std::ffi::OsString>) -> ExitCode {
853 dispatch(remember_raw_args(args))
854}
855
856static RAW_ARGS: std::sync::Mutex<Vec<(String, Option<std::ffi::OsString>)>> =
860 std::sync::Mutex::new(Vec::new());
861
862fn remember_raw_args(args: Vec<std::ffi::OsString>) -> Vec<String> {
871 let mut raw: Vec<(String, Option<std::ffi::OsString>)> = Vec::new();
872 let args = args
873 .into_iter()
874 .map(|arg| match arg.into_string() {
875 Ok(arg) => arg,
876 Err(arg) => {
877 let spelling = escaped_path(&arg);
878 match raw.iter_mut().find(|(known, _)| *known == spelling) {
879 Some((_, original)) if original.as_ref() != Some(&arg) => *original = None,
880 Some(_) => {}
881 None => raw.push((spelling.clone(), Some(arg))),
882 }
883 spelling
884 }
885 })
886 .collect();
887 if let Ok(mut slot) = RAW_ARGS.lock() {
888 *slot = raw;
889 }
890 args
891}
892
893pub(crate) fn fs_path(arg: &str) -> std::path::PathBuf {
897 RAW_ARGS
898 .lock()
899 .ok()
900 .and_then(|raw| {
901 raw.iter()
902 .find(|(spelling, _)| spelling == arg)
903 .and_then(|(_, original)| original.clone())
904 })
905 .map_or_else(|| std::path::PathBuf::from(arg), std::path::PathBuf::from)
906}
907
908pub(crate) fn text_arg(arg: &str) -> String {
912 if !arg.starts_with('\0') {
913 return arg.to_string();
914 }
915 RAW_ARGS
916 .lock()
917 .ok()
918 .and_then(|raw| {
919 raw.iter()
920 .find(|(spelling, _)| spelling == arg)
921 .and_then(|(_, original)| original.clone())
922 })
923 .map_or_else(
924 || arg.to_string(),
925 |original| original.to_string_lossy().into_owned(),
926 )
927}
928
929pub(crate) fn path_arg(path: &Path) -> String {
937 if let Some(text) = path.to_str() {
938 return text.to_string();
939 }
940 let spelling = escaped_path(path.as_os_str());
941 if let Ok(mut raw) = RAW_ARGS.lock() {
942 let original = path.as_os_str().to_owned();
943 match raw.iter_mut().find(|(known, _)| *known == spelling) {
944 Some((_, known)) if known.as_ref() != Some(&original) => *known = None,
945 Some(_) => {}
946 None => raw.push((spelling.clone(), Some(original))),
947 }
948 }
949 spelling
950}
951
952#[cfg(unix)]
955fn escaped_path(path: &std::ffi::OsStr) -> String {
956 use std::os::unix::ffi::OsStrExt;
957 let mut out = String::from('\0');
958 for chunk in path.as_bytes().utf8_chunks() {
959 out.push_str(&chunk.valid().replace('\\', "\\x5C"));
960 for byte in chunk.invalid() {
961 out.push_str(&format!("\\x{byte:02X}"));
962 }
963 }
964 out
965}
966
967#[cfg(not(unix))]
968fn escaped_path(path: &std::ffi::OsStr) -> String {
969 format!("\0{}", path.to_string_lossy())
970}
971
972pub fn run_embedded(program: Vec<std::ffi::OsString>, args: Vec<std::ffi::OsString>) -> ExitCode {
982 let _turn = EMBEDDED_RUN.lock().unwrap_or_else(|e| e.into_inner());
985 if let Ok(mut slot) = SELF_PROGRAM.lock() {
986 *slot = program;
987 }
988 run(args)
989}
990
991static EMBEDDED_RUN: std::sync::Mutex<()> = std::sync::Mutex::new(());
993
994static SELF_PROGRAM: std::sync::Mutex<Vec<std::ffi::OsString>> = std::sync::Mutex::new(Vec::new());
996
997fn dispatch(args: Vec<String>) -> ExitCode {
998 plugins::install(Vec::new());
1001 if demo::requested(&args) {
1002 return demo::dispatch(&args);
1003 }
1004 if doctor::requested(&args) {
1005 return doctor::dispatch(&args);
1006 }
1007 if plugins::requested(&args) {
1008 return plugins::dispatch(&args);
1009 }
1010 #[cfg(feature = "art")]
1011 if micro::requested(&args) {
1012 return micro::dispatch(&args);
1013 }
1014 #[cfg(feature = "interact")]
1015 if let Some(command) = interactive::requested(&args) {
1016 return interactive::dispatch(command, &args);
1017 }
1018 #[cfg(feature = "record")]
1019 if record::requested(&args) {
1020 return record::dispatch(&args);
1021 }
1022 #[cfg(feature = "serve")]
1023 if serve::requested(&args) {
1024 return serve::dispatch(&args);
1025 }
1026 match parse(&args) {
1027 Ok(None) => ExitCode::SUCCESS, Ok(Some(cli)) => {
1029 if let Err(message) = plugins::check_linked() {
1030 return fail(&cli, ExitClass::Usage, message);
1031 }
1032 if !cli.plugins.is_empty() {
1033 match plugins::load(&cli.plugins) {
1034 Ok(loaded) => plugins::install(loaded),
1035 Err((class, message)) => return fail(&cli, class, message),
1036 }
1037 }
1038 if let Some(message) = cli
1040 .data
1041 .unknown_transform(&plugin_registry(MermaidBackend::Text))
1042 {
1043 return fail(&cli, ExitClass::Usage, message);
1044 }
1045 if cli.batch {
1046 if let Err(error) = batch::install_interrupt_handler() {
1047 return fail(
1048 &cli,
1049 ExitClass::Input,
1050 format!("cannot install batch interruption handler: {error}"),
1051 );
1052 }
1053 }
1054 run_cli(cli)
1055 }
1056 Err(message) => emit_error(
1057 wants_json_report(&args),
1058 ExitClass::Usage,
1059 &format!("{message} (try --help)"),
1060 ),
1061 }
1062}
1063
1064fn self_command() -> std::io::Result<std::process::Command> {
1068 let program = SELF_PROGRAM
1069 .lock()
1070 .map(|program| program.clone())
1071 .unwrap_or_default();
1072 Ok(match program.split_first() {
1073 Some((executable, rest)) => {
1074 let mut command = std::process::Command::new(executable);
1075 command.args(rest);
1076 command
1077 }
1078 None => std::process::Command::new(std::env::current_exe()?),
1079 })
1080}
1081
1082const VALUE_OPTIONS: &[&str] = &[
1085 "--batch-input-root",
1086 "--batch-name-template",
1087 "--log-presentation",
1088 "--mermaid-backend",
1089 "--dot-backend",
1090 "--metadata",
1091 "--why",
1092 "--package",
1093 "--timings",
1094 "--audit",
1095 "--depth",
1096 "--kind",
1097 "--x",
1098 "--y",
1099 "--sample",
1100 "--columns",
1101 "--top",
1102 "--highlighter",
1103 "--code-theme",
1104 "--plugin",
1105 "-w",
1106 "--width",
1107 "-o",
1108 "--export-html",
1109 "--export-svg",
1110 "-a",
1111 "--panel",
1112 "-d",
1113 "--padding",
1114 "-h",
1115 "--head",
1116 "-t",
1117 "--tail",
1118 "--lexer",
1119 "-W",
1120 "--max-width",
1121 "--rule-style",
1122 "--rule-char",
1123 "--title",
1124 "--caption",
1125 "-s",
1126 "--style",
1127 "-S",
1128 "--panel-style",
1129 "--report",
1130 "--image-mode",
1131 "--image-fit",
1132 "--image-anchor",
1133 "--image-background",
1134 "--height",
1135 "--theme",
1136 "--theme-style",
1137 "--theme-file",
1138 "--image-color",
1139 "--image-dither",
1140 "--image-color-distance",
1141 "--image-rotate",
1142 "--image-max-width",
1143 "--image-max-height",
1144 "--image-brightness",
1145 "--image-contrast",
1146 "--image-gamma",
1147 "--demo-section",
1148 "--demo-delay",
1149 "--watch-interval",
1150 "--interval",
1151 "--watch-debounce",
1152 "--gif-mode",
1153 "--encoding",
1154 "--threshold",
1155 "--loop",
1156 "--jobs",
1157 "--collision",
1158 "--format",
1159 "--select",
1160 "--filter",
1161 "--highlight",
1162 "--transform",
1163 "--find",
1164 "--max-depth",
1165 "--max-length",
1166 "--compare",
1167 "--context",
1168 "--language",
1169 "--search",
1170 "--offset",
1171 "--length",
1172 "--bytes-per-line",
1173 "--group",
1174 "--limit",
1175 "--cast",
1176 "--redact-pattern",
1177 "--config",
1178 "--profile",
1179];
1180
1181fn selects_mode_explicitly(args: &[String]) -> bool {
1185 explicit_mode(args).is_some()
1186}
1187
1188fn explicit_mode(args: &[String]) -> Option<Mode> {
1190 let mut iter = args.iter();
1191 let mut seen_positional = false;
1192 while let Some(arg) = iter.next() {
1193 if arg == "--" {
1194 break;
1195 }
1196 if VALUE_OPTIONS.contains(&arg.as_str()) {
1197 iter.next();
1198 continue;
1199 }
1200 if arg.starts_with('-') && arg.len() > 1 {
1201 if let Some(flag) = mode_flag_alias(arg) {
1202 return command_mode(&flag[2..]);
1203 }
1204 continue;
1205 }
1206 if !seen_positional {
1207 seen_positional = true;
1208 if let Some(mode) = command_mode(arg) {
1209 return Some(mode);
1210 }
1211 }
1212 }
1213 None
1214}
1215
1216fn command_word(args: &[String]) -> Option<&str> {
1219 let mut iter = args.iter();
1220 while let Some(arg) = iter.next() {
1221 if arg == "--" {
1222 return None;
1223 }
1224 if VALUE_OPTIONS.contains(&arg.as_str()) {
1225 iter.next();
1226 } else if !arg.starts_with('-') || arg == "-" {
1227 return command_mode(arg).map(|_| arg.as_str());
1228 }
1229 }
1230 None
1231}
1232
1233pub(crate) fn subcommand_word(args: &[String]) -> Option<&str> {
1239 let mut iter = args.iter();
1240 while let Some(arg) = iter.next() {
1241 if arg == "--" || mode_flag_alias(arg).is_some() {
1242 return None;
1243 }
1244 if VALUE_OPTIONS.contains(&arg.as_str()) {
1245 iter.next();
1246 } else if !arg.starts_with('-') || arg == "-" {
1247 return Some(arg);
1248 }
1249 }
1250 None
1251}
1252
1253fn mode_flag_alias(arg: &str) -> Option<&'static str> {
1255 Some(match arg {
1256 "--print" | "-p" => "--print",
1257 "--markdown" | "-m" => "--markdown",
1258 "--json" | "-j" | "-J" => "--json",
1259 "--syntax" | "-x" => "--syntax",
1260 "--csv" => "--csv",
1261 "--ipynb" => "--ipynb",
1262 "--rst" => "--rst",
1263 "--jsonl" | "--ndjson" => "--jsonl",
1264 "--log" => "--log",
1265 "--rule" | "-u" => "--rule",
1266 "--image" => "--image",
1267 "--gif" => "--gif",
1268 "--diff" => "--diff",
1269 "--inspect" => "--inspect",
1270 "--ansi-explain" => "--ansi-explain",
1271 _ => return None,
1272 })
1273}
1274
1275fn emit_error(json: bool, class: ExitClass, message: &str) -> ExitCode {
1276 let message = &controls::shown(message);
1279 if json {
1280 eprintln!(
1281 "{}",
1282 serde_json::json!({
1283 "ok": false,
1284 "code": class.name(),
1285 "exit_code": class.code(),
1286 "message": message,
1287 "error": {
1288 "message": message,
1289 },
1290 })
1291 );
1292 } else {
1293 eprintln!("rich: {message}");
1294 }
1295 class.exit_code()
1296}
1297
1298fn emit_success_report(format: ReportFormat) {
1299 if format == ReportFormat::Json {
1300 eprintln!(
1301 "{}",
1302 serde_json::json!({
1303 "ok": true,
1304 "code": ExitClass::Success.name(),
1305 "exit_code": ExitClass::Success.code(),
1306 "result": {},
1307 })
1308 );
1309 }
1310}
1311
1312fn fail(cli: &Cli, class: ExitClass, message: impl AsRef<str>) -> ExitCode {
1313 let message = controls::shown(message.as_ref());
1314 if watch::capture_error(&message) {
1316 return class.exit_code();
1317 }
1318 emit_error(cli.report_format == ReportFormat::Json, class, &message)
1319}
1320
1321fn success(cli: &Cli) -> ExitCode {
1322 if !watch::capturing() {
1323 emit_success_report(cli.report_format);
1324 }
1325 ExitClass::Success.exit_code()
1326}
1327
1328#[cfg(test)]
1335fn glob_match(pattern: &str, text: &str) -> bool {
1336 let pattern: Vec<GlobUnit> = pattern.chars().map(GlobUnit::Char).collect();
1337 let text: Vec<GlobUnit> = text.chars().map(GlobUnit::Char).collect();
1338 glob_match_units(&pattern, &text)
1339}
1340
1341#[derive(Clone, Copy, PartialEq, Eq)]
1344enum GlobUnit {
1345 Char(char),
1346 Byte(u8),
1347}
1348
1349fn glob_units(name: &std::ffi::OsStr) -> Vec<GlobUnit> {
1352 #[cfg(unix)]
1353 {
1354 use std::os::unix::ffi::OsStrExt;
1355 let mut units = Vec::new();
1356 for chunk in name.as_bytes().utf8_chunks() {
1357 units.extend(chunk.valid().chars().map(GlobUnit::Char));
1358 units.extend(chunk.invalid().iter().map(|&byte| GlobUnit::Byte(byte)));
1359 }
1360 units
1361 }
1362 #[cfg(not(unix))]
1363 {
1364 name.to_string_lossy().chars().map(GlobUnit::Char).collect()
1365 }
1366}
1367
1368fn glob_match_units(pattern: &[GlobUnit], text: &[GlobUnit]) -> bool {
1369 const ANY: GlobUnit = GlobUnit::Char('?');
1370 const STAR: GlobUnit = GlobUnit::Char('*');
1371 let (mut p, mut t) = (0usize, 0usize);
1372 let (mut star, mut retry) = (None, 0usize);
1373 while t < text.len() {
1374 if p < pattern.len() && (pattern[p] == ANY || pattern[p] == text[t]) {
1375 p += 1;
1376 t += 1;
1377 } else if p < pattern.len() && pattern[p] == STAR {
1378 star = Some(p);
1379 retry = t;
1380 p += 1;
1381 } else if let Some(open) = star {
1382 p = open + 1;
1383 retry += 1;
1384 t = retry;
1385 } else {
1386 return false;
1387 }
1388 }
1389 while p < pattern.len() && pattern[p] == STAR {
1390 p += 1;
1391 }
1392 p == pattern.len()
1393}
1394
1395fn has_glob_meta(value: &str) -> bool {
1396 value.contains('*') || value.contains('?')
1397}
1398
1399fn split_glob(resource: &str) -> (PathBuf, String) {
1401 let meta = resource.find(['*', '?']).unwrap_or(0);
1402 match resource[..meta].rfind(['/', '\\']) {
1403 Some(cut) => (
1404 PathBuf::from(&resource[..cut]),
1405 resource[cut + 1..].to_string(),
1406 ),
1407 None => (PathBuf::from("."), resource.to_string()),
1408 }
1409}
1410
1411fn collect_files(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
1419 let mut stack = vec![dir.to_path_buf()];
1420 while let Some(current) = stack.pop() {
1421 batch::check_interrupted()?;
1422 let entries = std::fs::read_dir(¤t)
1423 .map_err(|error| format!("cannot scan {}: {error}", current.display()))?;
1424 for entry in entries {
1425 batch::check_interrupted()?;
1426 let entry = entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1427 let path = entry.path();
1428 let kind = std::fs::symlink_metadata(&path)
1429 .map_err(|error| format!("cannot read {}: {error}", path.display()))?
1430 .file_type();
1431 if kind.is_symlink() {
1432 continue;
1433 }
1434 if kind.is_dir() {
1435 stack.push(path);
1436 } else if kind.is_file() {
1437 out.push(path_arg(&path));
1438 }
1439 }
1440 }
1441 Ok(())
1442}
1443
1444fn expand_batch_resources(resources: &[String]) -> Result<Vec<String>, String> {
1445 let mut out = Vec::new();
1446 for resource in resources {
1447 batch::check_interrupted()?;
1448 if resource == "-" || is_url(resource) {
1449 return Err("batch resources must be local files or directories".into());
1450 }
1451 if has_glob_meta(resource) {
1452 let raw = fs_path(resource);
1456 let escaped = raw.as_os_str() != std::ffi::OsStr::new(resource);
1457 let (dir, pattern) = if escaped {
1458 let parent = raw.parent().unwrap_or(Path::new(""));
1459 if has_glob_meta(&parent.to_string_lossy()) {
1460 return Err(format!(
1461 "batch glob {} may only use * and ? in the final path segment",
1462 controls::shown(resource)
1463 ));
1464 }
1465 let dir = if parent.as_os_str().is_empty() {
1466 PathBuf::from(".")
1467 } else {
1468 parent.to_path_buf()
1469 };
1470 (dir, glob_units(raw.file_name().unwrap_or_default()))
1471 } else {
1472 let (dir, pattern) = split_glob(resource);
1473 if pattern.contains('/') || pattern.contains('\\') {
1474 return Err(format!(
1475 "batch glob {resource:?} may only use * and ? in the final path segment"
1476 ));
1477 }
1478 (dir, pattern.chars().map(GlobUnit::Char).collect())
1479 };
1480 let entries = std::fs::read_dir(&dir)
1481 .map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1482 for entry in entries {
1483 batch::check_interrupted()?;
1484 let entry =
1485 entry.map_err(|error| format!("cannot scan {}: {error}", dir.display()))?;
1486 let path = entry.path();
1487 if !path.is_file() {
1488 continue;
1489 }
1490 if glob_match_units(&pattern, &glob_units(&entry.file_name())) {
1491 out.push(path_arg(&path));
1492 }
1493 }
1494 continue;
1495 }
1496 let path = fs_path(resource);
1497 if path.is_file() {
1498 out.push(resource.clone());
1499 } else if path.is_dir() {
1500 collect_files(&path, &mut out)?;
1501 } else {
1502 return Err(format!("batch resource does not exist: {resource}"));
1503 }
1504 }
1505 batch::check_interrupted()?;
1506 out.sort();
1507 out.dedup();
1508 batch::check_interrupted()?;
1509 if out.is_empty() {
1510 return Err("batch plan contains no files".into());
1511 }
1512 Ok(out)
1513}
1514
1515#[derive(Default, Clone)]
1521struct BatchOutputs {
1522 html: Option<String>,
1523 svg: Option<String>,
1524}
1525
1526fn batch_export_path(
1527 path: Option<&str>,
1528 input: &str,
1529 index: usize,
1530 total: usize,
1531) -> Option<String> {
1532 let path = path?;
1533 if total == 1 {
1534 return Some(path.into());
1535 }
1536 let p = fs_path(path);
1537 let input = fs_path(input);
1539 let stem = input
1540 .file_stem()
1541 .unwrap_or_else(|| std::ffi::OsStr::new("output"));
1542 let mut name = stem.to_owned();
1543 match p.extension() {
1544 Some(ext) if !ext.is_empty() => {
1545 name.push(".");
1546 name.push(ext);
1547 }
1548 _ => name.push(format!("-{index}")),
1549 }
1550 Some(path_arg(&if p.is_dir() {
1551 p.join(name)
1552 } else {
1553 p.with_file_name(name)
1554 }))
1555}
1556
1557fn suffixed_path(path: &Path, suffix: usize) -> String {
1559 let mut name = path
1560 .file_stem()
1561 .unwrap_or_else(|| std::ffi::OsStr::new("output"))
1562 .to_owned();
1563 name.push(format!("-{suffix}"));
1564 if let Some(ext) = path.extension() {
1565 name.push(".");
1566 name.push(ext);
1567 }
1568 path_arg(&path.with_file_name(name))
1569}
1570
1571fn resolve_destination(
1577 cli: &Cli,
1578 candidate: String,
1579 taken: &mut std::collections::BTreeSet<String>,
1580) -> Result<String, (ExitClass, String)> {
1581 let occupied = |path: &str, taken: &std::collections::BTreeSet<String>| {
1582 taken.contains(path) || (!cli.overwrite && fs_path(path).exists())
1583 };
1584 match cli.collision {
1585 CollisionPolicy::Overwrite => {
1586 taken.insert(candidate.clone());
1587 Ok(candidate)
1588 }
1589 CollisionPolicy::Suffix => {
1590 let mut chosen = candidate.clone();
1591 let mut suffix = 2;
1592 while occupied(&chosen, taken) {
1593 batch::check_interrupted().map_err(|message| (ExitClass::Input, message))?;
1594 chosen = suffixed_path(&fs_path(&candidate), suffix);
1595 suffix += 1;
1596 }
1597 taken.insert(chosen.clone());
1598 Ok(chosen)
1599 }
1600 CollisionPolicy::Error => {
1601 if taken.contains(&candidate) {
1602 return Err((
1603 ExitClass::Usage,
1604 format!("batch output collision: {candidate}"),
1605 ));
1606 }
1607 if !cli.overwrite && fs_path(&candidate).exists() {
1608 return Err((
1609 ExitClass::Input,
1610 format!("refusing to overwrite existing output: {candidate} (use --overwrite)"),
1611 ));
1612 }
1613 taken.insert(candidate.clone());
1614 Ok(candidate)
1615 }
1616 }
1617}
1618
1619fn worker_args(args: &[String]) -> Vec<String> {
1622 let mut result = vec!["--no-config".into()];
1623 let mut iter = args.iter();
1624 let mut seen_resource = false;
1625 let mut selected_mode = false;
1626 while let Some(arg) = iter.next() {
1627 if arg == "--" {
1628 break;
1629 }
1630 if VALUE_OPTIONS.contains(&arg.as_str()) {
1631 let value = iter.next();
1632 if !matches!(
1633 arg.as_str(),
1634 "--jobs"
1635 | "--batch-input-root"
1636 | "--batch-name-template"
1637 | "--collision"
1638 | "--report"
1639 | "-o"
1640 | "--export-html"
1641 | "--export-svg"
1642 | "--config"
1643 | "--profile"
1644 ) {
1645 result.push(arg.clone());
1646 if let Some(value) = value {
1647 result.push(value.clone());
1648 }
1649 }
1650 } else if arg.starts_with('-') && arg != "-" {
1651 selected_mode |= mode_flag_alias(arg).is_some();
1652 if !matches!(
1653 arg.as_str(),
1654 "--batch"
1655 | "--batch-preserve-dirs"
1656 | "--no-batch-preserve-dirs"
1657 | "--no-batch"
1658 | "--dry-run"
1659 | "--progress"
1660 | "--no-progress"
1661 | "--continue-on-error"
1662 | "--no-continue-on-error"
1663 | "--overwrite"
1664 | "--no-overwrite"
1665 | "--machine-json"
1666 ) {
1667 result.push(arg.clone());
1668 }
1669 } else if !seen_resource {
1670 if let Some(mode) = command_mode(arg).filter(|_| !selected_mode) {
1671 selected_mode = true;
1672 result.push(format!("--{}", mode_name(mode)));
1673 } else {
1674 seen_resource = true;
1675 }
1676 }
1677 }
1678 result
1679}
1680
1681fn wants_json_report(args: &[String]) -> bool {
1682 let mut format = ReportFormat::Human;
1683 let mut iter = args.iter();
1684 while let Some(arg) = iter.next() {
1685 match arg.as_str() {
1686 "--report" => {
1687 if let Some(value) = iter.next() {
1688 let _ = format.apply_option("--report", Some(value));
1689 }
1690 }
1691 "--machine-json" => {
1692 let _ = format.apply_option("--machine-json", None);
1693 }
1694 "--" => break,
1695 _ => {}
1696 }
1697 }
1698 format == ReportFormat::Json
1699}
1700
1701fn parse_box(name: &str) -> Result<Option<BoxSet>, String> {
1713 let name = name.to_ascii_lowercase();
1714 if name == "none" {
1715 return Ok(None);
1716 }
1717 PANEL_BOXES
1718 .iter()
1719 .find(|(known, _)| *known == name)
1720 .map(|(_, set)| Some(*set))
1721 .ok_or_else(|| {
1722 let names: Vec<&str> = PANEL_BOXES.iter().map(|(known, _)| *known).collect();
1723 format!(
1724 "unknown panel box {name:?} (use none or {})",
1725 names.join(", ")
1726 )
1727 })
1728}
1729
1730pub(crate) const PANEL_BOXES: &[(&str, BoxSet)] = {
1732 use rich::r#box as b;
1733 &[
1734 ("ascii", b::ASCII),
1735 ("ascii2", b::ASCII2),
1736 ("square", b::SQUARE),
1737 ("rounded", b::ROUNDED),
1738 ("heavy", b::HEAVY),
1739 ("double", b::DOUBLE),
1740 ("ascii_double_head", b::ASCII_DOUBLE_HEAD),
1741 ("square_double_head", b::SQUARE_DOUBLE_HEAD),
1742 ("minimal", b::MINIMAL),
1743 ("minimal_heavy_head", b::MINIMAL_HEAVY_HEAD),
1744 ("minimal_double_head", b::MINIMAL_DOUBLE_HEAD),
1745 ("simple", b::SIMPLE),
1746 ("simple_head", b::SIMPLE_HEAD),
1747 ("simple_heavy", b::SIMPLE_HEAVY),
1748 ("horizontals", b::HORIZONTALS),
1749 ("heavy_edge", b::HEAVY_EDGE),
1750 ("heavy_head", b::HEAVY_HEAD),
1751 ("double_edge", b::DOUBLE_EDGE),
1752 ("markdown", b::MARKDOWN),
1753 ]
1754};
1755
1756fn parse_padding(value: &str) -> Result<(usize, usize, usize, usize), String> {
1760 let error = || "padding should be 1, 2, or 4 integers separated by commas".to_string();
1761 let parts: Result<Vec<usize>, _> = value
1762 .split(',')
1763 .map(|p| p.trim().parse::<usize>())
1764 .collect();
1765 let parts = parts.map_err(|_| error())?;
1766 match parts.as_slice() {
1767 [p] => Ok((*p, *p, *p, *p)),
1768 [v, h] => Ok((*v, *h, *v, *h)),
1769 [t, r, b, l] => Ok((*t, *r, *b, *l)),
1770 _ => Err(error()),
1771 }
1772}
1773
1774fn mode_name(mode: Mode) -> &'static str {
1776 MODE_SPECS
1777 .iter()
1778 .find_map(|spec| (spec.mode == mode).then_some(spec.primary))
1779 .unwrap_or("unknown")
1780}
1781
1782fn command_mode(command: &str) -> Option<Mode> {
1783 MODE_SPECS
1784 .iter()
1785 .find_map(|spec| spec.aliases.contains(&command).then_some(spec.mode))
1786}
1787
1788fn parse_lines(flag: &str, value: Option<&String>) -> Result<usize, String> {
1790 let flag = match flag {
1791 "-h" | "--head" => "--head",
1792 _ => "--tail",
1793 };
1794 let value = value.ok_or_else(|| format!("{flag} requires a number of LINES"))?;
1795 match value.parse::<i64>() {
1796 Ok(lines) if lines >= 1 => Ok(lines as usize),
1797 Ok(lines) => Err(format!("{flag}: {lines} is not in the range x>=1")),
1798 Err(_) => Err(format!("{flag}: {value:?} is not a valid integer")),
1799 }
1800}
1801
1802fn set_mode(current: &mut Mode, mode: Mode) -> Result<(), String> {
1803 if *current != Mode::Auto && *current != mode {
1804 return Err(format!(
1805 "only one render mode ({RENDER_MODE_FLAGS}) may be given"
1806 ));
1807 }
1808 *current = mode;
1809 Ok(())
1810}
1811
1812fn parse(args: &[String]) -> Result<Option<Cli>, String> {
1814 let roots = ConfigRoots::default();
1815 if authoring::dispatch(args)? {
1816 return Ok(None);
1817 }
1818 if tools::bench_dispatch(args)? {
1819 return Ok(None);
1820 }
1821 if let Some(output) = config::inspect(args, &roots)? {
1822 authoring::out(&format!("{output}\n"));
1823 return Ok(None);
1824 }
1825 let merged = config_args(args, &roots)?;
1826 parse_inner(&merged)
1827}
1828
1829fn parse_inner(args: &[String]) -> Result<Option<Cli>, String> {
1830 let mut mode = Mode::Auto;
1831 let mut resources: Vec<String> = Vec::new();
1832 let mut loops = None;
1833 let mut diff_threshold = None;
1834 let mut image_mode = ImageMode::Auto;
1835 let mut image_fit = None;
1836 let mut image_anchor = None;
1837 let mut image_background = None;
1838 let mut image_color = None;
1839 let mut image_dither = None;
1840 let mut image_color_distance = None;
1841 let mut image_rotate = None;
1842 let mut image_flip_horizontal = false;
1843 let mut image_flip_vertical = false;
1844 let mut image_grayscale = false;
1845 let mut image_max_width = None;
1846 let mut image_max_height = None;
1847 let mut image_brightness = None;
1848 let mut image_contrast = None;
1849 let mut image_gamma = None;
1850 let mut log_presentation = String::from("plain");
1851 let mut mermaid_backend = None;
1852 let mut dot_backend = None;
1853 let mut graph_sources = sources::GraphSourceOptions::default();
1854 let mut chart = chart::ChartOptions::default();
1855 let mut schema_options = sources::SchemaOptions::default();
1856 let mut profile_options = profile::ProfileOptions::default();
1857 let mut highlighter: Option<String> = None;
1858 let mut plugins: Vec<String> = Vec::new();
1859 let mut code_theme: Option<String> = None;
1860 let mut theme_styles = std::collections::BTreeMap::new();
1861 let mut theme_file_styles = std::collections::BTreeMap::new();
1862 let mut height = None;
1863 let mut extensions = CliExtensions::default();
1864 let mut data = inspect::DataOptions::default();
1865 let mut tool_options = tools::ToolOptions::default();
1866 let mut viewer_options = viewers::ViewerOptions::default();
1867 let mut paging_explicit = false;
1869 let mut ansi_word = false;
1871 let mut width = None;
1872 let mut justify = None;
1873 let mut no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
1874 let mut export_html: Option<String> = None;
1875 let mut export_svg: Option<String> = None;
1876 let mut panel = None;
1877 let mut padding = None;
1878 let mut title = None;
1879 let mut caption = None;
1880 let mut panel_style = None;
1881 let mut style = None;
1882 let mut expand = false;
1883 let mut pager = false;
1884 let mut auto_pager = false;
1885 let mut hyperlinks = false;
1886 let mut source = SourceOptions::default();
1887 let mut text_flags = [false; 4];
1890 let mut emoji = false;
1891 let mut soft = false;
1892 let mut max_width = None;
1893 let mut rule_style = None;
1894 let mut rule_char = None;
1895 let mut force_terminal = false;
1896 let mut sanitize = None;
1899 let mut report_format = ReportFormat::Human;
1900 let mut watch = false;
1901 let mut watch_interval = 1.0;
1902 let mut watch_cache = false;
1903 let mut watch_debounce: Option<f64> = None;
1904 let mut watch_poll = false;
1905 let mut watch_exit_on_error = false;
1906 let mut batch = false;
1907 let mut progress = true;
1908 let mut micro_project = false;
1909 let mut continue_on_error = false;
1910 let mut jobs = 1usize;
1911 let mut dry_run = false;
1912 let mut batch_paths = batch_paths::BatchPathOptions::default();
1913 let mut overwrite = false;
1914 let mut collision = CollisionPolicy::Error;
1915 let mut end_of_options = false;
1920
1921 let mut iter = args.iter();
1922 while let Some(arg) = iter.next() {
1923 if end_of_options {
1924 resources.push(arg.to_string());
1925 continue;
1926 }
1927 if extensions.parse_option(arg, &mut iter)? {
1928 continue;
1929 }
1930 if data.parse_option(arg, &mut iter)? {
1931 continue;
1932 }
1933 if tool_options.parse_option(arg, &mut iter)? {
1934 continue;
1935 }
1936 if viewer_options.parse_option(arg, &mut iter)? {
1937 continue;
1938 }
1939 if graph_sources.parse_option(arg, &mut iter)? {
1940 continue;
1941 }
1942 if chart.parse_option(arg, &mut iter)? {
1943 continue;
1944 }
1945 if schema_options.parse_option(arg) {
1946 continue;
1947 }
1948 if profile_options.parse_option(arg, &mut iter)? {
1949 continue;
1950 }
1951 if matches!(
1952 arg.as_str(),
1953 "--pager" | "--auto-pager" | "--no-pager" | "--no-auto-pager"
1954 ) {
1955 paging_explicit = true;
1956 }
1957 match arg.as_str() {
1958 "--" => end_of_options = true,
1959 "--help" => {
1961 let no_color = no_color || cli_spec::no_color_requested(args);
1962 let help = command_word(args)
1964 .and_then(|command| cli_spec::subcommand_help(&[command], no_color));
1965 match help {
1966 Some(help) => authoring::out(&format!("{help}\n")),
1967 None => print_help(no_color),
1968 }
1969 return Ok(None);
1970 }
1971 "-V" | "-v" | "--version" => {
1972 println!("rich (rs-rich-cli) {VERSION}");
1973 return Ok(None);
1974 }
1975 "-p" | "--print" => set_mode(&mut mode, Mode::Print)?,
1976 "-m" | "--markdown" => set_mode(&mut mode, Mode::Markdown)?,
1977 "-j" | "-J" | "--json" => set_mode(&mut mode, Mode::Json)?,
1980 "-x" | "--syntax" => set_mode(&mut mode, Mode::Syntax)?,
1981 "--csv" => set_mode(&mut mode, Mode::Csv)?,
1982 "--ipynb" => set_mode(&mut mode, Mode::Ipynb)?,
1983 "--rst" => set_mode(&mut mode, Mode::Rst)?,
1984 "--gif" => set_mode(&mut mode, Mode::Gif)?,
1985 "--diff" => set_mode(&mut mode, Mode::Diff)?,
1986 "--image" => set_mode(&mut mode, Mode::Image)?,
1987 "--jsonl" | "--ndjson" => set_mode(&mut mode, Mode::JsonLines)?,
1988 "--log" => set_mode(&mut mode, Mode::Log)?,
1989 "--inspect" => set_mode(&mut mode, Mode::Inspect)?,
1990 "--ansi-explain" => set_mode(&mut mode, Mode::AnsiExplain)?,
1991 "--highlighter" => {
1992 let value = iter
1993 .next()
1994 .ok_or("--highlighter requires a code highlighter's name")?;
1995 highlighter = Some(value.clone());
1996 }
1997 "--code-theme" => {
1998 let value = iter.next().ok_or("--code-theme requires a theme name")?;
1999 code_theme = Some(value.clone());
2000 }
2001 "--plugin" => {
2002 let path = iter.next().ok_or("--plugin requires PATH")?;
2003 plugins.push(path.clone());
2004 }
2005 "--mermaid-backend" => {
2006 let value = iter
2007 .next()
2008 .ok_or("--mermaid-backend requires text, mmdc or off")?;
2009 mermaid_backend = Some(MermaidBackend::parse(value)?);
2010 }
2011 "--dot-backend" => {
2012 let value = iter
2013 .next()
2014 .ok_or("--dot-backend requires text, graphviz or off")?;
2015 dot_backend = Some(sources::DotBackend::parse(value)?);
2016 }
2017 "--log-presentation" => {
2018 let value = iter
2019 .next()
2020 .ok_or("--log-presentation requires plain or rich")?;
2021 if !matches!(value.as_str(), "plain" | "rich") {
2022 return Err("--log-presentation requires plain or rich".into());
2023 }
2024 log_presentation = value.clone();
2025 }
2026 "--report" => {
2027 let value = iter.next().map(String::as_str);
2028 report_format.apply_option("--report", value)?;
2029 }
2030 "--machine-json" => {
2031 report_format.apply_option("--machine-json", None)?;
2032 }
2033 "--progress" => progress = true,
2034 "--no-progress" => progress = false,
2035 "--micro-project" => micro_project = true,
2036 "--no-micro-project" => micro_project = false,
2037 "--theme-style" => {
2038 let binding = iter.next().ok_or("--theme-style requires NAME=STYLE")?;
2039 let (name, value) = binding
2040 .split_once('=')
2041 .ok_or("--theme-style requires NAME=STYLE")?;
2042 config::validate_theme_binding(name, value)?;
2043 let style = Style::parse(value)
2044 .map_err(|error| format!("invalid theme style {name}: {error}"))?;
2045 theme_styles.insert(name.to_owned(), style);
2046 }
2047 "--theme-file" => {
2048 let path = iter.next().ok_or("--theme-file requires PATH")?;
2049 theme_file_styles = read_theme_file(path, &format!("--theme-file {path}"))?;
2050 }
2051 "--image-color" => {
2052 const USAGE: &str =
2053 "--image-color requires truecolor, ansi256, ansi16 or grayscale";
2054 let value = iter.next().ok_or(USAGE)?;
2055 if !matches!(
2056 value.as_str(),
2057 "truecolor" | "ansi256" | "ansi16" | "grayscale"
2058 ) {
2059 return Err(USAGE.into());
2060 }
2061 image_color = Some(value.clone());
2062 }
2063 flag @ ("--image-max-width" | "--image-max-height") => {
2064 let usage = format!("{flag} requires a positive integer");
2065 let value = iter
2066 .next()
2067 .and_then(|v| v.parse::<usize>().ok())
2068 .filter(|v| *v > 0)
2069 .ok_or(usage)?;
2070 if flag == "--image-max-width" {
2071 image_max_width = Some(value);
2072 } else {
2073 image_max_height = Some(value);
2074 }
2075 }
2076 flag @ ("--image-brightness" | "--image-contrast" | "--image-gamma") => {
2077 let gamma = flag == "--image-gamma";
2078 let usage = if gamma {
2079 format!("{flag} requires a finite number greater than 0 (1.0 = unchanged)")
2080 } else {
2081 format!("{flag} requires a finite number of at least 0 (1.0 = unchanged)")
2082 };
2083 let value = iter
2084 .next()
2085 .and_then(|v| v.parse::<f32>().ok())
2086 .filter(|v| v.is_finite() && (*v > 0.0 || (!gamma && *v == 0.0)))
2087 .ok_or(usage)?;
2088 match flag {
2089 "--image-brightness" => image_brightness = Some(value),
2090 "--image-contrast" => image_contrast = Some(value),
2091 _ => image_gamma = Some(value),
2092 }
2093 }
2094 "--image-flip-horizontal" => image_flip_horizontal = true,
2095 "--no-image-flip-horizontal" => image_flip_horizontal = false,
2096 "--image-flip-vertical" => image_flip_vertical = true,
2097 "--no-image-flip-vertical" => image_flip_vertical = false,
2098 "--image-grayscale" => image_grayscale = true,
2099 "--no-image-grayscale" => image_grayscale = false,
2100 "--image-rotate" => {
2101 let value = iter
2102 .next()
2103 .ok_or("--image-rotate requires 0, 90, 180 or 270")?;
2104 image_rotate = Some(match value.as_str() {
2105 "0" => 0,
2106 "90" => 90,
2107 "180" => 180,
2108 "270" => 270,
2109 _ => return Err("--image-rotate requires 0, 90, 180 or 270".into()),
2110 });
2111 }
2112 "--image-dither" => {
2113 const USAGE: &str =
2114 "--image-dither requires none, floyd-steinberg, bayer4x4 or atkinson";
2115 let value = iter.next().ok_or(USAGE)?;
2116 if !matches!(
2117 value.as_str(),
2118 "none" | "floyd-steinberg" | "bayer4x4" | "atkinson"
2119 ) {
2120 return Err(USAGE.into());
2121 }
2122 image_dither = Some(value.clone());
2123 }
2124 "--image-color-distance" => {
2125 const USAGE: &str = "--image-color-distance requires rgb or oklab";
2126 let value = iter.next().ok_or(USAGE)?;
2127 if !matches!(value.as_str(), "rgb" | "oklab") {
2128 return Err(USAGE.into());
2129 }
2130 image_color_distance = Some(value.clone());
2131 }
2132 "--image-mode" => {
2133 let value = iter.next().ok_or(
2134 "--image-mode requires one of: auto, sixel, blocks, quadrants, braille, ascii, none",
2135 )?;
2136 image_mode = value.parse()?;
2137 }
2138 "--image-fit" => {
2139 let value = iter
2140 .next()
2141 .ok_or("--image-fit requires contain, cover, stretch or native")?;
2142 if !matches!(value.as_str(), "contain" | "cover" | "stretch" | "native") {
2143 return Err("--image-fit requires contain, cover, stretch or native".into());
2144 }
2145 image_fit = Some(value.clone());
2146 }
2147 "--image-anchor" => {
2148 let value = iter.next().ok_or("--image-anchor requires a position")?;
2149 if !matches!(
2150 value.as_str(),
2151 "center"
2152 | "top"
2153 | "bottom"
2154 | "left"
2155 | "right"
2156 | "top-left"
2157 | "top-right"
2158 | "bottom-left"
2159 | "bottom-right"
2160 ) {
2161 return Err("--image-anchor requires center/top/bottom/left/right/top-left/top-right/bottom-left/bottom-right".into());
2162 }
2163 image_anchor = Some(value.clone());
2164 }
2165 "--image-background" => {
2166 const USAGE: &str = "--image-background requires #RRGGBB, default or checkerboard";
2167 let value = iter.next().ok_or(USAGE)?;
2168 image_background = Some(ImageBackdrop::parse(value).ok_or(USAGE)?);
2169 }
2170 "--height" => {
2171 let value = iter.next().ok_or("--height requires a number")?;
2172 let rows: usize = value
2173 .parse()
2174 .map_err(|_| format!("invalid height '{value}'"))?;
2175 if rows == 0 {
2176 return Err("--height must be at least 1".into());
2177 }
2178 height = Some(rows);
2179 }
2180 "--threshold" => {
2181 let value = iter
2182 .next()
2183 .ok_or("--threshold requires a percentage, e.g. --threshold 2")?;
2184 let percent: f32 = value
2185 .parse()
2186 .map_err(|_| format!("invalid threshold '{value}'"))?;
2187 if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
2192 return Err(format!(
2193 "threshold must be a percentage between 0 and 100, got '{value}'"
2194 ));
2195 }
2196 diff_threshold = Some(percent);
2197 }
2198 "--loop" => {
2199 let value = iter.next().ok_or("--loop requires a count (0 = forever)")?;
2200 loops = Some(
2201 value
2202 .parse()
2203 .map_err(|_| format!("invalid loop count {value:?}"))?,
2204 );
2205 }
2206 "-u" | "--rule" => set_mode(&mut mode, Mode::Rule)?,
2207 "-l" | "--left" => justify = Some(Justify::Left),
2208 "-r" | "--right" if justify != Some(Justify::Left) => justify = Some(Justify::Right),
2209 "-r" | "--right" => {}
2210 "-c" | "--center" if justify.is_none() => justify = Some(Justify::Center),
2211 "-c" | "--center" => {}
2212 "-L" | "--text-left" => text_flags[0] = true,
2213 "-R" | "--text-right" => text_flags[1] = true,
2214 "-C" | "--text-center" => text_flags[2] = true,
2215 "-F" | "--text-full" => text_flags[3] = true,
2216 "-h" | "--head" => source.head = Some(parse_lines(arg, iter.next())?),
2217 "-t" | "--tail" => source.tail = Some(parse_lines(arg, iter.next())?),
2218 "-n" | "--line-numbers" => source.line_numbers = true,
2219 "-g" | "--guides" => source.guides = true,
2220 "--lexer" => {
2221 source.lexer = Some(iter.next().ok_or("--lexer requires a LEXER")?.clone());
2222 }
2223 "--no-wrap" => source.no_wrap = true,
2224 "--infer" => source.infer = true,
2225 "--emoji" => emoji = true,
2226 "--soft" => soft = true,
2227 "-W" | "--max-width" => {
2228 let value = iter.next().ok_or("--max-width requires a SIZE")?;
2229 let size: i64 = value
2231 .parse()
2232 .map_err(|_| format!("invalid --max-width '{value}'"))?;
2233 max_width = usize::try_from(size).ok().filter(|size| *size > 0);
2234 }
2235 "--rule-style" => {
2236 let value = iter.next().ok_or("--rule-style requires a STYLE")?;
2237 rule_style = Some(
2238 Style::parse(value).map_err(|e| format!("unable to parse rule style: {e}"))?,
2239 );
2240 }
2241 "--rule-char" => {
2242 rule_char = Some(
2243 iter.next()
2244 .ok_or("--rule-char requires a CHARACTER")?
2245 .clone(),
2246 );
2247 }
2248 "--force-terminal" => force_terminal = true,
2249 "--no-color" => no_color = true,
2250 "--pager" => {
2251 pager = true;
2252 auto_pager = false;
2253 }
2254 "--auto-pager" => {
2255 auto_pager = true;
2256 pager = false;
2257 }
2258 "--no-pager" => {
2259 pager = false;
2260 auto_pager = false;
2261 }
2262 "--no-auto-pager" => auto_pager = false,
2263 "--color" => no_color = false,
2264 "--no-watch" => watch = false,
2265 "--no-watch-cache" => watch_cache = false,
2266 "--no-watch-poll" => watch_poll = false,
2267 "--no-watch-exit-on-error" => watch_exit_on_error = false,
2268 "--no-batch" => batch = false,
2269 "--no-continue-on-error" => continue_on_error = false,
2270 "--no-overwrite" => {
2271 overwrite = false;
2272 collision = CollisionPolicy::Error;
2273 }
2274 "--no-sanitize" => sanitize = Some(false),
2275 "--dry-run" => dry_run = true,
2276 "--batch-preserve-dirs" => batch_paths.preserve_dirs = true,
2277 "--no-batch-preserve-dirs" => batch_paths.preserve_dirs = false,
2278 "--batch-input-root" => {
2279 batch_paths.input_root = Some(fs_path(
2280 iter.next().ok_or("--batch-input-root requires a path")?,
2281 ))
2282 }
2283 "--batch-name-template" => {
2284 batch_paths.template = Some(
2285 batch_paths::FilenameTemplate::parse(
2286 iter.next()
2287 .ok_or("--batch-name-template requires a template")?,
2288 )
2289 .map_err(|e| e.to_string())?,
2290 )
2291 }
2292 "--watch" => watch = true,
2293 "--watch-cache" => watch_cache = true,
2294 "--watch-poll" => watch_poll = true,
2295 "--watch-exit-on-error" => watch_exit_on_error = true,
2296 "--watch-debounce" => {
2297 let value = iter
2298 .next()
2299 .ok_or("--watch-debounce requires seconds (0 or more)")?;
2300 let seconds = value
2301 .parse::<f64>()
2302 .map_err(|_| format!("invalid watch debounce {value:?}"))?;
2303 if !seconds.is_finite() || !(0.0..=3600.0).contains(&seconds) {
2304 return Err("--watch-debounce must be between 0 and 3600 seconds".into());
2305 }
2306 watch_debounce = Some(seconds);
2307 }
2308 "--watch-interval" | "--interval" => {
2309 let value = iter
2310 .next()
2311 .ok_or("--watch-interval requires seconds greater than zero")?;
2312 watch_interval = value
2313 .parse::<f64>()
2314 .map_err(|_| format!("invalid watch interval {value:?}"))?;
2315 if !watch_interval.is_finite() || watch_interval <= 0.0 {
2316 return Err("--watch-interval must be greater than zero".into());
2317 }
2318 }
2319 "--batch" => batch = true,
2320 "--continue-on-error" => continue_on_error = true,
2321 "--overwrite" => {
2322 overwrite = true;
2323 collision = CollisionPolicy::Overwrite;
2324 }
2325 "--collision" => {
2326 collision = match iter
2327 .next()
2328 .ok_or("--collision requires error, overwrite, or suffix")?
2329 .as_str()
2330 {
2331 "error" => CollisionPolicy::Error,
2332 "overwrite" => CollisionPolicy::Overwrite,
2333 "suffix" => CollisionPolicy::Suffix,
2334 other => return Err(format!("unknown collision policy {other:?}")),
2335 };
2336 overwrite = collision == CollisionPolicy::Overwrite;
2337 }
2338 "--jobs" => {
2339 jobs = iter
2340 .next()
2341 .ok_or("--jobs requires a positive number")?
2342 .parse()
2343 .map_err(|_| "--jobs requires a positive number".to_string())?;
2344 if jobs == 0 {
2345 return Err("--jobs must be at least 1".into());
2346 }
2347 }
2348 "--sanitize" => sanitize = Some(true),
2349 "-y" | "--hyperlinks" => hyperlinks = true,
2353 "--export-html" | "-o" => {
2357 export_html = Some(iter.next().ok_or("--export-html requires a PATH")?.clone());
2358 }
2359 "--export-svg" => {
2360 export_svg = Some(iter.next().ok_or("--export-svg requires a PATH")?.clone());
2361 }
2362 "-a" | "--panel" => {
2363 let value = iter.next().ok_or("--panel requires a box name")?;
2364 panel = parse_box(value)?;
2365 }
2366 "-d" | "--padding" => {
2367 let value = iter.next().ok_or("--padding requires a value")?;
2368 padding = Some(parse_padding(value)?);
2369 }
2370 "-e" | "--expand" => expand = true,
2371 "--title" => {
2372 title = Some(iter.next().ok_or("--title requires a value")?.clone());
2373 }
2374 "--caption" => {
2375 caption = Some(iter.next().ok_or("--caption requires a value")?.clone());
2376 }
2377 "-s" | "--style" => {
2382 let value = iter.next().ok_or("--style requires a value")?;
2383 style = Some(Style::parse(value).map_err(|e| format!("invalid --style: {e}"))?);
2384 }
2385 "-S" | "--panel-style" => {
2386 let value = iter.next().ok_or("--panel-style requires a value")?;
2387 panel_style =
2388 Some(Style::parse(value).map_err(|e| format!("invalid --panel-style: {e}"))?);
2389 }
2390 "-w" | "--width" => {
2391 let value = iter.next().ok_or("--width requires a number")?;
2392 let columns: usize = value.parse().map_err(|_| {
2393 if std::path::Path::new(value).exists() {
2397 format!("--width needs a number, but got the file '{value}' — a value is missing")
2398 } else {
2399 format!("invalid width '{value}'")
2400 }
2401 })?;
2402 if columns == 0 {
2403 return Err("--width must be at least 1".into());
2404 }
2405 width = Some(columns);
2406 }
2407 other if other.starts_with('-') && other != "-" => {
2408 return Err(format!("unknown option {other:?}"));
2409 }
2410 other
2411 if mode == Mode::Auto && resources.is_empty() && command_mode(other).is_some() =>
2412 {
2413 ansi_word = other == "ansi";
2414 set_mode(&mut mode, command_mode(other).expect("checked above"))?;
2415 }
2416 "explain" if ansi_word && resources.is_empty() => ansi_word = false,
2417 other => resources.push(other.to_string()),
2418 }
2419 }
2420
2421 if data.format.is_some() {
2424 if !matches!(mode, Mode::Auto | Mode::Inspect) {
2425 return Err(format!(
2426 "--format cannot be combined with --{}",
2427 mode_name(mode)
2428 ));
2429 }
2430 if mode == Mode::Auto && resources.is_empty() && !batch {
2431 resources.push("-".into());
2432 }
2433 }
2434 if let Some(flag) = data
2435 .inspect_only_option(mode == Mode::Capture)
2436 .filter(|_| mode != Mode::Inspect)
2437 {
2438 if flag == "--redact" {
2439 return Err("--redact only has an effect with --inspect or `rich capture`".into());
2440 }
2441 return Err(format!("{flag} only has an effect with --inspect"));
2442 }
2443 if let Some(flag) = data.transform_option() {
2444 if !transforms_apply(mode) {
2445 return Err(format!(
2446 "{flag} only has an effect on {}",
2447 transform_modes(flag)
2448 ));
2449 }
2450 data.check_transforms(mode == Mode::Inspect)?;
2451 }
2452 if let Some(flag) = tool_options.diff_option().filter(|_| mode != Mode::Diff) {
2453 return Err(format!("{flag} only has an effect with --diff"));
2454 }
2455 if let Some(flag) = tool_options
2456 .ansi_option()
2457 .filter(|_| mode != Mode::AnsiExplain)
2458 {
2459 return Err(format!("{flag} only has an effect with --ansi-explain"));
2460 }
2461 for (flag, commands) in viewer_options
2462 .given()
2463 .into_iter()
2464 .chain(graph_sources.given())
2465 .chain(chart.given())
2466 .chain(schema_options.given())
2467 .chain(profile_options.given())
2468 {
2469 if !commands.contains(&mode_name(mode)) {
2470 return Err(format!(
2471 "{flag} only has an effect with `rich {}`",
2472 commands.join("` or `rich ")
2473 ));
2474 }
2475 }
2476 if mode == Mode::Capture && resources.is_empty() {
2477 return Err("capture needs a command: rich capture [OPTIONS] -- COMMAND [ARGS...]".into());
2478 }
2479 if mode == Mode::View && !paging_explicit {
2480 auto_pager = true;
2481 }
2482 data.validate()?;
2483
2484 if watch && (batch || pager || auto_pager) {
2485 return Err("--watch cannot be combined with --batch or paging".into());
2486 }
2487
2488 if batch {
2492 if mode.accepts_multiple_resources() {
2493 return Err(format!(
2494 "--batch cannot be combined with {}",
2495 if mode == Mode::Diff {
2496 "--diff"
2497 } else {
2498 "--gif"
2499 }
2500 ));
2501 }
2502 if resources.is_empty() {
2503 return Err("--batch needs at least one file, directory or glob".into());
2504 }
2505 if pager || auto_pager {
2507 return Err("--pager cannot be combined with --batch".into());
2508 }
2509 }
2510
2511 if mode == Mode::Diff && !(1..=2).contains(&resources.len()) {
2514 return Err(
2515 "--diff needs two images or files to compare, or one patch: --diff before after".into(),
2516 );
2517 }
2518 if mode == Mode::Diff && tool_options.conflicts() {
2521 if resources.len() != 1 {
2522 return Err("--conflicts needs exactly one file: rich diff --conflicts FILE".into());
2523 }
2524 if diff_threshold.is_some() {
2525 return Err("--threshold cannot be combined with --conflicts".into());
2526 }
2527 }
2528 if mode == Mode::Image {
2529 if resources.is_empty() {
2530 return Err(
2531 "--image requires an image file: rich --image photo.png (or `-` for stdin)".into(),
2532 );
2533 }
2534 if image_mode == ImageMode::None {
2535 return Err(
2536 "--image-mode none has no effect with --image; use auto, ascii, blocks, or sixel"
2537 .into(),
2538 );
2539 }
2540 }
2541 let quantized = matches!(
2542 image_color.as_deref(),
2543 Some("ansi256" | "ansi16" | "grayscale")
2544 );
2545 if quantized && image_mode == ImageMode::Braille {
2546 return Err(format!(
2547 "--image-color {} has no effect with --image-mode braille, which is monochrome",
2548 image_color.as_deref().unwrap()
2549 ));
2550 }
2551 for (flag, value, neutral) in [
2552 ("--image-dither", image_dither.as_deref(), "none"),
2553 (
2554 "--image-color-distance",
2555 image_color_distance.as_deref(),
2556 "rgb",
2557 ),
2558 ] {
2559 if let Some(value) = value.filter(|v| *v != neutral && !quantized) {
2560 return Err(format!(
2561 "{flag} {value} requires --image-color ansi256, ansi16 or grayscale"
2562 ));
2563 }
2564 }
2565 if image_anchor.is_some() && image_fit.as_deref() != Some("cover") {
2566 return Err("--image-anchor requires --image-fit cover".into());
2567 }
2568 if (batch_paths.preserve_dirs
2569 || batch_paths.template.is_some()
2570 || batch_paths.input_root.is_some())
2571 && !batch
2572 {
2573 return Err("batch path options require --batch".into());
2574 }
2575 if batch_paths.preserve_dirs && batch_paths.input_root.is_none() {
2576 return Err("--batch-preserve-dirs requires --batch-input-root".into());
2577 }
2578 if dry_run && !batch {
2579 return Err("--dry-run requires --batch".into());
2580 }
2581 if jobs > 1 && (!batch || (export_html.is_none() && export_svg.is_none())) {
2582 return Err("--jobs greater than 1 requires --batch with a file export".into());
2583 }
2584 if image_fit.as_deref().is_some_and(|fit| fit != "native") && height.is_none() {
2586 return Err("--image-fit requires --height to define the target rectangle".into());
2587 }
2588 let effective_mode = if mode == Mode::Auto {
2593 detect_mode(resources.first().map(String::as_str))
2594 } else {
2595 mode
2596 };
2597 extensions.validate(
2598 effective_mode == Mode::Gif,
2599 effective_mode.allows_extensions()
2600 && !(mode == Mode::Auto && resources.is_empty())
2601 && !(mode == Mode::Print && resources.first().is_some_and(|r| r != "-" && !is_url(r))),
2602 )?;
2603 let exporting = export_html.is_some() || export_svg.is_some();
2604 if exporting {
2605 let unsupported = if effective_mode == Mode::Gif {
2606 Some("--gif")
2607 } else if mode == Mode::Auto && resources.is_empty() {
2608 Some("the capability demo")
2609 } else {
2610 None
2611 };
2612 if let Some(what) = unsupported {
2613 return Err(format!("--export-html/--export-svg cannot capture {what}"));
2614 }
2615 }
2616
2617 let text_justify = [
2623 Justify::Left,
2624 Justify::Right,
2625 Justify::Center,
2626 Justify::Full,
2627 ]
2628 .into_iter()
2629 .zip(text_flags)
2630 .find_map(|(justify, given)| given.then_some(justify));
2631 let source_mode = matches!(effective_mode, Mode::Syntax | Mode::Ipynb | Mode::Auto);
2634 let print_mode = matches!(effective_mode, Mode::Print | Mode::Rule);
2635 let orphans = [
2636 (
2637 "--head/--tail",
2638 source.head.is_some() || source.tail.is_some(),
2639 "--syntax, --csv or --ipynb",
2640 source_mode || effective_mode == Mode::Csv,
2641 ),
2642 (
2643 "--infer",
2644 source.infer,
2645 "--csv",
2646 effective_mode == Mode::Csv,
2647 ),
2648 (
2649 "--line-numbers",
2650 source.line_numbers,
2651 "--syntax or --ipynb",
2652 source_mode,
2653 ),
2654 (
2655 "--guides",
2656 source.guides,
2657 "--syntax or --ipynb",
2658 source_mode,
2659 ),
2660 (
2661 "--lexer",
2662 source.lexer.is_some(),
2663 "--syntax, --ipynb or --rst",
2664 source_mode || effective_mode == Mode::Rst,
2665 ),
2666 (
2667 "--no-wrap",
2668 source.no_wrap,
2669 "--syntax, --ipynb, --print or --rule",
2670 source_mode || print_mode,
2671 ),
2672 (
2673 "--text-left/--text-right/--text-center/--text-full",
2674 text_justify.is_some(),
2675 "--print or --rule",
2676 print_mode,
2677 ),
2678 (
2679 "--rule-style",
2680 rule_style.is_some(),
2681 "--rule",
2682 effective_mode == Mode::Rule,
2683 ),
2684 (
2685 "--rule-char",
2686 rule_char.is_some(),
2687 "--rule",
2688 effective_mode == Mode::Rule,
2689 ),
2690 (
2693 "--soft",
2694 soft,
2695 "a mode other than --jsonl or --log",
2696 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2697 ),
2698 (
2699 "--max-width",
2700 max_width.is_some(),
2701 "a mode other than --jsonl or --log",
2702 !matches!(effective_mode, Mode::JsonLines | Mode::Log),
2703 ),
2704 (
2705 "--threshold",
2706 diff_threshold.is_some(),
2707 "--diff",
2708 mode == Mode::Diff,
2709 ),
2710 (
2711 "--image-mode",
2712 image_mode != ImageMode::Auto,
2713 "--diff/--image",
2714 mode == Mode::Diff || mode == Mode::Image,
2715 ),
2716 ("--height", height.is_some(), "--image", mode == Mode::Image),
2717 (
2718 "--image-rotate",
2719 image_rotate.is_some(),
2720 "--image",
2721 mode == Mode::Image,
2722 ),
2723 (
2724 "--image-flip-horizontal",
2725 image_flip_horizontal,
2726 "--image",
2727 mode == Mode::Image,
2728 ),
2729 (
2730 "--image-flip-vertical",
2731 image_flip_vertical,
2732 "--image",
2733 mode == Mode::Image,
2734 ),
2735 (
2736 "--image-grayscale",
2737 image_grayscale,
2738 "--image",
2739 mode == Mode::Image,
2740 ),
2741 (
2742 "--image-color",
2743 image_color.is_some(),
2744 "--image or --gif",
2745 mode == Mode::Image || effective_mode == Mode::Gif,
2746 ),
2747 (
2748 "--image-max-width",
2749 image_max_width.is_some(),
2750 "--image",
2751 mode == Mode::Image,
2752 ),
2753 (
2754 "--image-max-height",
2755 image_max_height.is_some(),
2756 "--image",
2757 mode == Mode::Image,
2758 ),
2759 (
2760 "--image-brightness",
2761 image_brightness.is_some(),
2762 "--image",
2763 mode == Mode::Image,
2764 ),
2765 (
2766 "--image-contrast",
2767 image_contrast.is_some(),
2768 "--image",
2769 mode == Mode::Image,
2770 ),
2771 (
2772 "--image-gamma",
2773 image_gamma.is_some(),
2774 "--image",
2775 mode == Mode::Image,
2776 ),
2777 (
2778 "--image-dither",
2779 image_dither.is_some(),
2780 "--image or --gif",
2781 mode == Mode::Image || effective_mode == Mode::Gif,
2782 ),
2783 (
2784 "--image-color-distance",
2785 image_color_distance.is_some(),
2786 "--image or --gif",
2787 mode == Mode::Image || effective_mode == Mode::Gif,
2788 ),
2789 (
2790 "--image-fit",
2791 image_fit.is_some(),
2792 "--image",
2793 mode == Mode::Image,
2794 ),
2795 (
2796 "--image-background",
2797 image_background.is_some(),
2798 "--image",
2799 mode == Mode::Image,
2800 ),
2801 (
2802 "--loop",
2803 loops.is_some(),
2804 "--gif",
2805 effective_mode == Mode::Gif,
2806 ),
2807 (
2812 "--panel-style",
2813 panel_style.is_some(),
2814 "--panel",
2815 panel.is_some(),
2816 ),
2817 (
2818 "--expand",
2819 expand,
2820 "--panel/--padding",
2821 panel.is_some() || padding.is_some(),
2822 ),
2823 ("--watch-interval", watch_interval != 1.0, "--watch", watch),
2824 ("--watch-cache", watch_cache, "--watch", watch),
2825 (
2826 "--watch-debounce",
2827 watch_debounce.is_some(),
2828 "--watch",
2829 watch,
2830 ),
2831 ("--watch-poll", watch_poll, "--watch", watch),
2832 (
2833 "--watch-exit-on-error",
2834 watch_exit_on_error,
2835 "--watch",
2836 watch,
2837 ),
2838 ];
2839 if let Some((flag, _, needs, _)) = orphans
2840 .iter()
2841 .find(|(_, given, _, mode_present)| *given && !*mode_present)
2842 {
2843 return Err(format!("{flag} only has an effect with {needs}"));
2844 }
2845
2846 let demo = mode == Mode::Auto && resources.is_empty();
2849 if effective_mode.draws_directly() || demo {
2850 let mode_name = if effective_mode == Mode::Gif {
2851 "--gif"
2852 } else if effective_mode == Mode::Image {
2853 "--image"
2854 } else if effective_mode.streams_records() {
2855 mode_name(effective_mode)
2856 } else if demo {
2857 "the capability demo"
2858 } else {
2859 "--diff"
2860 };
2861 let unsupported = [
2862 (
2863 "--pager",
2864 (pager || auto_pager)
2865 && (effective_mode == Mode::Gif || effective_mode.streams_records() || demo),
2866 ),
2867 (
2868 "--export-html/--export-svg",
2869 (export_html.is_some() || export_svg.is_some()) && effective_mode.streams_records(),
2870 ),
2871 ("--width", demo && width.is_some()),
2872 ("--theme-style", demo && !theme_styles.is_empty()),
2873 ("--theme-file", demo && !theme_file_styles.is_empty()),
2874 (
2875 "--hyperlinks",
2876 hyperlinks && (effective_mode.streams_records() || demo),
2877 ),
2878 ("--panel", panel.is_some()),
2879 ("--padding", padding.is_some()),
2880 ("--title", title.is_some()),
2881 ("--caption", caption.is_some()),
2882 ("--style", style.is_some()),
2883 ("--panel-style", panel_style.is_some()),
2884 ("--expand", expand),
2885 ("--left/--center/--right", justify.is_some()),
2886 ];
2887 if let Some((flag, _)) = unsupported.iter().find(|(_, given)| *given) {
2888 return Err(format!("{flag} cannot be combined with {mode_name}"));
2889 }
2890 }
2891 if !mode.accepts_multiple_resources() && resources.len() > 1 && !batch && !watch {
2894 return Err("only one resource may be given (except with --gif or --watch)".into());
2895 }
2896 let sanitize = sanitize.unwrap_or_else(|| {
2901 mode == Mode::View
2902 || mode == Mode::Schema
2903 || (mode == Mode::Diff
2904 && !(resources.len() == 2 && resources.iter().all(|r| looks_like_image(r))))
2905 });
2906 let resource = resources.first().map(|resource| {
2908 if print_mode {
2909 text_arg(resource)
2910 } else {
2911 resource.clone()
2912 }
2913 });
2914 let title = title.map(|title| text_arg(&title));
2915 let caption = caption.map(|caption| text_arg(&caption));
2916 let rule_char = rule_char.map(|characters| text_arg(&characters));
2917
2918 code_highlighting(highlighter.as_deref(), code_theme.as_deref())?;
2920 Ok(Some(Cli {
2921 mode,
2922 resource,
2923 resources,
2924 loops,
2925 diff_threshold,
2926 image_mode,
2927 image_fit,
2928 image_anchor,
2929 image_background,
2930 image_color,
2931 image_dither,
2932 image_color_distance,
2933 image_rotate,
2934 image_flip_horizontal,
2935 image_flip_vertical,
2936 image_grayscale,
2937 image_max_width,
2938 image_max_height,
2939 image_brightness,
2940 image_contrast,
2941 image_gamma,
2942 log_presentation,
2943 mermaid_backend,
2944 dot_backend,
2945 graph_sources,
2946 chart,
2947 schema: schema_options,
2948 profile: profile_options,
2949 highlighter,
2950 code_theme,
2951 plugins,
2952 micro_project,
2953 theme_styles,
2954 theme_file_styles,
2955 height,
2956 extensions,
2957 width,
2958 justify,
2959 text_justify,
2960 emoji,
2961 soft,
2962 max_width,
2963 rule_style,
2964 rule_char,
2965 force_terminal,
2966 source,
2967 no_color,
2968 export_html,
2969 export_svg,
2970 panel,
2971 padding,
2972 title,
2973 caption,
2974 panel_style,
2975 style,
2976 expand,
2977 pager,
2978 auto_pager,
2979 hyperlinks,
2980 sanitize,
2981 report_format,
2982 watch,
2983 watch_interval,
2984 watch_cache,
2985 watch_debounce: watch_debounce.unwrap_or(watch::DEFAULT_DEBOUNCE_SECONDS),
2986 watch_poll,
2987 watch_exit_on_error,
2988 batch,
2989 progress,
2990 continue_on_error,
2991 jobs,
2992 dry_run,
2993 batch_paths,
2994 worker_args: worker_args(args),
2995 overwrite,
2996 collision,
2997 data,
2998 tools: tool_options,
2999 viewers: viewer_options,
3000 }))
3001}
3002
3003fn read_window(cli: &Cli, offset: u64, max: u64) -> Result<(Vec<u8>, bool), String> {
3013 use std::io::{Seek, SeekFrom};
3014 let resource = cli.resource.as_deref();
3015 let name = resource.unwrap_or("<stdin>");
3016 let window = |bytes: Vec<u8>| {
3017 let start = usize::try_from(offset)
3018 .unwrap_or(usize::MAX)
3019 .min(bytes.len());
3020 let mut rest = bytes[start..].to_vec();
3021 let more = rest.len() as u64 > max;
3022 rest.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3023 (rest, more)
3024 };
3025 if let Some(url) = resource.filter(|r| is_url(r)) {
3026 return fetch_url(url, cli.extensions.encoding).map(|(text, _)| window(text.into_bytes()));
3027 }
3028 if cli.extensions.encoding.is_some() {
3029 return read_resource_limited(
3030 resource,
3031 cli.extensions.encoding,
3032 Some(controls::INSPECT_LIMIT),
3033 )
3034 .map(|text| window(text.into_bytes()))
3035 .map_err(|err| format!("cannot read {name}: {err}"));
3036 }
3037 let error = |err: std::io::Error| format!("cannot read {name}: {err}");
3038 let take = max.saturating_add(1);
3040 let mut bytes = Vec::new();
3041 match resource.filter(|r| *r != "-") {
3042 Some(path) if fs_path(path).is_dir() => {
3043 return Err(format!("cannot read {path}: it is a directory"));
3044 }
3045 Some(path) => {
3046 let mut file = std::fs::File::open(fs_path(path)).map_err(error)?;
3047 if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() {
3049 std::io::copy(&mut (&mut file).take(offset), &mut std::io::sink())
3050 .map_err(error)?;
3051 }
3052 file.take(take).read_to_end(&mut bytes).map_err(error)?;
3053 }
3054 None => {
3055 let mut stdin = std::io::stdin().lock();
3056 std::io::copy(&mut (&mut stdin).take(offset), &mut std::io::sink()).map_err(error)?;
3057 stdin.take(take).read_to_end(&mut bytes).map_err(error)?;
3058 }
3059 }
3060 let more = bytes.len() as u64 > max;
3061 bytes.truncate(usize::try_from(max).unwrap_or(usize::MAX));
3062 Ok((bytes, more))
3063}
3064
3065fn read_limited(reader: impl Read, limit: Option<u64>) -> std::io::Result<Vec<u8>> {
3067 let mut bytes = Vec::new();
3068 match limit {
3069 None => {
3070 let mut reader = reader;
3071 reader.read_to_end(&mut bytes)?;
3072 }
3073 Some(limit) => {
3074 reader
3075 .take(limit.saturating_add(1))
3076 .read_to_end(&mut bytes)?;
3077 if bytes.len() as u64 > limit {
3078 return Err(std::io::Error::other(format!(
3079 "larger than {}, the most this command reads",
3080 controls::size(limit)
3081 )));
3082 }
3083 }
3084 }
3085 Ok(bytes)
3086}
3087
3088fn read_resource(resource: Option<&str>, encoding: Option<Encoding>) -> std::io::Result<String> {
3101 read_resource_limited(resource, encoding, None)
3102}
3103
3104fn read_resource_limited(
3108 resource: Option<&str>,
3109 encoding: Option<Encoding>,
3110 limit: Option<u64>,
3111) -> std::io::Result<String> {
3112 let content = match resource {
3113 Some(path) if path != "-" => {
3114 let file = &fs_path(path);
3115 if file.is_dir() {
3118 return Err(std::io::Error::new(
3119 std::io::ErrorKind::InvalidInput,
3120 "is a directory, not a file",
3121 ));
3122 }
3123 let bytes = match limit {
3124 None => std::fs::read(file)?,
3125 Some(_) => read_limited(std::fs::File::open(file)?, limit)?,
3126 };
3127 if let Some(encoding) = encoding {
3128 return encoding.decode(&bytes).map(normalize_newlines);
3129 }
3130 if has_utf16_bom(&bytes) {
3131 eprintln!("rich: {} has a UTF-16 BOM; use --encoding utf-16 to decode it (default remains UTF-8 replacement)", controls::shown(path));
3132 }
3133 match String::from_utf8(bytes) {
3134 Ok(text) => text,
3135 Err(_) if looks_like_image(path) => {
3140 return Err(std::io::Error::new(
3141 std::io::ErrorKind::InvalidData,
3142 "looks like an image; use `rich --image <path>` to view it, or \
3143 `rich --diff <before> <after>` to compare two images",
3144 ))
3145 }
3146 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
3147 }
3148 }
3149 _ => {
3150 if std::io::stdin().is_terminal() {
3151 let eof = if cfg!(windows) {
3152 "Ctrl-Z then Enter"
3153 } else {
3154 "Ctrl-D"
3155 };
3156 eprintln!("rich: reading stdin; finish input with {eof}");
3157 }
3158 let buffer = read_limited(std::io::stdin(), limit)?;
3159 decode_strict(buffer, encoding)?
3160 }
3161 };
3162 Ok(normalize_newlines(if encoding.is_some() {
3163 content
3164 } else {
3165 strip_bom(content)
3166 }))
3167}
3168
3169fn sanitize_json_value(value: &mut serde_json::Value) {
3170 match value {
3171 serde_json::Value::String(text) => *text = sanitize_terminal_controls(text),
3172 serde_json::Value::Array(values) => values.iter_mut().for_each(sanitize_json_value),
3173 serde_json::Value::Object(values) => values.values_mut().for_each(sanitize_json_value),
3174 _ => {}
3175 }
3176}
3177
3178fn sanitize_json_source(content: &str) -> Result<String, serde_json::Error> {
3179 let mut value = serde_json::from_str::<serde_json::Value>(content)?;
3180 sanitize_json_value(&mut value);
3181 serde_json::to_string(&value)
3182}
3183
3184fn decode_strict(bytes: Vec<u8>, encoding: Option<Encoding>) -> std::io::Result<String> {
3186 if let Some(encoding) = encoding {
3187 return encoding.decode(&bytes);
3188 }
3189 String::from_utf8(bytes).map_err(|err| std::io::Error::new(
3190 std::io::ErrorKind::InvalidData,
3191 if has_utf16_bom(err.as_bytes()) {
3192 "input has a UTF-16 BOM; use --encoding utf-16"
3193 } else {
3194 "input is not valid UTF-8 text; for known headerless UTF-16 select --encoding utf-16le or utf-16be"
3195 },
3196 ))
3197}
3198
3199fn normalize_newlines(text: String) -> String {
3208 const CR: char = '\r';
3209 const LF: char = '\n';
3210 if !text.contains(CR) {
3211 return text;
3212 }
3213 let mut out = String::with_capacity(text.len());
3214 let mut chars = text.chars().peekable();
3215 while let Some(c) = chars.next() {
3216 if c == CR {
3217 if chars.peek() == Some(&LF) {
3220 chars.next();
3221 }
3222 out.push(LF);
3223 } else {
3224 out.push(c);
3225 }
3226 }
3227 out
3228}
3229
3230fn looks_like_image(path: &str) -> bool {
3240 std::path::Path::new(path)
3241 .extension()
3242 .and_then(|e| e.to_str())
3243 .map(|e| e.to_ascii_lowercase())
3244 .is_some_and(|e| {
3245 matches!(
3246 e.as_str(),
3247 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tif" | "tiff" | "ico" | "avif"
3248 )
3249 })
3250}
3251
3252fn strip_bom(text: String) -> String {
3253 match text.strip_prefix('\u{feff}') {
3254 Some(rest) => rest.to_string(),
3255 None => text,
3256 }
3257}
3258
3259fn is_url(resource: &str) -> bool {
3262 let head: String = resource.chars().take(8).collect::<String>().to_lowercase();
3263 head.starts_with("http://") || head.starts_with("https://")
3264}
3265
3266fn resource_ext(resource: &str) -> Option<String> {
3270 let basename = resource.rsplit(['/', '\\']).next().unwrap_or(resource);
3271 let basename = basename.split(['?', '#']).next().unwrap_or(basename);
3272 basename
3273 .rsplit_once('.')
3274 .map(|(_, ext)| ext.to_ascii_lowercase())
3275 .filter(|ext| !ext.is_empty())
3276}
3277
3278#[cfg(feature = "fetch")]
3281const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
3282
3283#[cfg(feature = "fetch")]
3291fn fetch_url(url: &str, encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3292 use std::time::Duration;
3293 let mut response = ureq::get(url)
3294 .config()
3295 .timeout_global(Some(Duration::from_secs(30)))
3296 .http_status_as_error(false)
3299 .build()
3300 .call()
3301 .map_err(|err| format!("cannot fetch {url}: {err}"))?;
3302 let content_type = response
3303 .headers()
3304 .get("content-type")
3305 .and_then(|value| value.to_str().ok())
3306 .map(str::to_string);
3307
3308 let mut buffer = Vec::new();
3314 response
3315 .body_mut()
3316 .with_config()
3317 .reader()
3318 .take(MAX_BODY_BYTES + 1)
3319 .read_to_end(&mut buffer)
3320 .map_err(|err| format!("cannot read {url}: {err}"))?;
3321 if buffer.len() as u64 > MAX_BODY_BYTES {
3322 return Err(format!(
3323 "{url} exceeds the {} MiB limit",
3324 MAX_BODY_BYTES / (1024 * 1024)
3325 ));
3326 }
3327 let body =
3328 decode_strict(buffer, encoding).map_err(|err| format!("cannot decode {url}: {err}"))?;
3329 Ok((body, content_type))
3330}
3331
3332#[cfg(not(feature = "fetch"))]
3334fn fetch_url(url: &str, _encoding: Option<Encoding>) -> Result<(String, Option<String>), String> {
3335 Err(format!(
3336 "cannot fetch {url}: this build has no URL support (rebuild with the `fetch` feature)"
3337 ))
3338}
3339
3340fn content_type_mode(content_type: &str) -> Option<Mode> {
3344 let mime = mime_of(content_type);
3345 match mime.as_str() {
3346 "text/markdown" | "text/x-markdown" => Some(Mode::Markdown),
3347 "text/x-rst" | "text/prs.fallenstein.rst" => Some(Mode::Rst),
3348 "application/json" | "text/json" => Some(Mode::Json),
3349 "text/csv" => Some(Mode::Csv),
3350 _ => None,
3351 }
3352}
3353
3354fn content_type_lexer(content_type: &str) -> Option<&'static str> {
3358 let mime = mime_of(content_type);
3359 Some(match mime.as_str() {
3360 "text/html" | "application/xhtml+xml" => "html",
3361 "text/css" => "css",
3362 "application/javascript" | "text/javascript" => "javascript",
3363 "application/xml" | "text/xml" => "xml",
3364 "text/x-python" | "application/x-python" | "text/x-python3" => "python",
3365 "text/x-rust" => "rust",
3366 "text/x-c" | "text/x-csrc" => "c",
3367 "application/x-sh" | "text/x-shellscript" => "bash",
3368 "application/x-yaml" | "text/yaml" | "text/x-yaml" => "yaml",
3369 "application/toml" | "text/x-toml" => "toml",
3370 _ => return None,
3371 })
3372}
3373
3374fn is_uninformative_ext(ext: &str) -> bool {
3377 matches!(ext, "txt" | "text" | "log" | "dat" | "out")
3378}
3379
3380fn mime_of(content_type: &str) -> String {
3383 content_type
3384 .split(';')
3385 .next()
3386 .unwrap_or("")
3387 .trim()
3388 .to_ascii_lowercase()
3389}
3390
3391fn transforms_apply(mode: Mode) -> bool {
3395 matches!(
3396 mode,
3397 Mode::Auto | Mode::Print | Mode::Syntax | Mode::Inspect
3398 )
3399}
3400
3401fn transform_misplaced_in(cli: &Cli, mode: Mode) -> Option<String> {
3405 if let Some(flag) = cli
3406 .data
3407 .transform_option()
3408 .filter(|_| !transforms_apply(mode))
3409 {
3410 return Some(transforms_misplaced(flag, mode));
3411 }
3412 (mode == Mode::Inspect && !cli.data.transforms().is_empty())
3413 .then(|| cli.data.check_transforms(true).unwrap_err())
3414}
3415
3416fn transforms_misplaced(flag: &str, mode: Mode) -> String {
3417 format!(
3418 "{flag} only has an effect on {}, not {}",
3419 transform_modes(flag),
3420 mode_name(mode)
3421 )
3422}
3423
3424fn transform_modes(flag: &str) -> &'static str {
3426 if flag == "--transform" {
3427 "text, --print or --syntax"
3428 } else {
3429 "text, --print, --syntax or --inspect"
3430 }
3431}
3432
3433fn detect_mode(resource: Option<&str>) -> Mode {
3434 match resource.and_then(resource_ext).as_deref() {
3435 Some("md") | Some("markdown") => Mode::Markdown,
3436 Some("json") => Mode::Json,
3437 Some("csv") | Some("tsv") => Mode::Csv,
3438 Some("ipynb") => Mode::Ipynb,
3439 Some("rst") => Mode::Rst,
3440 Some("gif") => Mode::Gif,
3441 Some("mmd") | Some("mermaid") if cfg!(feature = "mermaid") => Mode::Mermaid,
3442 Some("dot") | Some("gv") => Mode::Dot,
3443 Some(_) => Mode::Syntax,
3451 None => Mode::Auto,
3455 }
3456}
3457
3458struct ForceWidth {
3466 child: Box<dyn Renderable>,
3467 width: usize,
3468}
3469
3470impl Renderable for ForceWidth {
3471 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3472 self.child
3473 .rich_render(console, &options.update_width(self.width))
3474 }
3475
3476 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
3477 Measurement::new(self.width, self.width)
3478 }
3479}
3480
3481struct Aligned {
3491 child: Box<dyn Renderable>,
3492 width: usize,
3498 justify: Justify,
3499}
3500
3501impl Renderable for Aligned {
3502 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
3503 let width = self.width.clamp(1, options.max_width);
3504 let lines = console.render_lines(self.child.as_ref(), &options.update_width(width), false);
3514 let widths: Vec<usize> = lines
3515 .iter()
3516 .map(|line| line.iter().map(Segment::cell_length).sum())
3517 .collect();
3518 let shape = widths.iter().copied().max().unwrap_or(0);
3522
3523 let excess = options.max_width.saturating_sub(shape);
3524 let (left, right) = match self.justify {
3525 Justify::Right => (excess, 0),
3526 Justify::Center => (excess / 2, excess - excess / 2),
3527 _ => (0, excess),
3528 };
3529 let blank = |count: usize| Segment::new(" ".repeat(count), Some(Style::new()));
3530
3531 let mut segments = Vec::new();
3532 let last = lines.len().saturating_sub(1);
3533 for (index, (line, line_width)) in lines.into_iter().zip(widths).enumerate() {
3534 if left > 0 {
3535 segments.push(blank(left));
3536 }
3537 segments.extend(line);
3538 let pad = shape - line_width + right;
3539 if pad > 0 {
3540 segments.push(blank(pad));
3541 }
3542 if index != last {
3543 segments.push(Segment::line());
3544 }
3545 }
3546 segments
3547 }
3548}
3549
3550fn measure_rendered(console: &Console, renderable: &dyn Renderable) -> usize {
3562 console
3563 .render_lines(renderable, &console.options(), false)
3564 .iter()
3565 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
3566 .max()
3567 .unwrap_or(0)
3568}
3569
3570fn micro_pending() -> bool {
3575 #[cfg(feature = "art")]
3576 return micro::drawing_pending();
3577 #[cfg(not(feature = "art"))]
3578 false
3579}
3580
3581fn validate_labels(cli: &Cli) -> Result<(), String> {
3582 for label in cli.title.iter().chain(cli.caption.iter()) {
3583 Text::from_markup(label).map_err(|err| err.to_string())?;
3584 }
3585 Ok(())
3586}
3587
3588fn decorate_and_emit(
3594 cli: &Cli,
3595 console: &Console,
3596 export: &Export,
3597 renderable: Box<dyn Renderable>,
3598 fit: Option<usize>,
3599) -> ExitCode {
3600 decorate_and_emit_with(cli, console, export, renderable, fit, emit_exit_code)
3601}
3602
3603fn decorate_and_emit_with(
3606 cli: &Cli,
3607 console: &Console,
3608 export: &Export,
3609 renderable: Box<dyn Renderable>,
3610 fit: Option<usize>,
3611 finish: impl FnOnce(&Cli, Result<(), String>) -> ExitCode,
3612) -> ExitCode {
3613 if cli.panel.is_some() {
3614 if let Err(err) = validate_labels(cli) {
3615 return fail(cli, ExitClass::Data, err);
3616 }
3617 }
3618 let max_width = console.width();
3619 let expand = cli.expand || cli.width.is_some();
3621 let mut renderable = renderable;
3622 let mut fit = fit;
3623
3624 if let Some((top, right, bottom, left)) = cli.padding {
3630 let padded: Box<dyn Renderable> =
3631 Box::new(Padding::new(renderable, (top, right, bottom, left)));
3632 fit = fit.map(|width| (width + left + right).min(max_width));
3635 renderable = if expand {
3636 padded
3637 } else {
3638 Box::new(Constrain::new(padded, fit))
3639 };
3640 }
3641
3642 if let Some(box_set) = cli.panel {
3643 let mut panel = Panel::new(renderable).box_set(box_set);
3644 #[cfg(feature = "art")]
3647 let micro_label = |label: &Option<String>| {
3648 label
3649 .as_deref()
3650 .and_then(|label| micro::panel_label(console, label, cli.micro_project))
3651 };
3652 #[cfg(not(feature = "art"))]
3653 let micro_label = |_: &Option<String>| None::<Text>;
3654 let micro_title = micro_label(&cli.title);
3655 if let Some(title) = micro_title.clone() {
3656 panel = panel.title_as_text(title);
3657 } else if let Some(title) = cli.title.clone() {
3658 panel = panel.title(title);
3659 }
3660 if let Some(caption) = micro_label(&cli.caption) {
3661 panel = panel.subtitle_as_text(caption);
3662 } else if let Some(caption) = cli.caption.clone() {
3663 panel = panel.subtitle(caption);
3664 }
3665 if let Some(style) = cli.panel_style.clone() {
3666 panel = panel.border_style(style);
3667 }
3668 let inner = max_width.saturating_sub(4);
3677 let child = fit.unwrap_or(inner).min(inner);
3678 let title_width = cli
3679 .title
3680 .as_deref()
3681 .map(|title| {
3682 let mut title = match µ_title {
3685 Some(title) => {
3686 let mut flat = title.blank_copy();
3687 flat.append(&title.plain().replace('\n', " "), None);
3688 flat
3689 }
3690 None => {
3691 let title = rich::emoji::replace(title);
3692 Text::from_markup(&title.replace('\n', " "))
3693 .expect("title markup validated before rendering")
3694 }
3695 };
3696 title.expand_tabs(8);
3697 cell_len(title.plain()) + 2
3698 })
3699 .unwrap_or(0);
3700 fit = Some(child.max(title_width) + 4);
3701 let panel: Box<dyn Renderable> = Box::new(panel);
3702 renderable = if expand {
3703 panel
3704 } else {
3705 Box::new(Constrain::new(panel, fit))
3706 };
3707 }
3708
3709 if let Some(style) = cli.style.clone() {
3712 renderable = Box::new(Styled::new(renderable, style));
3713 }
3714
3715 if let Some(width) = cli.width.filter(|_| !cli.pager) {
3717 renderable = Box::new(ForceWidth {
3718 child: renderable,
3719 width,
3720 });
3721 fit = Some(width);
3722 }
3723
3724 if let Some(justify) = cli.justify {
3725 renderable = Box::new(Aligned {
3726 child: renderable,
3727 width: fit.unwrap_or(max_width),
3728 justify,
3729 });
3730 }
3731
3732 #[cfg(feature = "art")]
3737 if let Some(registry) =
3738 micro::take_drawing().filter(|_| export.terminal_only() && !watch::watching())
3739 {
3740 renderable = micro::drawn(console, registry, renderable);
3741 }
3742
3743 let mut options = console.options();
3746 if let Some(max_width) = cli.max_width {
3747 options.max_width = max_width.min(options.max_width);
3748 }
3749 let shown = if cli.soft {
3750 let mut segments = soft_wrap_segments(console, renderable.as_ref(), &options);
3751 if !segments.is_empty() {
3752 segments.push(Segment::line());
3753 }
3754 emit_segments(console, export, segments)
3755 } else {
3756 emit(console, export, |c| {
3757 c.print_with(renderable.as_ref(), &options)
3758 })
3759 };
3760 finish(cli, shown)
3761}
3762
3763fn soft_wrap_segments(
3769 console: &Console,
3770 renderable: &dyn Renderable,
3771 options: &ConsoleOptions,
3772) -> Vec<Segment> {
3773 let mut options = options.clone();
3774 options.no_wrap.get_or_insert(true);
3775 options
3776 .overflow
3777 .get_or_insert(rich::console::Overflow::Ignore);
3778 let joined = renderable.printed_text();
3779 let renderable: &dyn Renderable = match &joined {
3780 Some(text) => text,
3781 None => renderable,
3782 };
3783 if renderable.fit_to_measurement() {
3784 let measurement = renderable.measure(console, &options);
3785 options.max_width = measurement.maximum.min(options.max_width).max(1);
3786 }
3787 console.render(renderable, Some(&options))
3788}
3789
3790fn run_cli(cli: Cli) -> ExitCode {
3791 if cli.watch {
3792 return run_watch(cli);
3793 }
3794 run_once(cli)
3795}
3796
3797fn run_watch(mut cli: Cli) -> ExitCode {
3798 let Some(resource) = cli.resource.clone() else {
3799 return fail(
3800 &cli,
3801 ExitClass::Usage,
3802 "--watch requires a file path or URL",
3803 );
3804 };
3805 let several = cli.resources.len() > 1;
3806 for resource in &cli.resources {
3807 if resource == "-" || (cli.mode == Mode::Print && !is_url(resource)) {
3808 return fail(
3809 &cli,
3810 ExitClass::Usage,
3811 "--watch requires a local file or URL, not stdin or literal markup",
3812 );
3813 }
3814 if several && is_url(resource) {
3815 return fail(
3816 &cli,
3817 ExitClass::Usage,
3818 "--watch with several resources requires local files; watch a URL on its own",
3819 );
3820 }
3821 let effective_mode = if cli.mode == Mode::Auto {
3822 detect_mode(Some(resource))
3823 } else {
3824 cli.mode
3825 };
3826 if effective_mode.draws_directly() || effective_mode == Mode::Rule {
3827 return fail(
3828 &cli,
3829 ExitClass::Usage,
3830 "--watch requires a resource-backed render mode",
3831 );
3832 }
3833 }
3834 if several && (cli.export_html.is_some() || cli.export_svg.is_some()) {
3835 return fail(
3836 &cli,
3837 ExitClass::Usage,
3838 "--export-html/--export-svg cannot be combined with watching several files",
3839 );
3840 }
3841 if !std::io::stdout().is_terminal() {
3845 cli.watch = false;
3846 if !several {
3847 return run_once(cli);
3848 }
3849 let mut first_failure = None;
3850 for resource in cli.resources.clone() {
3851 let mut single = cli.clone();
3852 single.resource = Some(resource.clone());
3853 single.resources = vec![resource];
3854 let status = run_once(single);
3855 if status != ExitClass::Success.exit_code() && first_failure.is_none() {
3856 first_failure = Some(status);
3857 }
3858 }
3859 return first_failure.unwrap_or_else(|| ExitClass::Success.exit_code());
3860 }
3861 watch::start_watching();
3863 let resource = resource.as_str();
3864 if !is_url(resource) {
3865 return watch::watch_files(cli);
3866 }
3867 if is_url(resource) {
3869 #[cfg(not(feature = "fetch"))]
3870 {
3871 cli.watch = false;
3872 return run_once(cli);
3873 }
3874 }
3875
3876 let interval = std::time::Duration::from_secs_f64(cli.watch_interval);
3877 let mut previous = None;
3878 loop {
3879 #[cfg(feature = "fetch")]
3880 let cached = if is_url(resource) && cli.watch_cache {
3881 Some(fetch_url(resource, cli.extensions.encoding))
3882 } else {
3883 None
3884 };
3885 #[cfg(not(feature = "fetch"))]
3886 let cached: Option<Result<(String, Option<String>), String>> = None;
3887 let fingerprint = match cached.as_ref() {
3888 Some(Ok((body, content_type))) => {
3889 use std::hash::{Hash, Hasher};
3890 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3891 body.hash(&mut hasher);
3892 content_type.hash(&mut hasher);
3893 format!("url:{:x}", hasher.finish())
3894 }
3895 Some(Err(error)) => format!("url-error:{error}"),
3896 None => watch_fingerprint(resource, false, cli.extensions.encoding),
3897 };
3898 let changed = previous.as_ref() != Some(&fingerprint);
3899 if changed || (is_url(resource) && !cli.watch_cache) {
3900 previous = Some(fingerprint);
3901 let console = Console::new();
3904 console.clear();
3905 console.control(&Control::home());
3906 let _ = std::io::stdout().flush();
3907 let mut iteration = cli.clone();
3908 iteration.watch = false;
3909 let status = run_once_with_fetch(iteration, cached.and_then(Result::ok));
3910 if status != ExitClass::Success.exit_code() {
3911 eprintln!("rich: watch will retry after the next change");
3914 }
3915 }
3916 std::thread::sleep(interval);
3917 }
3918}
3919
3920fn watch_fingerprint(resource: &str, cache_url: bool, encoding: Option<Encoding>) -> String {
3921 if is_url(resource) {
3922 #[cfg(feature = "fetch")]
3923 if cache_url {
3924 return match fetch_url(resource, encoding) {
3925 Ok((body, content_type)) => {
3926 use std::hash::{Hash, Hasher};
3927 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3928 body.hash(&mut hasher);
3929 content_type.hash(&mut hasher);
3930 format!("url:{:x}", hasher.finish())
3931 }
3932 Err(error) => format!("url-error:{error}"),
3933 };
3934 }
3935 #[cfg(not(feature = "fetch"))]
3936 let _ = (cache_url, encoding);
3937 return "url".to_string();
3938 }
3939 match std::fs::metadata(fs_path(resource)) {
3940 Ok(metadata) if metadata.is_file() => {
3941 use std::hash::Hasher;
3942 let mut file = match std::fs::File::open(fs_path(resource)) {
3946 Ok(file) => file,
3947 Err(error) => return format!("file-error:{error}"),
3948 };
3949 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3950 let mut buffer = [0u8; 64 * 1024];
3951 loop {
3952 match file.read(&mut buffer) {
3953 Ok(0) => break,
3954 Ok(count) => hasher.write(&buffer[..count]),
3955 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
3956 Err(error) => return format!("file-error:{error}"),
3957 }
3958 }
3959 format!("file:{:x}", hasher.finish())
3960 }
3961 Ok(_) => "not-file".to_string(),
3962 Err(_) => "missing".to_string(),
3963 }
3964}
3965
3966fn run_once(cli: Cli) -> ExitCode {
3967 run_once_with_fetch(cli, None)
3968}
3969
3970fn cli_theme(cli: &Cli) -> rich::Theme {
3973 let mut theme = rich::Theme::default_theme();
3974 for (name, style) in cli.theme_file_styles.iter().chain(&cli.theme_styles) {
3975 theme.insert(name.clone(), style.clone());
3976 }
3977 theme
3978}
3979
3980impl Cli {
3981 fn themed(&self) -> bool {
3983 !self.theme_styles.is_empty() || !self.theme_file_styles.is_empty()
3984 }
3985}
3986
3987fn read_theme_file(
3995 path: &str,
3996 label: &str,
3997) -> Result<std::collections::BTreeMap<String, Style>, String> {
3998 let limit = controls::THEME_FILE_LIMIT;
3999 let path = &fs_path(path);
4001 let metadata = std::fs::metadata(path).map_err(|err| format!("{label}: {err}"))?;
4003 if !metadata.is_file() {
4004 return Err(format!("{label}: not a regular file"));
4005 }
4006 let too_large = || {
4007 format!(
4008 "{label}: larger than {}, the most read from a theme file",
4009 controls::size(limit)
4010 )
4011 };
4012 if metadata.len() > limit {
4013 return Err(too_large());
4014 }
4015 let file = std::fs::File::open(path).map_err(|err| format!("{label}: {err}"))?;
4016 let mut bytes = Vec::new();
4017 file.take(limit + 1)
4018 .read_to_end(&mut bytes)
4019 .map_err(|err| format!("{label}: {err}"))?;
4020 if bytes.len() as u64 > limit {
4021 return Err(too_large());
4022 }
4023 let text = String::from_utf8(bytes).map_err(|err| format!("{label}: {err}"))?;
4024 let theme = rich::Theme::from_file(&text, false).map_err(|err| match &err {
4025 rich::errors::RichError::ThemeConfig(_) => format!("{label}: {err}"),
4027 _ => match bad_style_line(&text) {
4028 Some(line) => format!("{label}: line {line}: {err}"),
4029 None => format!("{label}: {err}"),
4030 },
4031 })?;
4032 Ok(theme
4033 .names()
4034 .filter_map(|name| Some((name.to_owned(), theme.get(name)?.clone())))
4035 .collect())
4036}
4037
4038fn bad_style_line(text: &str) -> Option<usize> {
4041 let mut in_styles = false;
4042 for (index, line) in text.lines().enumerate() {
4043 let trimmed = line.trim();
4044 if trimmed.starts_with('[') {
4045 in_styles = trimmed.eq_ignore_ascii_case("[styles]");
4046 continue;
4047 }
4048 if !in_styles || trimmed.starts_with(['#', ';']) || line.starts_with([' ', '\t']) {
4049 continue;
4050 }
4051 let Some((_, value)) = trimmed.split_once(['=', ':']) else {
4052 continue;
4053 };
4054 if Style::parse(value.trim()).is_err() {
4055 return Some(index + 1);
4056 }
4057 }
4058 None
4059}
4060
4061fn run_once_with_fetch(mut cli: Cli, prefetched: Option<(String, Option<String>)>) -> ExitCode {
4062 if cli.sanitize {
4063 cli.title = cli
4064 .title
4065 .take()
4066 .map(|title| sanitize_terminal_controls(&title));
4067 cli.caption = cli
4068 .caption
4069 .take()
4070 .map(|caption| sanitize_terminal_controls(&caption));
4071 }
4072
4073 if cli.batch {
4074 return run_batch(&cli);
4075 }
4076
4077 if cli.mode == Mode::Auto && cli.resource.is_none() {
4079 run_demo(cli.no_color, std::time::Duration::ZERO);
4080 return success(&cli);
4081 }
4082
4083 let mut mode = match cli.mode {
4084 Mode::Auto => detect_mode(cli.resource.as_deref()),
4085 other => other,
4086 };
4087 if let Some(message) = transform_misplaced_in(&cli, mode) {
4088 return fail(&cli, ExitClass::Usage, message);
4089 }
4090
4091 let mut prefetched = prefetched;
4095 let mut view_as = None;
4096 let mut view_bytes = Vec::new();
4097 if mode == Mode::View {
4098 let ext = cli
4099 .resource
4100 .as_deref()
4101 .filter(|r| *r != "-")
4102 .and_then(resource_ext);
4103 let by_name = viewers::detect(ext.as_deref(), cli.resource.as_deref(), b"");
4106 if !matches!(
4107 by_name,
4108 viewers::ViewAs::Image | viewers::ViewAs::Gif | viewers::ViewAs::Log
4109 ) {
4110 let limit = if by_name == viewers::ViewAs::Notebook {
4113 controls::INSPECT_LIMIT
4114 } else {
4115 controls::VIEW_LIMIT
4116 };
4117 let more;
4118 (view_bytes, more) = match read_window(&cli, 0, limit) {
4119 Ok(read) => read,
4120 Err(err) => return fail(&cli, ExitClass::Input, err),
4121 };
4122 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4123 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4124 if kind == viewers::ViewAs::Binary {
4125 if more || view_bytes.len() as u64 > controls::BYTES_LIMIT {
4126 view_bytes.truncate(controls::BYTES_LIMIT as usize);
4127 viewers::notice(
4128 name,
4129 &controls::size(controls::BYTES_LIMIT),
4130 "use rich hex with --offset and --length for the rest",
4131 );
4132 }
4133 } else if let Some(end) = viewers::line_end(&view_bytes, controls::LINE_LIMIT)
4134 .filter(|_| matches!(kind, viewers::ViewAs::Source(_) | viewers::ViewAs::Patch))
4135 {
4136 view_bytes.truncate(end);
4138 viewers::notice(
4139 name,
4140 &format!("{} lines", controls::LINE_LIMIT),
4141 "use a pager or rich --syntax for the whole file",
4142 );
4143 } else if more {
4144 viewers::notice(
4145 name,
4146 &controls::size(limit),
4147 "use a pager or rich --syntax for the whole file",
4148 );
4149 }
4150 }
4151 let kind = viewers::detect(ext.as_deref(), cli.resource.as_deref(), &view_bytes);
4152 mode = match kind {
4153 viewers::ViewAs::Markdown => Mode::Markdown,
4154 viewers::ViewAs::Csv => Mode::Csv,
4155 viewers::ViewAs::Notebook => Mode::Ipynb,
4156 viewers::ViewAs::Image => Mode::Image,
4157 viewers::ViewAs::Gif => Mode::Gif,
4158 viewers::ViewAs::Log => Mode::Log,
4159 _ => Mode::View,
4160 };
4161 let text = String::from_utf8_lossy(&view_bytes).into_owned();
4162 prefetched = Some((text, None));
4163 view_as = Some(kind);
4164 }
4165
4166 let mut builder = Console::builder().no_color(cli.no_color).emoji(cli.emoji);
4171 if cli.themed() {
4172 builder = builder.theme(cli_theme(&cli));
4173 }
4174 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
4177 builder = builder.force_terminal(terminal == "1");
4178 }
4179 if cli.force_terminal {
4180 builder = builder.force_terminal(true);
4181 }
4182 if let Some(width) = cli.width.filter(|_| mode.draws_directly()) {
4183 builder = builder.width(width);
4184 }
4185 if let Some(width) = watch::capture_width() {
4188 builder = builder.width(width);
4189 }
4190 let mut console = builder.build();
4191 render_target::attach(&mut console);
4192 console.install_extensions();
4193 plugins::install_highlighters(&mut console);
4194 apply_code_highlighting(&mut console, &cli);
4195
4196 if mode == Mode::Rule {
4197 if let Some(title) = &cli.resource {
4198 if let Err(err) = Text::from_markup(title) {
4199 return fail(&cli, ExitClass::Data, err.to_string());
4200 }
4201 }
4202 }
4203
4204 let literal = mode == Mode::Rule
4210 || (mode == Mode::Print && cli.resource.as_deref().is_some_and(|r| !is_url(r)));
4211 let mut svg_title = cli
4212 .resource
4213 .as_deref()
4214 .filter(|r| *r != "-" && !literal)
4215 .map(|r| r.rsplit(['/', '\\']).next().unwrap_or(r).to_string())
4216 .unwrap_or_else(|| "rich".to_string());
4217 if cli.sanitize {
4218 svg_title = sanitize_terminal_controls(&svg_title);
4219 }
4220 #[allow(unused_mut)]
4221 let mut export = Export {
4222 html_path: cli.export_html.as_deref(),
4223 svg_path: cli.export_svg.as_deref(),
4224 svg_title: &svg_title,
4225 pager: cli.pager,
4226 auto_pager: cli.auto_pager,
4227 };
4228
4229 if mode == Mode::Gif {
4232 return play_gifs(&cli, &console);
4233 }
4234
4235 if mode == Mode::Diff {
4238 let images = cli.resources.len() == 2 && cli.resources.iter().all(|r| looks_like_image(r));
4239 if !images {
4240 return run_text_diff(&cli, &console, &export);
4241 }
4242 if let Some(flag) = cli.tools.diff_option() {
4243 return fail(
4244 &cli,
4245 ExitClass::Usage,
4246 format!("{flag} applies to text diffs, not images"),
4247 );
4248 }
4249 #[cfg(feature = "art")]
4250 return run_diff(&cli, &console, &export);
4251 #[cfg(not(feature = "art"))]
4252 return fail(
4253 &cli,
4254 ExitClass::Usage,
4255 "this build has no image support (rebuild with the `art` feature)",
4256 );
4257 }
4258
4259 #[cfg(feature = "art")]
4262 if mode == Mode::Image {
4263 return run_image(&cli, &console, &export);
4264 }
4265 #[cfg(not(feature = "art"))]
4266 if mode == Mode::Image {
4267 return fail(
4268 &cli,
4269 ExitClass::Usage,
4270 "this build has no image support (rebuild with the `art` feature)",
4271 );
4272 }
4273
4274 if mode.streams_records() {
4275 return run_json_lines(&cli, &console, mode == Mode::Log);
4276 }
4277
4278 if mode == Mode::Rule {
4282 let mut rule = match cli.resource.as_deref() {
4283 Some(title) if title != "-" && cli.sanitize => {
4284 Rule::new(sanitize_terminal_controls(title))
4285 }
4286 Some(title) if title != "-" => Rule::new(title),
4287 _ => Rule::line(),
4288 };
4289 let style = cli
4294 .rule_style
4295 .clone()
4296 .unwrap_or_else(|| Style::parse("bright_green").expect("a valid style"));
4297 rule = rule.style(style);
4298 if let Some(characters) = cli.rule_char.as_deref().filter(|c| !c.is_empty()) {
4299 if cli.sanitize {
4302 rule = rule.characters(sanitize_terminal_controls(characters));
4303 } else {
4304 rule = rule.characters(characters);
4305 }
4306 }
4307 match cli.text_justify {
4308 Some(Justify::Left) => rule = rule.align(rich::align::HorizontalAlign::Left),
4309 Some(Justify::Right) => rule = rule.align(rich::align::HorizontalAlign::Right),
4310 _ => {}
4311 }
4312 return decorate_and_emit(&cli, &console, &export, Box::new(rule), Some(1));
4315 }
4316
4317 if mode == Mode::Capture {
4319 let redactor = match viewers::capture_redactor(&cli.viewers, cli.data.redact()) {
4320 Ok(redactor) => redactor,
4321 Err(err) => return fail(&cli, ExitClass::Usage, err),
4322 };
4323 let width = cli
4326 .width
4327 .unwrap_or_else(|| console.width())
4328 .saturating_sub(4)
4329 .max(1);
4330 let cast = match viewers::cast_path(&cli.viewers) {
4333 Some(path) => match std::fs::File::create(path) {
4334 Ok(file) => Some((path, file)),
4335 Err(err) => {
4336 return fail(
4337 &cli,
4338 ExitClass::Input,
4339 format!("cannot write {path}: {err}"),
4340 )
4341 }
4342 },
4343 None => None,
4344 };
4345 let mut captured = match viewers::capture(&cli.resources, width) {
4346 Ok(captured) => captured,
4347 Err(err) => return fail(&cli, ExitClass::Input, err),
4348 };
4349 if let Some(notice) = &captured.notice {
4350 eprintln!("rich: capture: {notice}");
4351 }
4352 if let Some(redactor) = &redactor {
4354 captured.redact(redactor);
4355 }
4356 if cli.sanitize {
4357 captured.sanitize();
4358 }
4359 if let Some((path, mut file)) = cast {
4360 let cast = viewers::asciicast(&captured, width, console.height());
4361 if let Err(err) = file.write_all(cast.as_bytes()) {
4362 return fail(
4363 &cli,
4364 ExitClass::Input,
4365 format!("cannot write {path}: {err}"),
4366 );
4367 }
4368 }
4369 let view = viewers::capture_view(&captured);
4370 let fit = view.measure(&console, &console.options()).maximum;
4371 let code = captured.exit_code();
4374 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4375 if code == 0 || shown.is_err() {
4376 return emit_exit_code(cli, shown);
4377 }
4378 if cli.report_format == ReportFormat::Json {
4380 eprintln!("{}", viewers::capture_report(&captured));
4381 }
4382 ExitCode::from(code)
4383 });
4384 }
4385 if mode == Mode::Mermaid && !cfg!(feature = "mermaid") {
4386 return fail(
4387 &cli,
4388 ExitClass::Usage,
4389 "this build has no Mermaid support (the mermaid feature)",
4390 );
4391 }
4392 if mode == Mode::Env {
4393 let view = viewers::env(&cli.viewers, &cli.resources);
4394 let fit = view.measure(&console, &console.options()).maximum;
4395 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4396 }
4397 if mode == Mode::Profile {
4398 let profile = match profile::profile(&cli) {
4399 Ok(profile) => profile,
4400 Err((class, err)) => return fail(&cli, class, err),
4401 };
4402 if cli.report_format == ReportFormat::Json {
4404 let json = serde_json::to_string_pretty(&profile.to_json()).unwrap_or_default();
4405 authoring::out(&format!("{json}\n"));
4406 return ExitClass::Success.exit_code();
4407 }
4408 let view: Box<dyn Renderable> = Box::new(profile);
4409 let fit = view.measure(&console, &console.options()).maximum;
4410 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4411 }
4412 if matches!(mode, Mode::Deps | Mode::Schema | Mode::Chart) {
4413 let view = match mode {
4414 Mode::Deps => sources::deps(&cli).map(|deps| (deps.view, deps.gate)),
4415 Mode::Schema => sources::schema(&cli).map(|view| (view, None)),
4416 _ => chart::chart(&cli).map(|view| (view, None)),
4417 };
4418 let (view, gate) = match view {
4419 Ok(view) => view,
4420 Err((class, err)) => return fail(&cli, class, err),
4421 };
4422 let fit = view.measure(&console, &console.options()).maximum;
4423 return decorate_and_emit_with(&cli, &console, &export, view, Some(fit), |cli, shown| {
4426 match gate {
4427 Some(message) if shown.is_ok() => fail(cli, ExitClass::Gate, message),
4428 _ => emit_exit_code(cli, shown),
4429 }
4430 });
4431 }
4432 if matches!(mode, Mode::Hex | Mode::Unicode) {
4433 let hex = mode == Mode::Hex;
4436 let offset = cli.viewers.offset().filter(|_| hex).unwrap_or(0);
4437 let length = cli.viewers.length().filter(|_| hex);
4438 let max = length.map_or(controls::BYTES_LIMIT, |n| n as u64);
4439 let (bytes, more) = match read_window(&cli, offset, max) {
4440 Ok(read) => read,
4441 Err(err) => return fail(&cli, ExitClass::Input, err),
4442 };
4443 if more && length.is_none() {
4444 let hint = if hex {
4445 "use --offset and --length for the rest"
4446 } else {
4447 "pass a shorter input"
4448 };
4449 viewers::notice(
4450 cli.resource.as_deref().unwrap_or("<stdin>"),
4451 &controls::size(controls::BYTES_LIMIT),
4452 hint,
4453 );
4454 }
4455 let view = if hex {
4456 viewers::hex(&cli.viewers, bytes, offset)
4457 } else {
4458 viewers::unicode(&cli.viewers, &bytes)
4459 };
4460 let view = match view {
4461 Ok(view) => view,
4462 Err(err) => return fail(&cli, ExitClass::Usage, err),
4463 };
4464 let fit = view.measure(&console, &console.options()).maximum;
4465 return decorate_and_emit(&cli, &console, &export, view, Some(fit));
4466 }
4467
4468 let resource_is_url = matches!(cli.resource.as_deref(), Some(r) if is_url(r));
4472 let (mut content, content_type) = if let Some(fetched) = prefetched {
4473 fetched
4474 } else if resource_is_url {
4475 match fetch_url(cli.resource.as_deref().unwrap(), cli.extensions.encoding) {
4476 Ok(fetched) => fetched,
4477 Err(err) => {
4478 return fail(&cli, ExitClass::Input, err);
4479 }
4480 }
4481 } else if mode == Mode::Print && matches!(cli.resource.as_deref(), Some(r) if r != "-") {
4482 (cli.resource.clone().unwrap(), None)
4483 } else {
4484 let limit = (mode == Mode::Inspect).then_some(controls::INSPECT_LIMIT);
4486 match read_resource_limited(cli.resource.as_deref(), cli.extensions.encoding, limit) {
4487 Ok(content) => (content, None),
4488 Err(err) => {
4489 let name = cli.resource.as_deref().unwrap_or("<stdin>");
4490 return fail(&cli, ExitClass::Input, format!("cannot read {name}: {err}"));
4491 }
4492 }
4493 };
4494 if cli.sanitize && mode != Mode::AnsiExplain {
4497 content = sanitize_terminal_controls(&content);
4498 }
4499
4500 let mut routed_language = None;
4503 let mut routed = false;
4504 if let Some(format) = cli.data.format.filter(|_| cli.mode == Mode::Auto) {
4505 if mode == Mode::Auto || matches!(format, inspect::InputFormat::Named(_)) {
4506 routed = true;
4507 mode = match inspect::route(format, &content, cli.resource.as_deref()) {
4508 inspect::Route::Json => Mode::Json,
4509 inspect::Route::Syntax(lexer) => {
4510 routed_language = Some(lexer);
4511 Mode::Syntax
4512 }
4513 inspect::Route::Text => Mode::Auto,
4514 };
4515 }
4516 }
4517
4518 if resource_is_url && mode == Mode::Auto && !routed {
4522 mode = content_type
4523 .as_deref()
4524 .and_then(content_type_mode)
4525 .unwrap_or(Mode::Syntax);
4526 }
4527
4528 if mode == Mode::Auto && cli.source.wants_syntax() {
4531 mode = Mode::Syntax;
4532 }
4533
4534 let json_content = if mode == Mode::Json && cli.sanitize {
4536 match sanitize_json_source(content.trim()) {
4537 Ok(content) => Some(content),
4538 Err(err) => {
4539 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4540 }
4541 }
4542 } else {
4543 None
4544 };
4545
4546 let json = if mode == Mode::Json {
4547 let source = json_content.as_deref().unwrap_or_else(|| content.trim());
4548 match Json::new(source) {
4549 Ok(json) => Some(json),
4550 Err(err) => {
4551 return fail(&cli, ExitClass::Data, format!("invalid JSON: {err}"));
4552 }
4553 }
4554 } else {
4555 None
4556 };
4557
4558 if mode == Mode::Ipynb {
4563 let notebook = match serde_json::from_str::<serde_json::Value>(&content) {
4564 Ok(mut value) if value.get("cells").and_then(|c| c.as_array()).is_some() => {
4565 if cli.sanitize {
4566 sanitize_json_value(&mut value);
4567 }
4568 value
4569 }
4570 Ok(_) => {
4571 return fail(&cli, ExitClass::Data, "not a notebook: no `cells` array");
4572 }
4573 Err(err) => {
4574 return fail(
4575 &cli,
4576 ExitClass::Data,
4577 format!("invalid notebook JSON: {err}"),
4578 );
4579 }
4580 };
4581 let renderable = match build_ipynb(¬ebook, cli.hyperlinks, cli.justify, &cli.source) {
4582 Ok(renderable) => renderable,
4583 Err(err) => return fail(&cli, ExitClass::Usage, err),
4584 };
4585 let fit = renderable.measure(&console, &console.options()).maximum;
4586 return decorate_and_emit(&cli, &console, &export, Box::new(renderable), Some(fit));
4587 }
4588
4589 if mode == Mode::Print {
4593 if let Err(err) = console.try_build_text(&content) {
4594 return fail(&cli, ExitClass::Data, err.to_string());
4595 }
4596 }
4597
4598 let extension = resource_ext(cli.resource.as_deref().unwrap_or_default())
4603 .filter(|ext| !is_uninformative_ext(ext));
4604 let language = routed_language
4605 .map(str::to_string)
4606 .or(extension)
4607 .or_else(|| {
4608 content_type
4609 .as_deref()
4610 .and_then(content_type_lexer)
4611 .map(str::to_string)
4612 })
4613 .unwrap_or_default();
4614
4615 if let Some(message) = transform_misplaced_in(&cli, mode) {
4617 return fail(&cli, ExitClass::Usage, message);
4618 }
4619
4620 let (renderable, fit): (Box<dyn Renderable>, Option<usize>) = match mode {
4623 Mode::Markdown => {
4626 #[cfg(feature = "art")]
4628 let micro = micro::markdown(&console, &content, cli.micro_project);
4629 #[cfg(feature = "art")]
4630 let source = micro.as_ref().map_or(content.as_str(), |m| m.source());
4631 #[cfg(not(feature = "art"))]
4632 let source = content.as_str();
4633 let mut markdown = build_markdown(source, cli.hyperlinks);
4634 if let Some(fences) = plugin_registry_with(
4637 cli.mermaid_backend.unwrap_or(MermaidBackend::Text),
4638 cli.dot_backend != Some(sources::DotBackend::Off),
4639 )
4640 .fences()
4641 {
4642 markdown = markdown.fence_renderer(fences);
4643 }
4644 #[cfg(feature = "art")]
4645 let view: Box<dyn Renderable> = match micro {
4646 Some(micro) => Box::new(micro.view(markdown)),
4647 None => Box::new(markdown),
4648 };
4649 #[cfg(not(feature = "art"))]
4650 let view: Box<dyn Renderable> = Box::new(markdown);
4651 (view, None)
4652 }
4653 Mode::Rst => {
4657 let lexer = cli.source.lexer.as_deref().unwrap_or("python");
4658 let document = rich_ext::rst::RestructuredText::new(&content).default_lexer(lexer);
4659 (Box::new(document), None)
4660 }
4661 #[cfg(feature = "mermaid")]
4662 Mode::Mermaid => {
4663 let default = if cfg!(feature = "mmdc") {
4665 MermaidBackend::Mmdc
4666 } else {
4667 MermaidBackend::Text
4668 };
4669 let backend = match cli.mermaid_backend.unwrap_or(default) {
4670 MermaidBackend::Off => MermaidBackend::Text,
4671 chosen => chosen,
4672 };
4673 let diagram =
4674 rich_mermaid::Mermaid::new(content.clone()).options(mermaid_options(backend));
4675 (Box::new(diagram), None)
4676 }
4677 Mode::Dot => {
4678 let graphviz = cli.dot_backend == Some(sources::DotBackend::Graphviz);
4681 let mut svg_done = false;
4682 if let (true, Some(path)) = (graphviz, export.svg_path) {
4683 svg_done = sources::graphviz_svg(&content, path);
4684 if svg_done {
4685 export.svg_path = None;
4686 }
4687 }
4688 match sources::dot(&content, cli.resource.as_deref()) {
4689 Ok(diagram) => (diagram, None),
4690 Err((_, err)) if svg_done => {
4693 eprintln!("rich: warning: {err}; only Graphviz's SVG was written");
4694 return success(&cli);
4695 }
4696 Err((class, err)) => return fail(&cli, class, err),
4697 }
4698 }
4699 Mode::Json => {
4700 let nested = cli.padding.is_some()
4717 || cli.panel.is_some()
4718 || cli.style.is_some()
4719 || cli.width.is_some()
4720 || cli.pager
4721 || cli.auto_pager;
4722 let json = json.expect("json parsed above").no_wrap(nested);
4723 #[cfg(feature = "json-escape-safe")]
4724 let json = json.escape_safe(true);
4725 let fit = measure_rendered(&console, &json);
4726 (Box::new(json), Some(fit))
4727 }
4728 Mode::Csv => {
4729 if let Err(err) = validate_labels(&cli) {
4730 return fail(&cli, ExitClass::Data, err);
4731 }
4732 let fallback = csv_fallback_delimiter(cli.resource.as_deref());
4735 let table = match build_csv_table(
4736 &content,
4737 fallback,
4738 cli.title.as_deref(),
4739 cli.caption.as_deref(),
4740 (cli.source.head, cli.source.tail),
4741 cli.source.infer,
4742 ) {
4743 Some(table) => table,
4744 None => {
4747 return fail(&cli, ExitClass::Data, "Could not determine delimiter");
4748 }
4749 };
4750 #[cfg(feature = "art")]
4753 let table = {
4754 let mut table = table;
4755 let label = |label: &Option<String>, style| {
4756 label.as_deref().and_then(|label| {
4757 micro::table_label(&console, label, style, cli.micro_project)
4758 })
4759 };
4760 if let Some(title) = label(&cli.title, "table.title") {
4761 table = table.title_text(title);
4762 }
4763 if let Some(caption) = label(&cli.caption, "table.caption") {
4764 table = table.caption_text(caption);
4765 }
4766 table
4767 };
4768 if cli.panel.is_none()
4772 && cli.padding.is_none()
4773 && cli.style.is_none()
4774 && cli.justify.is_none()
4775 && cli.max_width.is_none()
4776 && !cli.soft
4777 && !cli.pager
4778 && !cli.auto_pager
4779 && cli.export_html.is_none()
4780 && cli.export_svg.is_none()
4781 && !watch::capturing()
4782 && !micro_pending()
4783 {
4784 let mut options = console.options();
4785 options.max_width = cli.width.unwrap_or_else(|| {
4786 table
4787 .measure(&console, &options)
4788 .maximum
4789 .min(options.max_width)
4790 .max(1)
4791 });
4792 let stdout = std::io::stdout();
4793 let mut output = std::io::BufWriter::new(stdout.lock());
4794 let result = table
4795 .try_for_each_line(&console, &options, |line| {
4796 let line = Segment::crop_lines(&line, console.width());
4797 writeln!(output, "{}", console.segments_to_string(&line))
4798 })
4799 .and_then(|()| output.flush());
4800 if let Err(error) = result {
4801 if error.kind() == std::io::ErrorKind::BrokenPipe {
4804 return success(&cli);
4805 }
4806 return fail(
4807 &cli,
4808 ExitClass::Input,
4809 format!("could not write CSV output: {error}"),
4810 );
4811 }
4812 return success(&cli);
4813 }
4814 let fit = measure_rendered(&console, &table);
4815 (Box::new(table), Some(fit))
4816 }
4817 Mode::Syntax if cli.data.transform_option().is_some() => {
4820 let language = cli.source.lexer.as_deref().unwrap_or(&language);
4821 let code = match cli.source.line_range(&content) {
4824 Ok(Some((start, end))) => {
4825 let start = start.max(1) as usize - 1;
4826 let end = end.max(0) as usize;
4827 let lines: Vec<&str> = content.lines().collect();
4828 lines
4829 .get(start.min(lines.len())..end.min(lines.len()))
4830 .unwrap_or_default()
4831 .join("\n")
4832 }
4833 Ok(None) => content.clone(),
4834 Err(err) => return fail(&cli, ExitClass::Usage, err),
4835 };
4836 let text = Syntax::new(code.as_str(), language).highlight_for(&console);
4837 let text = match cli
4838 .data
4839 .text_pipeline(&plugin_registry(MermaidBackend::Text))
4840 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4841 {
4842 Ok(text) => text,
4843 Err(err) => return fail(&cli, ExitClass::Data, err),
4844 };
4845 let fit = text.measurement().1;
4846 let background = match text.base_style() {
4849 rich::style::StyleType::Style(style) => style.clone(),
4850 rich::style::StyleType::Name(_) => rich::Style::new(),
4851 };
4852 let padded =
4855 rich::padding::Padding::new(Box::new(text), (0, 0, 0, 0)).style(background);
4856 (Box::new(padded), Some(fit))
4857 }
4858 Mode::Syntax => {
4859 let syntax = match cli.source.syntax(&content, &language) {
4860 Ok(syntax) => syntax,
4861 Err(err) => return fail(&cli, ExitClass::Usage, err),
4862 };
4863 let fit = syntax.measure(&console, &console.options()).maximum;
4866 (Box::new(syntax), Some(fit))
4867 }
4868 Mode::View => {
4869 let view: Box<dyn Renderable> = match view_as {
4870 Some(viewers::ViewAs::Patch) => {
4871 let name = cli.resource.clone().unwrap_or_else(|| "<stdin>".into());
4872 match tools::text_diff(&cli.tools, &[name], &[content.clone()], None, false) {
4873 Ok(outcome) => outcome.renderable,
4874 Err(err) => return fail(&cli, ExitClass::Data, err),
4875 }
4876 }
4877 Some(viewers::ViewAs::Binary) => {
4878 match viewers::hex(&cli.viewers, std::mem::take(&mut view_bytes), 0) {
4879 Ok(view) => view,
4880 Err(err) => return fail(&cli, ExitClass::Usage, err),
4881 }
4882 }
4883 Some(viewers::ViewAs::Source(ref lexer)) => {
4884 if let Some(summary) = viewers::search_summary(&cli.viewers, &content) {
4885 eprintln!("{summary}");
4886 }
4887 viewers::source(&cli.viewers, &content, lexer)
4888 }
4889 _ => viewers::source(&cli.viewers, &content, "txt"),
4890 };
4891 let fit = view.measure(&console, &console.options()).maximum;
4892 (view, Some(fit))
4893 }
4894 Mode::AnsiExplain => {
4895 let view = tools::ansi_explain(&cli.tools, &content);
4896 let fit = view.measure(&console, &console.options()).maximum;
4897 (view, Some(fit))
4898 }
4899 Mode::Inspect => {
4900 let encoding = cli.extensions.encoding;
4901 let read = |path: &str| {
4902 read_resource_limited(Some(path), encoding, Some(controls::INSPECT_LIMIT))
4903 .map_err(|err| format!("cannot read {path}: {err}"))
4904 };
4905 let view = match inspect::build(&cli.data, &content, cli.resource.as_deref(), read) {
4906 Ok(view) => view,
4907 Err(err) => return fail(&cli, ExitClass::Data, err),
4908 };
4909 let fit = view.measure(&console, &console.options()).maximum;
4910 (view, Some(fit))
4911 }
4912 _ => {
4915 #[cfg(feature = "art")]
4918 let micro = (mode == Mode::Print && cli.emoji)
4919 .then(|| micro::print_markup(&console, &content, cli.micro_project))
4920 .flatten();
4921 #[cfg(feature = "art")]
4922 let mut text = match micro {
4923 Some(text) => text,
4924 None if mode == Mode::Print => console.build_text(&content),
4925 None => Text::new(content.as_str()),
4926 };
4927 #[cfg(not(feature = "art"))]
4928 let mut text = if mode == Mode::Print {
4929 console.build_text(&content)
4930 } else {
4931 Text::new(content.as_str())
4932 };
4933 if let Some(justify) = cli.text_justify {
4937 text.set_justify(justify);
4938 }
4939 if cli.source.no_wrap {
4940 text.set_no_wrap(Some(true));
4941 }
4942 let text = match cli
4943 .data
4944 .text_pipeline(&plugin_registry(MermaidBackend::Text))
4945 .and_then(|pipeline| pipeline.apply(text).map_err(inspect::transform_failed))
4946 {
4947 Ok(text) => text,
4948 Err(err) => return fail(&cli, ExitClass::Data, err),
4949 };
4950 let fit = text.measurement().1;
4951 (Box::new(text), Some(fit))
4952 }
4953 };
4954 decorate_and_emit(&cli, &console, &export, renderable, fit)
4955}
4956
4957#[allow(dead_code)]
4960fn exit_code(ok: bool) -> ExitCode {
4961 if ok {
4962 ExitCode::SUCCESS
4963 } else {
4964 ExitCode::FAILURE
4965 }
4966}
4967
4968fn emit_exit_code(cli: &Cli, result: Result<(), String>) -> ExitCode {
4969 match result {
4970 Ok(()) => success(cli),
4971 Err(message) => fail(cli, ExitClass::Input, message),
4972 }
4973}
4974
4975fn write_rendered_stdout(console: &Console, renderable: &dyn Renderable) -> std::io::Result<()> {
4976 let output = console.render_export(renderable);
4977 let stdout = std::io::stdout();
4978 let mut lock = stdout.lock();
4979 lock.write_all(output.as_bytes())
4980}
4981
4982fn run_json_lines(cli: &Cli, console: &Console, log_mode: bool) -> ExitCode {
4983 if cli.extensions.encoding.is_some() {
4984 return fail(
4985 cli,
4986 ExitClass::Usage,
4987 "--encoding is not supported with streaming JSONL/log mode",
4988 );
4989 }
4990 if matches!(cli.resource.as_deref(), Some(resource) if is_url(resource)) {
4991 return fail(
4992 cli,
4993 ExitClass::Usage,
4994 "streaming JSONL/log mode reads files or stdin, not URLs",
4995 );
4996 }
4997
4998 let mut input: Box<dyn BufRead> = match cli.resource.as_deref() {
4999 Some(path) if path != "-" => {
5000 let file = &fs_path(path);
5001 if file.is_dir() {
5002 return fail(
5003 cli,
5004 ExitClass::Input,
5005 format!("cannot read {path}: is a directory, not a file"),
5006 );
5007 }
5008 match std::fs::File::open(file) {
5009 Ok(file) => Box::new(std::io::BufReader::new(file)),
5010 Err(err) => {
5011 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
5012 }
5013 }
5014 }
5015 _ => {
5016 if std::io::stdin().is_terminal() {
5017 let eof = if cfg!(windows) {
5018 "Ctrl-Z then Enter"
5019 } else {
5020 "Ctrl-D"
5021 };
5022 eprintln!("rich: reading stdin; finish input with {eof}");
5023 }
5024 Box::new(std::io::BufReader::new(std::io::stdin()))
5025 }
5026 };
5027
5028 let mut line = String::new();
5029 let mut line_number = 0usize;
5030 loop {
5031 line.clear();
5032 let read = match input.read_line(&mut line) {
5033 Ok(read) => read,
5034 Err(err) => {
5035 return fail(
5036 cli,
5037 ExitClass::Input,
5038 format!("cannot read JSONL stream: {err}"),
5039 );
5040 }
5041 };
5042 if read == 0 {
5043 break;
5044 }
5045 line_number += 1;
5046 let record = line.trim_end_matches(['\r', '\n']);
5047 let mut value = match serde_json::from_str::<serde_json::Value>(record) {
5048 Ok(value) => value,
5049 Err(err) => {
5050 return fail(
5051 cli,
5052 ExitClass::Data,
5053 format!("invalid JSONL at line {line_number}: {err}"),
5054 );
5055 }
5056 };
5057 if cli.sanitize {
5058 sanitize_json_value(&mut value);
5059 }
5060 let wrote = if log_mode {
5061 if cli.log_presentation == "rich" {
5062 write_rendered_stdout(console, &structured_log::event(&value))
5063 } else {
5064 let text = Text::new(format_log_record(&value));
5065 write_rendered_stdout(console, &text)
5066 }
5067 } else {
5068 let source = serde_json::to_string(&value).expect("JSON value serializes");
5069 let json = Json::new(&source).expect("serialized JSON parses");
5070 write_rendered_stdout(console, &json)
5071 };
5072 if let Err(error) = wrote {
5073 if error.kind() == std::io::ErrorKind::BrokenPipe {
5074 return success(cli);
5075 }
5076 return fail(
5077 cli,
5078 ExitClass::Input,
5079 format!("could not write JSONL output: {error}"),
5080 );
5081 }
5082 }
5083 success(cli)
5084}
5085
5086fn format_log_record(value: &serde_json::Value) -> String {
5087 let Some(object) = value.as_object() else {
5088 return serde_json::to_string(value).expect("JSON value serializes");
5089 };
5090 let take_value = |keys: &[&'static str]| {
5091 keys.iter().find_map(|key| {
5092 object.get(*key).map(|value| {
5093 let display = value.as_str().map(str::to_string).unwrap_or_else(|| {
5094 serde_json::to_string(value).expect("JSON value serializes")
5095 });
5096 (*key, display, value.is_string())
5097 })
5098 })
5099 };
5100 let timestamp = take_value(&["timestamp", "time", "@timestamp"]);
5101 let level = take_value(&["level", "severity"]);
5102 let message = take_value(&["message", "msg"]);
5103 let mut rest = serde_json::Map::new();
5104 for (key, value) in object {
5105 let consumed = timestamp
5106 .as_ref()
5107 .is_some_and(|(consumed, _, _)| consumed == key)
5108 || level
5109 .as_ref()
5110 .is_some_and(|(consumed, _, _)| consumed == key)
5111 || message
5112 .as_ref()
5113 .is_some_and(|(consumed, _, _)| consumed == key);
5114 if !consumed {
5115 rest.insert(key.clone(), value.clone());
5116 }
5117 }
5118 let mut parts = Vec::new();
5119 if let Some((_, timestamp, _)) = timestamp {
5120 parts.push(timestamp);
5121 }
5122 if let Some((_, level, is_string)) = level {
5123 parts.push(if is_string {
5124 level.to_ascii_uppercase()
5125 } else {
5126 level
5127 });
5128 }
5129 if let Some((_, message, _)) = message {
5130 parts.push(message);
5131 }
5132 if !rest.is_empty() {
5133 parts.push(
5134 serde_json::to_string(&serde_json::Value::Object(rest)).expect("JSON value serializes"),
5135 );
5136 }
5137 if parts.is_empty() {
5138 serde_json::to_string(value).expect("JSON value serializes")
5139 } else {
5140 parts.join(" ")
5141 }
5142}
5143
5144fn is_number(value: &str) -> bool {
5150 let body = value.strip_prefix('-').unwrap_or(value);
5151 let (integer, fraction) = match body.split_once('.') {
5152 Some((integer, fraction)) => (integer, Some(fraction)),
5153 None => (body, None),
5154 };
5155 let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
5156 digits(integer) && fraction.is_none_or(digits)
5157}
5158
5159fn build_csv_table(
5178 content: &str,
5179 fallback_delimiter: Option<char>,
5180 title: Option<&str>,
5181 caption: Option<&str>,
5182 window: (Option<usize>, Option<usize>),
5183 infer: bool,
5184) -> Option<Table> {
5185 let sample: String = content.chars().take(1024).collect();
5188 let sniffed = sniff(&sample, Some(&[',', '\t', '|', ';'])).zip(has_header(&sample));
5189 let (dialect, header) = match sniffed {
5190 Some(sniffed) => sniffed,
5191 None => (Dialect::excel(fallback_delimiter?), true),
5192 };
5193 let mut rows = read_rows(content, &dialect);
5194 if window.0.is_some() || window.1.is_some() {
5197 let header_row = if header && !rows.is_empty() {
5198 Some(rows.remove(0))
5199 } else {
5200 None
5201 };
5202 let mut data: Vec<Vec<String>> = rows.into_iter().filter(|row| !row.is_empty()).collect();
5203 match window {
5204 (Some(head), _) => data.truncate(head),
5205 (None, Some(tail)) => {
5206 data.drain(..data.len().saturating_sub(tail));
5207 }
5208 (None, None) => {}
5209 }
5210 rows = header_row.into_iter().chain(data).collect();
5211 }
5212 let types = infer.then(|| infer_csv_types(&rows, header));
5213 Some(render_csv(rows, header, title, caption, types))
5214}
5215
5216fn infer_csv_types(rows: &[Vec<String>], has_header: bool) -> Vec<InferredType> {
5220 let width = rows.iter().map(Vec::len).max().unwrap_or(0);
5221 let mut data = rich_data::Rows::new((0..width).map(|index| index.to_string()));
5222 for row in rows
5223 .iter()
5224 .skip(usize::from(has_header))
5225 .filter(|row| !row.is_empty())
5226 {
5227 data.push(
5228 (0..width)
5229 .map(|index| rich_data::Value::from(row.get(index).map_or("", String::as_str))),
5230 );
5231 }
5232 Inferrer::new()
5233 .infer(&data)
5234 .columns()
5235 .iter()
5236 .map(|column| column.data_type())
5237 .collect()
5238}
5239
5240fn csv_fallback_delimiter(resource: Option<&str>) -> Option<char> {
5247 let name = resource.unwrap_or_default().to_lowercase();
5248 if name.ends_with(".csv") {
5249 Some(',')
5250 } else if name.ends_with(".tsv") {
5251 Some('\t')
5252 } else {
5253 None
5254 }
5255}
5256
5257fn render_csv(
5267 rows: Vec<Vec<String>>,
5268 has_header: bool,
5269 title: Option<&str>,
5270 caption: Option<&str>,
5271 types: Option<Vec<InferredType>>,
5272) -> Table {
5273 let mut table = Table::new()
5274 .border_style(Style::parse("blue").expect("valid style"))
5275 .show_header(has_header || types.is_some())
5276 .box_set(if has_header { HEAVY_HEAD } else { SQUARE });
5277 if let Some(title) = title {
5278 table = table.title(title);
5279 }
5280 if let Some(caption) = caption {
5281 table = table.caption(caption);
5282 }
5283
5284 let empty: Vec<String> = Vec::new();
5285 let (header, body) = if has_header {
5286 match rows.split_first() {
5287 Some((header, body)) => (header, body),
5288 None => return table,
5289 }
5290 } else {
5291 (&empty, rows.as_slice())
5292 };
5293 let data: Vec<&Vec<String>> = body.iter().filter(|row| !row.is_empty()).collect();
5295
5296 let widest = data.iter().map(|row| row.len()).max().unwrap_or(0);
5301 let columns = header.len().max(widest);
5302
5303 for index in 0..columns {
5304 let inferred = types
5309 .as_ref()
5310 .map(|types| types.get(index).copied().unwrap_or(InferredType::Null));
5311 let numeric = match inferred {
5312 Some(inferred) => matches!(inferred, InferredType::Integer | InferredType::Float),
5313 None => data.iter().all(|row| match row.get(index) {
5314 Some(value) => value.is_empty() || is_number(value),
5315 None => false,
5316 }),
5317 };
5318 let name = header.get(index).map(String::as_str).unwrap_or("");
5319 if let Some(inferred) = inferred {
5320 let mut heading = Text::new(name);
5321 if !name.is_empty() {
5322 heading.append("\n", None);
5323 }
5324 heading.append(
5325 inferred.name(),
5326 Some(Style::parse("dim").expect("valid style").into()),
5327 );
5328 let justify = if numeric {
5329 Justify::Right
5330 } else {
5331 Justify::Left
5332 };
5333 table.add_column_text(heading, justify);
5334 } else if numeric {
5335 table.add_column_justify(name, Justify::Right);
5336 } else {
5337 table.add_column(name);
5338 }
5339 if numeric {
5340 table.column_style(Style::parse("bold green").expect("valid style"));
5341 table.column_header_fill(Style::parse("bold green").expect("valid style"));
5342 }
5343 }
5344 drop(data);
5347 table.extend_owned_rows(
5348 rows.into_iter()
5349 .skip(usize::from(has_header))
5350 .filter(|row| !row.is_empty())
5351 .collect(),
5352 );
5353 table
5354}
5355
5356fn join_source(value: &serde_json::Value) -> String {
5359 match value {
5360 serde_json::Value::String(s) => s.clone(),
5361 serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str()).collect(),
5362 _ => String::new(),
5363 }
5364}
5365
5366fn join_traceback(value: &serde_json::Value) -> String {
5370 match value {
5371 serde_json::Value::String(s) => s.clone(),
5372 serde_json::Value::Array(a) => a
5373 .iter()
5374 .filter_map(|v| v.as_str())
5375 .collect::<Vec<_>>()
5376 .join(
5377 "
5378",
5379 ),
5380 _ => String::new(),
5381 }
5382}
5383
5384fn io_label(word: &str, count: Option<i64>, base: &str, number: &str) -> Text {
5388 let base_style = Style::parse(base).ok();
5389 let number_style = Style::parse(number).ok();
5390 let n = count.map_or_else(|| " ".to_string(), |c| c.to_string());
5391 let mut text = Text::new("");
5392 text.append(&format!("{word}["), base_style.clone().map(Into::into));
5393 text.append(&n, number_style.map(Into::into));
5394 text.append("]:", base_style.map(Into::into));
5395 text
5396}
5397
5398struct Notebook {
5401 items: Vec<Box<dyn Renderable>>,
5402 justify: Option<Justify>,
5405}
5406
5407impl Renderable for Notebook {
5408 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
5409 let mut options = options.clone();
5410 if let Some(justify) = self.justify {
5411 options.justify = justify;
5412 }
5413 let mut segments = Vec::new();
5414 for item in &self.items {
5415 let rendered = item.rich_render(console, &options);
5416 if !rendered.is_empty() {
5417 segments.extend(rendered);
5418 segments.push(Segment::line());
5419 }
5420 }
5421 if !segments.is_empty() {
5423 segments.pop();
5424 }
5425 segments
5426 }
5427
5428 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
5429 self.items
5430 .iter()
5431 .map(|item| item.measure(console, options))
5432 .fold(Measurement::new(0, 0), |width, item| {
5433 Measurement::new(
5434 width.minimum.max(item.minimum),
5435 width.maximum.max(item.maximum),
5436 )
5437 })
5438 }
5439}
5440
5441fn notebook_ansi(source: &str) -> Text {
5443 let mut result = Text::new("");
5444 for (index, line) in AnsiDecoder::new().decode(source).iter().enumerate() {
5445 if index > 0 {
5446 result.append("\n", None);
5447 }
5448 result = result.append_text(line);
5449 }
5450 result
5451}
5452
5453fn build_ipynb(
5456 notebook: &serde_json::Value,
5457 hyperlinks: bool,
5458 justify: Option<Justify>,
5459 source_options: &SourceOptions,
5460) -> Result<Notebook, String> {
5461 let language = notebook["metadata"]["kernelspec"]["language"]
5462 .as_str()
5463 .or_else(|| notebook["metadata"]["language_info"]["name"].as_str())
5464 .unwrap_or("python");
5465 let empty = Vec::new();
5466 let mut items: Vec<Box<dyn Renderable>> = Vec::new();
5467 let mut new_line = true;
5468 for cell in notebook["cells"].as_array().unwrap_or(&empty) {
5469 if new_line {
5470 items.push(text(""));
5471 }
5472 if cell.get("execution_count").is_some() {
5473 items.push(Box::new(io_label(
5474 "In ",
5475 cell["execution_count"].as_i64().filter(|n| *n != 0),
5476 "green",
5477 "#66ff00",
5478 )));
5479 }
5480 let source = join_source(&cell["source"]);
5481 items.push(match cell["cell_type"].as_str().unwrap_or("") {
5482 "markdown" => Box::new(build_markdown(&source, hyperlinks)),
5483 "code" => Box::new(
5486 Panel::new(Box::new(source_options.syntax(&source, language)?))
5487 .border_style(Style::parse("dim").expect("valid style")),
5488 ),
5489 _ => text(&source),
5490 });
5491 new_line = true;
5492 for output in cell["outputs"].as_array().unwrap_or(&empty) {
5493 let rendered = match output["output_type"].as_str().unwrap_or("") {
5494 "stream" => {
5495 new_line = false;
5496 notebook_ansi(&join_source(&output["text"]))
5497 }
5498 "error" => {
5499 new_line = true;
5500 notebook_ansi(join_traceback(&output["traceback"]).trim_end())
5501 }
5502 "execute_result" => {
5503 new_line = true;
5504 let mut label = io_label(
5505 "Out",
5506 output["execution_count"].as_i64().filter(|n| *n != 0),
5507 "red",
5508 "#ee4b2b",
5509 );
5510 label.append("\n", None);
5511 label.append_text(¬ebook_ansi(&join_source(&output["data"]["text/plain"])))
5512 }
5513 _ => continue,
5514 };
5515 items.push(Box::new(rendered));
5516 }
5517 }
5518 Ok(Notebook { items, justify })
5519}
5520
5521fn diff_threshold_exceeded(changed: f32, limit: f32) -> bool {
5522 let displayed = |value: f32| {
5525 format!("{value:.1}")
5526 .parse::<f32>()
5527 .expect("formatted percentage is a number")
5528 };
5529 displayed(changed) > displayed(limit)
5530}
5531
5532fn run_text_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5535 let mut contents = Vec::with_capacity(cli.resources.len());
5536 for resource in &cli.resources {
5537 let content = if is_url(resource) {
5538 fetch_url(resource, cli.extensions.encoding).map(|(content, _)| content)
5539 } else {
5540 read_resource(Some(resource), cli.extensions.encoding)
5541 .map_err(|err| format!("cannot read {resource}: {err}"))
5542 };
5543 match content {
5544 Ok(content) => contents.push(content),
5545 Err(err) => return fail(cli, ExitClass::Input, err),
5546 }
5547 }
5548 let names: Vec<String> = cli
5549 .resources
5550 .iter()
5551 .map(|r| {
5552 if r == "-" {
5553 "<stdin>".to_string()
5554 } else {
5555 r.clone()
5556 }
5557 })
5558 .collect();
5559 let outcome = match tools::text_diff(
5560 &cli.tools,
5561 &names,
5562 &contents,
5563 cli.diff_threshold,
5564 cli.sanitize,
5565 ) {
5566 Ok(outcome) => outcome,
5567 Err(err) => return fail(cli, ExitClass::Data, err),
5568 };
5569 let fit = outcome
5570 .renderable
5571 .measure(console, &console.options())
5572 .maximum;
5573 let code = decorate_and_emit(cli, console, export, outcome.renderable, Some(fit));
5574 if code != ExitCode::SUCCESS || !outcome.failed {
5575 return code;
5576 }
5577 fail(cli, ExitClass::Gate, "diff threshold exceeded")
5578}
5579
5580#[cfg(feature = "art")]
5596fn open_image_path(path: &str) -> Result<rich_art::image::DynamicImage, String> {
5597 if fs_path(path).is_dir() {
5598 return Err(format!("cannot read {path}: is a directory, not a file"));
5599 }
5600 match rich_art::image::open(fs_path(path)) {
5601 Ok(image) => Ok(image),
5602 Err(err) => {
5603 use rich_art::image::error::{ImageFormatHint, UnsupportedErrorKind};
5604 let hint = match &err {
5605 rich_art::image::ImageError::Unsupported(error) => match error.kind() {
5606 UnsupportedErrorKind::Format(ImageFormatHint::PathExtension(ext)) =>
5607 Some(format!("unsupported image extension {}; use a supported image format such as PNG or JPEG", ext.display())),
5608 _ => None,
5609 },
5610 _ => None,
5611 };
5612 Err(format!(
5613 "cannot read {path}: {}",
5614 hint.unwrap_or_else(|| err.to_string())
5615 ))
5616 }
5617 }
5618}
5619
5620#[cfg(feature = "art")]
5626fn to_art_image_mode(mode: ImageMode) -> rich_art::ImageMode {
5627 match mode {
5628 ImageMode::Auto => rich_art::ImageMode::Auto,
5629 ImageMode::Sixel => rich_art::ImageMode::Sixel,
5630 ImageMode::Blocks => rich_art::ImageMode::Blocks,
5631 ImageMode::Braille => rich_art::ImageMode::Braille,
5632 ImageMode::Quadrants => rich_art::ImageMode::Quadrants,
5633 ImageMode::Ascii => rich_art::ImageMode::Ascii,
5634 ImageMode::None => unreachable!("--image-mode none is rejected during argument parsing"),
5635 }
5636}
5637
5638#[cfg(feature = "art")]
5645fn run_image(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5646 use rich_art::ImageArt;
5647
5648 let Some(resource) = cli.resource.as_deref() else {
5649 return fail(
5650 cli,
5651 ExitClass::Usage,
5652 "--image requires an image file: rich --image photo.png (or `-` for stdin)",
5653 );
5654 };
5655
5656 let image = if resource == "-" {
5657 let mut buffer = Vec::new();
5658 if let Err(err) = std::io::stdin().read_to_end(&mut buffer) {
5659 return fail(cli, ExitClass::Input, format!("cannot read stdin: {err}"));
5660 }
5661 match rich_art::image::load_from_memory(&buffer) {
5662 Ok(image) => image,
5663 Err(err) => {
5664 return fail(
5665 cli,
5666 ExitClass::Input,
5667 format!("cannot decode image from stdin: {err}"),
5668 )
5669 }
5670 }
5671 } else {
5672 match open_image_path(resource) {
5673 Ok(image) => image,
5674 Err(message) => return fail(cli, ExitClass::Input, message),
5675 }
5676 };
5677
5678 let width = cli.width.unwrap_or_else(|| console.width());
5679 let mut art = ImageArt::new(image)
5680 .transforms(rich_art::ImageTransforms {
5681 rotation: match cli.image_rotate {
5682 Some(90) => rich_art::Rotation::Clockwise90,
5683 Some(180) => rich_art::Rotation::Clockwise180,
5684 Some(270) => rich_art::Rotation::Clockwise270,
5685 _ => rich_art::Rotation::None,
5686 },
5687 flip_horizontal: cli.image_flip_horizontal,
5688 flip_vertical: cli.image_flip_vertical,
5689 grayscale: cli.image_grayscale,
5690 brightness: cli.image_brightness.unwrap_or(1.0),
5691 contrast: cli.image_contrast.unwrap_or(1.0),
5692 gamma: cli.image_gamma.unwrap_or(1.0),
5693 })
5694 .mode(to_art_image_mode(cli.image_mode))
5695 .width(width)
5696 .color(!cli.no_color);
5697 if let Some(height) = cli.height {
5698 art = art.height(height);
5699 }
5700 if let Some(columns) = cli.image_max_width {
5701 art = art.max_width(columns);
5702 }
5703 if let Some(rows) = cli.image_max_height {
5704 art = art.max_height(rows);
5705 }
5706
5707 if let Some(fit) = cli.image_fit.as_deref() {
5708 art = art.fit(match fit {
5709 "cover" => rich_art::ImageFit::Cover,
5710 "stretch" => rich_art::ImageFit::Stretch,
5711 "native" => rich_art::ImageFit::Native,
5712 _ => rich_art::ImageFit::Contain,
5713 });
5714 }
5715 if let Some(anchor) = cli.image_anchor.as_deref() {
5716 use rich_art::ImageAnchor;
5717 art = art.anchor(match anchor {
5718 "top" => ImageAnchor::Top,
5719 "bottom" => ImageAnchor::Bottom,
5720 "left" => ImageAnchor::Left,
5721 "right" => ImageAnchor::Right,
5722 "top-left" => ImageAnchor::TopLeft,
5723 "top-right" => ImageAnchor::TopRight,
5724 "bottom-left" => ImageAnchor::BottomLeft,
5725 "bottom-right" => ImageAnchor::BottomRight,
5726 _ => ImageAnchor::Center,
5727 });
5728 }
5729 match cli.image_background {
5730 Some(ImageBackdrop::Color(rgb)) => art = art.background(rgb),
5731 Some(ImageBackdrop::Terminal) => {
5732 art = art.background_mode(rich_art::ImageBackground::TerminalDefault)
5733 }
5734 Some(ImageBackdrop::Checkerboard) => {
5735 art = art.background_mode(rich_art::ImageBackground::Checkerboard)
5736 }
5737 None => {}
5738 }
5739 let (color_mode, dither, distance) = image_color_processing(cli);
5740 art = art
5741 .color_mode(color_mode)
5742 .dither(dither)
5743 .color_distance(distance);
5744
5745 let options = console.options();
5750 if let Err(err) = art.render(console, &options) {
5751 let message = match err {
5755 rich_art::ImageArtError::NonTerminalDestination if console.is_terminal() => format!(
5756 "this terminal (TERM={}) is not known to support Sixel graphics; set \
5757 RICH_SIXEL=1 or RICH_GRAPHICS=sixel to use Sixel anyway, or choose \
5758 --image-mode blocks, braille or ascii",
5759 std::env::var("TERM").unwrap_or_default()
5760 ),
5761 err => err.to_string(),
5762 };
5763 return fail(cli, ExitClass::Input, message);
5764 }
5765
5766 let terminal_only = Export {
5767 html_path: None,
5768 svg_path: None,
5769 ..*export
5770 };
5771 if let Err(message) = emit(console, &terminal_only, |c| c.print(&art)) {
5772 return fail(cli, ExitClass::Input, message);
5773 }
5774 if export.html_path.is_some() || export.svg_path.is_some() {
5775 use rich::protocol::RenderEnvironment;
5776 use rich_ext::target::{RenderTarget, TargetKind, TargetOverrides};
5777 let mut caps = render_target::observe(console, TargetOverrides::default()).capabilities;
5778 caps.color_system = if cli.no_color {
5779 None
5780 } else {
5781 Some(ColorSystem::Truecolor)
5782 };
5783 let target = RenderTarget::new(TargetKind::Html, caps, console.theme().clone());
5784 let export_console = target.console();
5785 if let Err(err) =
5786 art.render_with_environment(&export_console, &export_console.options(), &target)
5787 {
5788 return fail(cli, ExitClass::Input, err.to_string());
5789 }
5790 let segments = target.segments(&art);
5791 debug_assert!(!target.capabilities().interactive);
5792 if let Err(message) = save_exports(&export_console, export, &segments) {
5793 return fail(cli, ExitClass::Input, message);
5794 }
5795 }
5796 success(cli)
5797}
5798
5799#[cfg(feature = "art")]
5800fn run_diff(cli: &Cli, console: &Console, export: &Export) -> ExitCode {
5801 use rich::Table;
5802 use rich_art::imagediff::{diff, DiffSettings};
5803 use rich_art::{AsciiArt, BlockArt, BrailleArt, SixelArt};
5804
5805 let (before_path, after_path) = (&cli.resources[0], &cli.resources[1]);
5806 let before = match open_image_path(before_path) {
5807 Ok(image) => image,
5808 Err(message) => return fail(cli, ExitClass::Input, message),
5809 };
5810 let after = match open_image_path(after_path) {
5811 Ok(image) => image,
5812 Err(message) => return fail(cli, ExitClass::Input, message),
5813 };
5814
5815 let report = match diff(&before, &after, &DiffSettings::default()) {
5816 Ok(report) => report,
5817 Err(err) => return fail(cli, ExitClass::Data, err.to_string()),
5818 };
5819
5820 let width = cli.width.unwrap_or_else(|| console.width());
5821 let changed = report.changed_fraction * 100.0;
5822 let naive = report.naive_changed_fraction * 100.0;
5823
5824 let failed = cli
5833 .diff_threshold
5834 .is_some_and(|limit| diff_threshold_exceeded(changed, limit));
5835
5836 let render_report = |c: &Console, for_export: bool| {
5837 let rows_cap = console.height().saturating_sub(14).clamp(6, 30);
5840
5841 let has_color = c.color_system().is_some() && !cli.no_color;
5848 let is_terminal = c.is_terminal();
5849 let mut mode = cli.image_mode;
5850 if mode == ImageMode::Auto {
5851 if !has_color && !for_export && cli.report_format == ReportFormat::Human {
5852 eprintln!("rich: auto image mode has no terminal colour, drawing the diff as ASCII instead");
5853 }
5854 mode = if !has_color {
5855 ImageMode::Ascii
5856 } else if is_terminal && rich_art::sixel::is_probably_supported() {
5857 ImageMode::Sixel
5858 } else {
5859 ImageMode::Blocks
5860 };
5861 }
5862
5863 if matches!(mode, ImageMode::Blocks | ImageMode::Quadrants) && !has_color {
5867 if !for_export && cli.report_format == ReportFormat::Human {
5868 eprintln!("rich: no colour available, drawing the diff as ASCII art");
5869 }
5870 mode = ImageMode::Ascii;
5871 }
5872 if mode == ImageMode::Sixel && !is_terminal {
5873 if cli.image_mode != ImageMode::Sixel
5874 && !for_export
5875 && cli.report_format == ReportFormat::Human
5876 {
5877 eprintln!(
5878 "rich: Sixel graphics need a terminal, drawing the diff as {} instead",
5879 if has_color { "blocks" } else { "ASCII art" }
5880 );
5881 }
5882 mode = if has_color {
5883 ImageMode::Blocks
5884 } else {
5885 ImageMode::Ascii
5886 };
5887 }
5888
5889 match mode {
5890 ImageMode::None => {}
5891 ImageMode::Ascii => c.print(
5892 &AsciiArt::new(report.heatmap())
5893 .width(width)
5894 .color(has_color),
5895 ),
5896 ImageMode::Blocks => c.print(
5897 &BlockArt::new(report.heatmap())
5898 .width(width)
5899 .height(rows_cap),
5900 ),
5901 ImageMode::Quadrants => c.print(
5902 &rich_art::QuadrantArt::new(report.heatmap())
5903 .width(width)
5904 .height(rows_cap),
5905 ),
5906 ImageMode::Braille => c.print(
5907 &BrailleArt::new(report.heatmap())
5908 .width(width)
5909 .height(rows_cap),
5910 ),
5911 ImageMode::Sixel => {
5912 let art = SixelArt::new(report.heatmap())
5913 .width(width)
5914 .height(rows_cap);
5915 if art.encode(width).is_some() {
5918 c.print(&art);
5919 } else {
5920 if cli.report_format == ReportFormat::Human {
5921 eprintln!("rich: could not encode Sixel, drawing the diff as blocks");
5922 }
5923 c.print(
5924 &BlockArt::new(report.heatmap())
5925 .width(width)
5926 .height(rows_cap),
5927 );
5928 }
5929 }
5930 ImageMode::Auto => unreachable!("resolved above"),
5931 }
5932 if mode != ImageMode::None {
5936 c.print_str("");
5937 }
5938 c.print_str(&format!(
5939 "[bold]{changed:.1}%[/] of the canvas changed perceptibly \
5940 [dim](a plain pixel diff would say {naive:.1}%)[/]"
5941 ));
5942
5943 if report.regions.is_empty() {
5944 c.print_str("[dim]No region large enough to report.[/]");
5945 } else {
5946 let mut table = Table::new().title("Where it changed");
5947 table.add_column("#");
5948 table.add_column_justify("Share", rich::Justify::Right);
5949 table.add_column_justify("Mean ΔE", rich::Justify::Right);
5950 table.add_column_justify("Area px", rich::Justify::Right);
5951 table.add_column("Box (x,y w×h)");
5952 for (rank, r) in report.regions.iter().enumerate() {
5953 table.add_row(&[
5954 &format!("{}", rank + 1),
5955 &format!("{:.0}%", r.share_of_change * 100.0),
5956 &format!("{:.1}", r.mean_delta_e),
5957 &format!("{}", r.area_px),
5958 &format!("{},{} {}×{}", r.x, r.y, r.width, r.height),
5959 ]);
5960 }
5961 c.print(&table);
5962 }
5963
5964 if let Some(limit) = cli.diff_threshold {
5968 if failed {
5969 c.print_str(&format!(
5970 "[bold red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
5971 ));
5972 } else {
5973 c.print_str(&format!(
5974 "[bold green]OK[/] {changed:.1}% changed, within {limit:.1}%"
5975 ));
5976 }
5977 }
5978 };
5979
5980 let terminal_only = Export {
5981 html_path: None,
5982 svg_path: None,
5983 ..*export
5984 };
5985 let mut wrote = emit(console, &terminal_only, |c| render_report(c, false));
5986 if export.html_path.is_some() || export.svg_path.is_some() {
5987 let mut export_console = Console::builder()
5991 .force_terminal(false)
5992 .color_system(Some(ColorSystem::Truecolor))
5993 .no_color(cli.no_color)
5994 .width(console.width())
5995 .height(console.height())
5996 .theme(console.theme().clone())
5997 .build();
5998 export_console.install_extensions();
5999 let segments = export_console.record_output(|c| render_report(c, true));
6000 wrote = wrote.and_then(|()| save_exports(&export_console, export, &segments));
6001 }
6002
6003 if let Err(message) = wrote {
6004 return fail(cli, ExitClass::Input, message);
6005 }
6006 if failed {
6007 return fail(cli, ExitClass::Gate, "diff threshold exceeded");
6008 }
6009 success(cli)
6010}
6011
6012#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6015#[cfg_attr(not(feature = "art"), allow(dead_code))]
6016enum ImageBackdrop {
6017 Color([u8; 3]),
6019 Terminal,
6021 Checkerboard,
6023}
6024
6025impl ImageBackdrop {
6026 fn parse(value: &str) -> Option<Self> {
6027 match value {
6028 "default" => return Some(ImageBackdrop::Terminal),
6029 "checkerboard" => return Some(ImageBackdrop::Checkerboard),
6030 _ => {}
6031 }
6032 let hex = value.strip_prefix('#')?;
6033 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
6034 return None;
6035 }
6036 let channel = |i: usize| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok();
6037 Some(ImageBackdrop::Color([
6038 channel(0)?,
6039 channel(1)?,
6040 channel(2)?,
6041 ]))
6042 }
6043}
6044
6045#[cfg(feature = "art")]
6048fn image_color_processing(
6049 cli: &Cli,
6050) -> (
6051 rich_art::ImageColorMode,
6052 rich_art::Dither,
6053 rich_art::ColorDistance,
6054) {
6055 use rich_art::{ColorDistance, Dither, ImageColorMode};
6056 let mode = match cli.image_color.as_deref() {
6057 Some("ansi256") => ImageColorMode::Ansi256,
6058 Some("ansi16") => ImageColorMode::Ansi16,
6059 Some("grayscale") => ImageColorMode::Grayscale,
6060 _ => ImageColorMode::TrueColor,
6061 };
6062 let dither = match cli.image_dither.as_deref() {
6063 Some("floyd-steinberg") => Dither::FloydSteinberg,
6064 Some("bayer4x4") => Dither::Bayer4x4,
6065 Some("atkinson") => Dither::Atkinson,
6066 _ => Dither::None,
6067 };
6068 let distance = match cli.image_color_distance.as_deref() {
6069 Some("oklab") => ColorDistance::Oklab,
6070 _ => ColorDistance::Rgb,
6071 };
6072 (mode, dither, distance)
6073}
6074
6075#[cfg(feature = "art")]
6076fn play_gifs(cli: &Cli, console: &Console) -> ExitCode {
6077 use rich_art::{AnimatedArt, Repeat, Stage};
6078
6079 if cli.resources.is_empty() {
6080 return fail(cli, ExitClass::Usage, "--gif needs at least one GIF path");
6081 }
6082 let count = cli.resources.len();
6083 const GAP: usize = 2;
6084
6085 let total = cli.width.unwrap_or_else(|| console.width());
6087 let per_gif = total
6088 .saturating_sub(GAP * count.saturating_sub(1))
6089 .checked_div(count)
6090 .unwrap_or(total)
6091 .max(8);
6092
6093 let repeat = match cli.loops {
6094 Some(0) => Repeat::Forever,
6095 Some(n) => Repeat::Times(n),
6096 None => Repeat::Times(1),
6097 };
6098
6099 let mut stage = Stage::new().gap(GAP);
6100 for path in &cli.resources {
6101 match AnimatedArt::from_path(path) {
6102 Ok(art) => {
6103 let (color_mode, dither, distance) = image_color_processing(cli);
6104 stage = stage.with(
6105 art.width(per_gif)
6106 .blocks(cli.extensions.gif_blocks())
6107 .color(!cli.no_color)
6108 .color_mode(color_mode)
6109 .dither(dither)
6110 .color_distance(distance)
6111 .repeat(repeat)
6112 .max_fps(30.0),
6114 );
6115 }
6116 Err(err) => {
6117 return fail(cli, ExitClass::Input, format!("cannot read {path}: {err}"));
6118 }
6119 }
6120 }
6121
6122 if !console.is_terminal() && cli.report_format == ReportFormat::Human {
6123 eprintln!("rich: GIF animation needs a terminal; rendering the first frame only");
6124 }
6125
6126 let mut builder = Console::builder().no_color(cli.no_color);
6128 if cli.themed() {
6129 builder = builder.theme(cli_theme(cli));
6130 }
6131 if let Ok(terminal) = std::env::var("RS_RICH_BATCH_TERMINAL") {
6134 builder = builder.force_terminal(terminal == "1");
6135 }
6136 if let Some(width) = cli.width {
6137 builder = builder.width(width);
6138 }
6139 match stage.play_stdout(builder.build()) {
6140 Ok(()) => success(cli),
6141 Err(err) => fail(cli, ExitClass::Input, format!("playback failed: {err}")),
6142 }
6143}
6144
6145#[cfg(not(feature = "art"))]
6147fn play_gifs(_cli: &Cli, _console: &Console) -> ExitCode {
6148 fail(
6149 _cli,
6150 ExitClass::Usage,
6151 "this build has no GIF support (rebuild with the `art` feature)",
6152 )
6153}
6154
6155struct Export<'a> {
6158 html_path: Option<&'a str>,
6160 svg_path: Option<&'a str>,
6162 svg_title: &'a str,
6164 pager: bool,
6166 auto_pager: bool,
6167}
6168
6169impl Export<'_> {
6170 fn terminal_only(&self) -> bool {
6173 !watch::capturing()
6174 && self.html_path.is_none()
6175 && self.svg_path.is_none()
6176 && !self.pager
6177 && !self.auto_pager
6178 }
6179}
6180
6181fn should_page(
6192 explicit: bool,
6193 automatic: bool,
6194 terminal: bool,
6195 lines: usize,
6196 height: usize,
6197) -> bool {
6198 terminal && (explicit || (automatic && lines > height.saturating_sub(1)))
6199}
6200
6201fn emit(console: &Console, export: &Export, render: impl FnOnce(&Console)) -> Result<(), String> {
6202 if export.terminal_only() {
6203 render(console);
6204 return Ok(());
6205 }
6206 emit_segments(console, export, console.record_output(render))
6207}
6208
6209fn emit_segments(console: &Console, export: &Export, segments: Vec<Segment>) -> Result<(), String> {
6212 if watch::capturing() {
6213 watch::capture_segments(segments);
6215 return Ok(());
6216 }
6217 let mut first_error = None;
6218
6219 let terminal = console.segments_to_string(&segments);
6221 if should_page(
6222 export.pager,
6223 export.auto_pager,
6224 std::io::stdout().is_terminal(),
6225 terminal.lines().count(),
6226 console.height(),
6227 ) {
6228 if let Err(err) = rich::pager::Pager::show(&rich::pager::SystemPager, &terminal) {
6231 first_error = Some(format!("cannot page output: {err}"));
6232 }
6233 } else {
6234 let stdout = std::io::stdout();
6235 let mut lock = stdout.lock();
6236 if let Err(err) = lock.write_all(terminal.as_bytes()) {
6237 first_error = Some(format!("could not write output: {err}"));
6238 }
6239 }
6240
6241 save_exports(console, export, &segments).and(first_error.map_or(Ok(()), Err))
6242}
6243
6244fn save_exports(console: &Console, export: &Export, segments: &[Segment]) -> Result<(), String> {
6245 let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
6246 let segments = filtered.as_slice();
6247 let mut first_error = None;
6248 if let Some(path) = export.html_path {
6249 let html = rich::export::export_html_classes(segments, &DEFAULT_TERMINAL_THEME);
6251 if let Err(err) = std::fs::write(fs_path(path), html) {
6252 first_error = Some(format!("failed to save HTML: {err}"));
6253 }
6254 }
6255 if let Some(path) = export.svg_path {
6256 let svg = rich::svg::export_svg(
6257 segments,
6258 &rich::SVG_EXPORT_THEME,
6259 export.svg_title,
6260 "rich-cli",
6263 console.width(),
6264 );
6265 if let Err(err) = std::fs::write(fs_path(path), svg) {
6266 first_error.get_or_insert_with(|| format!("failed to save SVG: {err}"));
6267 }
6268 }
6269 first_error.map_or(Ok(()), Err)
6270}
6271
6272fn print_help(no_color: bool) {
6274 cli_spec::print_help(no_color);
6275}
6276
6277fn build_demo_console(no_color: bool) -> Console {
6278 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6283 let mut console = Console::builder()
6284 .force_terminal(to_terminal)
6285 .no_color(no_color)
6286 .color_system(if to_terminal {
6287 Some(ColorSystem::Truecolor)
6288 } else {
6289 None
6290 })
6291 .theme(rich_ext::extended_theme())
6294 .build();
6295 console.install_extensions();
6296 console
6297}
6298
6299fn run_demo(no_color: bool, delay: std::time::Duration) -> Console {
6300 let to_terminal = std::io::stdout().is_terminal() && !no_color;
6301 let console = build_demo_console(no_color);
6302
6303 demo::section(&console, delay, "rs-rich-cli");
6304 console.print_str("[bold magenta]rs-rich-cli[/] — a Rust port of [italic]rich[/]");
6305 console.print_str(
6306 "Core renderables track Python rich 15.0.0; Rust extensions add more workflows.",
6307 );
6308
6309 demo::section(&console, delay, "markup · color · theme · extension");
6311 console.print_str(
6312 "Styles: [bold]bold[/] [dim]dim[/] [italic]italic[/] [underline]underline[/] [reverse]reverse[/]",
6313 );
6314 console.print_str(
6315 "Color: [red]red[/] [green]green[/] [blue]blue[/] [#ff8800]#ff8800[/] [white on blue]on blue[/]",
6316 );
6317 console.print_str("Theme: [error]error[/], [warning]warning[/], [info]info[/]");
6318 let mut themed = build_demo_console(no_color);
6320 let release =
6321 rich::Theme::from_styles([("release", "bold magenta")], true).expect("valid demo theme");
6322 themed.use_theme(release).print_str(
6323 "Stack: [release]release[/] from a pushed theme, popped when its guard drops",
6324 );
6325 console.print_str("Extension: numbers like 3.14 and 2026 are auto-highlighted");
6326 let hl = Console::builder()
6332 .force_terminal(to_terminal)
6333 .no_color(no_color)
6334 .color_system(if to_terminal {
6335 Some(ColorSystem::Truecolor)
6336 } else {
6337 None
6338 })
6339 .highlight(true)
6340 .build();
6341 hl.print_str("Highlight: result = func(42, True, None, '/usr/bin')");
6342 let mut stamp = Text::new("2023-06-15T13:45:30+02:00");
6344 ISO8601Highlighter::new().highlight(&mut stamp);
6345 console.print(&Text::new("ISO 8601: ").append_text(&stamp));
6346 console.print_str("Emoji: :rocket: :fire: :sparkles: :thumbs_up: :snake: :coffee:");
6347
6348 demo::section(&console, delay, "rule");
6350 console.print(&Rule::line());
6351 demo::section(&console, delay, "centered");
6352 console.print(&Rule::new("left").align(HorizontalAlign::Left));
6353 console.print(&Rule::new("right").align(HorizontalAlign::Right));
6354
6355 demo::section(&console, delay, "panel");
6357 console.print(&Panel::new(text("rounded box (default)")).title("rounded"));
6358 console.print(
6359 &Panel::new(text("square box"))
6360 .box_set(SQUARE)
6361 .title("square"),
6362 );
6363 console.print(
6364 &Panel::new(text("double box"))
6365 .box_set(DOUBLE)
6366 .title("double"),
6367 );
6368 console.print(
6369 &Panel::new(text("title + subtitle"))
6370 .title("top")
6371 .subtitle("bottom"),
6372 );
6373 let legacy = Console::builder()
6375 .force_terminal(to_terminal)
6376 .no_color(no_color)
6377 .color_system(if to_terminal {
6378 Some(ColorSystem::Truecolor)
6379 } else {
6380 None
6381 })
6382 .width(console.width())
6383 .legacy_windows(true)
6384 .build();
6385 legacy.print(&Panel::new(text("rounded → square on legacy Windows")).title("legacy"));
6386
6387 demo::section(&console, delay, "padding");
6389 console.print(
6390 &Panel::new(Box::new(Padding::new(text("padded (1, 4)"), (1, 4, 1, 4)))).title("padding"),
6391 );
6392
6393 demo::section(&console, delay, "styled");
6395 console.print(&Styled::new(
6396 Box::new(Panel::new(text("green under the whole panel")).title("styled")),
6397 Style::parse("green").unwrap(),
6398 ));
6399
6400 demo::section(&console, delay, "align");
6402 console.print(&Align::left(text("← left")));
6403 console.print(&Align::center(text("center")));
6404 console.print(&Align::right(text("right →")));
6405
6406 demo::section(&console, delay, "justify");
6408 console.print_justified("left justified", Justify::Left);
6409 console.print_justified("centered", Justify::Center);
6410 console.print_justified("right justified", Justify::Right);
6411
6412 demo::section(&console, delay, "constrain (width 24)");
6414 console.print(&Constrain::new(
6415 Box::new(Panel::new(text("constrained")).title("≤24")),
6416 Some(24),
6417 ));
6418
6419 demo::section(&console, delay, "table");
6421 let mut table = Table::new().title("ported renderables");
6422 table
6424 .add_column("Renderable")
6425 .column_style(Style::parse("cyan").unwrap());
6426 table.add_column("Module").column_width(12);
6427 table.add_column_justify("Parity", Justify::Center);
6428 for (renderable, module, parity) in [
6429 ("Text / markup", "text, markup", "✓"),
6430 ("Rule", "rule", "✓"),
6431 ("Panel", "panel", "✓"),
6432 ("Padding", "padding", "✓"),
6433 ("Align", "align", "✓"),
6434 ("Constrain", "constrain", "✓"),
6435 ("Columns", "columns", "✓"),
6436 ("Table", "table", "✓"),
6437 ("Tree", "tree", "✓"),
6438 ("ProgressBar", "progress_bar", "✓"),
6439 ] {
6440 table.add_row(&[renderable, module, parity]);
6441 }
6442 console.print(&table);
6443
6444 demo::section(&console, delay, "columns");
6446 let months = [
6447 "January",
6448 "February",
6449 "March",
6450 "April",
6451 "May",
6452 "June",
6453 "July",
6454 "August",
6455 "September",
6456 "October",
6457 "November",
6458 "December",
6459 ];
6460 console.print(&Columns::new(
6461 months.iter().map(|m| m.to_string()).collect(),
6462 ));
6463
6464 demo::section(&console, delay, "tree");
6466 let mut tree = Tree::new("rs-rich-cli");
6467 let core = tree.add("crates/rich (core, mirrors rich 15.0.0)");
6468 core.add("color · style · text · console");
6469 core.add("box · rule · panel · padding · align");
6470 core.add("table · tree · wrap · filesize");
6471 tree.add("crates/rich-ext (our extensions)");
6472 tree.add("crates/rich-cli (this binary)");
6473 console.print(&tree);
6474
6475 demo::section(&console, delay, "progress bar");
6477 for pct in [0.0, 33.0, 66.0, 100.0] {
6478 console.print(&ProgressBar::new(100.0, pct).width(48));
6479 }
6480
6481 demo::section(&console, delay, "progress");
6485 let clock = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0f64.to_bits()));
6486 let set_time =
6487 |seconds: f64| clock.store(seconds.to_bits(), std::sync::atomic::Ordering::SeqCst);
6488 let reader = clock.clone();
6489 let now = move || f64::from_bits(reader.load(std::sync::atomic::Ordering::SeqCst));
6490 let mut progress = Progress::new().clock(now.clone());
6491 let downloading = progress.add_task("Downloading", 100.0, 0.0);
6492 let processing = progress.add_task("Processing", 100.0, 0.0);
6493 progress.add_task("Waiting", 100.0, 0.0);
6494 set_time(4.0);
6495 progress.advance(downloading, 20.0);
6496 progress.advance(processing, 60.0);
6497 set_time(8.0);
6498 progress.advance(downloading, 30.0);
6499 progress.advance(processing, 40.0);
6500 console.print(&progress);
6501
6502 let mut transfer = Progress::new().clock(now).columns(vec![
6504 ProgressColumn::spinner(),
6505 ProgressColumn::Description,
6506 ProgressColumn::Bar,
6507 ProgressColumn::Download,
6508 ProgressColumn::TransferSpeed,
6509 ProgressColumn::TimeElapsed,
6510 ProgressColumn::time_remaining(),
6511 ]);
6512 let iso = transfer.add_task("ubuntu.iso", 4_700_000_000.0, 0.0);
6513 let notes = transfer.add_task("notes.txt", 42_000.0, 0.0);
6514 set_time(12.0);
6515 transfer.advance(iso, 1_200_000_000.0);
6516 transfer.advance(notes, 20_000.0);
6517 set_time(20.0);
6518 transfer.advance(iso, 900_000_000.0);
6519 transfer.advance(notes, 22_000.0);
6520 console.print(&transfer);
6521
6522 let mut mofn = Progress::new().columns(vec![
6524 ProgressColumn::Description,
6525 ProgressColumn::Bar,
6526 ProgressColumn::MofN,
6527 ]);
6528 mofn.add_task("Files", 8.0, 3.0);
6529 mofn.add_task("Chunks", 128.0, 128.0);
6530 console.print(&mofn);
6531
6532 let mut download = Progress::new().columns(vec![
6534 ProgressColumn::Description,
6535 ProgressColumn::Bar,
6536 ProgressColumn::Download,
6537 ]);
6538 download.add_task("archive.tar", 10_000_000.0, 4_200_000.0);
6539 console.print(&download);
6540
6541 demo::section(&console, delay, "json");
6543 if let Ok(json) = Json::new(
6544 r#"{"port": "rich", "version": "15.0.0", "parity": true, "widgets": ["panel", "table", "tree"]}"#,
6545 ) {
6546 console.print(&json);
6547 }
6548
6549 demo::section(&console, delay, "pretty");
6551 let value = vec![("panel", 1u32), ("table", 2), ("tree", 3)];
6552 console.print(&Pretty::new(&value));
6553
6554 demo::section(&console, delay, "log");
6556 let log = LogRender::new().show_level(true);
6557 for (level, message) in [
6558 (LogLevel::Info, "server started on port 8080"),
6559 (LogLevel::Debug, "cache warm: 128 entries"),
6560 (LogLevel::Warn, "disk usage at 85%"),
6561 (LogLevel::Error, "connection reset by peer"),
6562 ] {
6563 console.print(&log.render(
6564 &console,
6565 Text::new(message),
6566 Some(Text::new("[12:00:00]")),
6567 level.text(),
6568 Some("main.rs"),
6569 Some(42),
6570 None,
6571 ));
6572 }
6573
6574 demo::section(&console, delay, "traceback");
6576 let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file: config.toml");
6577 let err = std::io::Error::other(cause);
6578 console.print(&Traceback::new(&err));
6579
6580 demo::section(&console, delay, "bar (range)");
6582 for (begin, end) in [(0.0, 100.0), (0.0, 62.0), (20.0, 80.0), (55.0, 100.0)] {
6583 console.print(&Bar::new(100.0, begin, end).width(48));
6584 }
6585
6586 demo::section(&console, delay, "markdown");
6588 console.print(&Markdown::new(
6589 "# 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---",
6590 ));
6591
6592 demo::section(&console, delay, "syntax");
6594 console.print(&Syntax::new(
6595 "fn main() {\n let msg = \"hello, rich\";\n println!(\"{msg}\");\n}",
6596 "rust",
6597 ));
6598
6599 demo::section(&console, delay, "control codes");
6601 let show_escape = |label: &str, control: &Control| {
6602 let escaped = control
6604 .as_str()
6605 .replace('\x1b', "\\x1b")
6606 .replace('\x07', "\\a")
6607 .replace('\r', "\\r");
6608 console.print(&Text::new(format!(" {label:>14}: {escaped}")));
6609 };
6610 show_escape("clear screen", &Control::clear());
6611 show_escape("home", &Control::home());
6612 show_escape("move (2,-1)", &Control::move_(2, -1));
6613 show_escape("move_to (3,4)", &Control::move_to(3, 4));
6614 show_escape("hide cursor", &Control::show_cursor(false));
6615 show_escape("bell", &Control::bell());
6616 let live_render = LiveRender::new(text("a\nb\nc"));
6618 let _ = console.render_to_string(&live_render); show_escape("live refresh", &live_render.position_cursor());
6620 let live_console = Console::builder()
6623 .force_terminal(true)
6624 .color_system(Some(ColorSystem::Truecolor))
6625 .width(20)
6626 .build();
6627 let mut live = Live::new(text("frame one"), live_console, Vec::<u8>::new());
6628 live.start();
6629 live.update(text("frame two"));
6630 live.stop();
6631 let stream = String::from_utf8_lossy(live.writer())
6632 .replace('\x1b', "\\x1b")
6633 .replace('\r', "\\r")
6634 .replace('\n', "\\n");
6635 console.print(&Text::new(format!(" {:>14}: {stream}", "live stream")));
6636
6637 demo::section(&console, delay, "ansi decoder");
6639 let raw =
6640 "\x1b[1;31mred bold\x1b[0m \x1b[38;5;214morange\x1b[0m \x1b[3;4mitalic underline\x1b[0m";
6641 console.print(&Text::new(format!(
6642 " input: {}",
6643 raw.replace('\x1b', "\\x1b")
6644 )));
6645 let mut decoder = rich::AnsiDecoder::new();
6646 for line in decoder.decode(raw) {
6647 console.print(&Text::new(" output: ").append_text(&line));
6648 }
6649
6650 demo::section(&console, delay, "capture · export_text");
6652 let plain = console.export_text(|c| {
6653 c.print(&Panel::new(text("captured panel")).box_set(SQUARE));
6654 });
6655 console.print(&Text::new(" export_text (styles stripped):"));
6656 for line in plain.lines() {
6657 console.print(&Text::new(format!(" {line}")));
6658 }
6659 let html_rule = Style::parse("bold red")
6661 .unwrap()
6662 .get_html_style(&DEFAULT_TERMINAL_THEME);
6663 console.print(&Text::new(format!(
6664 " export_html: [bold red] → <span style=\"{html_rule}\">"
6665 )));
6666
6667 demo::section(&console, delay, "layout");
6670 let panel_leaf = |title: &str, body: &str| {
6671 Layout::with_renderable(Box::new(
6672 Panel::new(text(body))
6673 .box_set(SQUARE)
6674 .title(title.to_string()),
6675 ))
6676 };
6677 let mut header = Layout::new();
6678 header.split_row(vec![
6679 panel_leaf("left", "pane A"),
6680 panel_leaf("right", "pane B"),
6681 ]);
6682 let mut root = Layout::new();
6683 root.split_column(vec![
6684 header,
6685 Layout::with_renderable(text("footer (fixed size 3)")).size(3),
6686 ]);
6687 let grid = Console::builder()
6688 .force_terminal(true)
6689 .color_system(Some(ColorSystem::Truecolor))
6690 .width(48)
6691 .height(7)
6692 .build();
6693 for line in grid.export_text(|c| c.print(&root)).lines() {
6694 console.print(&Text::new(line));
6695 }
6696
6697 demo::section(&console, delay, "spinner (frames)");
6699 for name in ["dots", "line", "arrow", "simpleDots"] {
6700 let spinner = Spinner::new(name);
6701 let frames: String = (0..6)
6702 .map(|i| console.render_to_string(&spinner.render(i as f64 * 0.15)))
6703 .collect::<Vec<_>>()
6704 .join(" ");
6705 console.print_str(&format!(" {name:>11}: {frames}"));
6706 }
6707 let status = Status::new("Loading…").renderable().render(0.0);
6709 console.print(&Text::new(" status: ").append_text(&status));
6710
6711 demo::section(&console, delay, "csv");
6713 let csv = "Product,Qty,Price\nWidget,3,9.99\nGadget,12,19.50\nGizmo,1,4.25";
6714 if let Some(table) = build_csv_table(csv, Some(','), None, None, (None, None), false) {
6715 console.print(&table);
6716 }
6717
6718 demo::section(&console, delay, "filesize");
6720 for bytes in [1u64, 999, 1_000, 1_500, 1_000_000, 1_500_000_000] {
6721 console.print_str(&format!(" {bytes:>13} → {}", filesize::decimal(bytes)));
6722 }
6723 console.print(&Rule::line());
6724 console
6725}
6726
6727#[cfg(test)]
6728mod tests {
6729 use super::*;
6730 use rich::ColorSystem;
6731
6732 const MODE_FLAGS: &[&str] = &[
6734 "--print",
6735 "-p",
6736 "--markdown",
6737 "-m",
6738 "--json",
6739 "-j",
6740 "-J",
6741 "--syntax",
6742 "-x",
6743 "--csv",
6744 "--ipynb",
6745 "--rst",
6746 "--jsonl",
6747 "--ndjson",
6748 "--log",
6749 "--rule",
6750 "-u",
6751 "--image",
6752 "--gif",
6753 "--diff",
6754 "--inspect",
6755 "--ansi-explain",
6756 ];
6757
6758 #[test]
6761 fn a_mode_flag_before_a_subcommand_word_makes_it_a_resource() {
6762 for flag in MODE_FLAGS {
6763 assert!(mode_flag_alias(flag).is_some(), "{flag}");
6764 }
6765 let claimed = |args: &[String]| {
6766 let mut words = Vec::new();
6767 if plugins::requested(args) {
6768 words.push("plugins");
6769 }
6770 if doctor::requested(args) {
6771 words.push("doctor");
6772 }
6773 #[cfg(feature = "art")]
6774 if micro::requested(args) {
6775 words.push("micro");
6776 }
6777 #[cfg(feature = "record")]
6778 if record::requested(args) {
6779 words.push("record");
6780 }
6781 #[cfg(feature = "serve")]
6782 if serve::requested(args) {
6783 words.push("serve");
6784 }
6785 #[cfg(feature = "interact")]
6786 words.extend(interactive::requested(args));
6787 if tools::bench_dispatch(args).unwrap_or(true) {
6788 words.push("bench");
6789 }
6790 let roots = ConfigRoots {
6791 home: None,
6792 cwd: std::env::temp_dir(),
6793 no_color_env: false,
6794 };
6795 if !matches!(config::inspect(args, &roots), Ok(None)) {
6796 words.push("config");
6797 }
6798 words
6799 };
6800 let words = [
6801 "plugins", "doctor", "record", "serve", "choose", "filter", "input", "confirm",
6802 "pager", "bench", "config",
6803 ];
6804 for word in words {
6805 let plain = claimed(&[word.to_string(), "--no-color".into()]);
6807 assert!(plain.is_empty() || plain == [word], "{word}: {plain:?}");
6808 for flag in MODE_FLAGS {
6809 let args = [flag.to_string(), word.to_string()];
6810 assert_eq!(claimed(&args), Vec::<&str>::new(), "{flag} {word}");
6811 let args = ["--no-color".to_string(), flag.to_string(), word.to_string()];
6812 assert_eq!(claimed(&args), Vec::<&str>::new(), "{args:?}");
6813 }
6814 }
6815 assert!(plugins::requested(&["--no-color".into(), "plugins".into()]));
6816 assert!(doctor::requested(&["doctor".into()]));
6817 }
6818
6819 #[cfg(feature = "art")]
6820 #[test]
6821 fn diff_threshold_uses_the_reports_tie_rounding() {
6822 assert!(!diff_threshold_exceeded(0.25, 0.24));
6824 assert!(diff_threshold_exceeded(0.75, 0.74));
6825 assert!(!diff_threshold_exceeded(0.75, 0.76));
6826 }
6827
6828 #[test]
6829 fn detects_urls() {
6830 assert!(is_url("http://example.com"));
6831 assert!(is_url("https://example.com/x"));
6832 assert!(is_url("HTTPS://example.com"));
6834 assert!(is_url("Http://example.com"));
6835 assert!(!is_url("example.com"));
6836 assert!(!is_url("./file.md"));
6837 assert!(!is_url("-"));
6838 assert!(!is_url("")); assert!(!is_url("ht"));
6840 assert!(!is_url("ftp://example.com"));
6841 }
6842
6843 #[test]
6844 fn uninformative_extensions_defer_to_content_type() {
6845 assert!(is_uninformative_ext("txt"));
6846 assert!(is_uninformative_ext("log"));
6847 assert!(!is_uninformative_ext("py"));
6848 assert!(!is_uninformative_ext("rs"));
6849 }
6850
6851 #[test]
6852 fn resource_ext_uses_basename_without_query() {
6853 assert_eq!(resource_ext("a.md").as_deref(), Some("md"));
6854 assert_eq!(resource_ext("path/to/b.RS").as_deref(), Some("rs"));
6855 assert_eq!(
6856 resource_ext("https://x.com/c.py?raw=1").as_deref(),
6857 Some("py")
6858 );
6859 assert_eq!(resource_ext("https://api.example.com/data"), None);
6861 assert_eq!(resource_ext("https://x.com/"), None);
6862 assert_eq!(resource_ext("noext"), None);
6863 }
6864
6865 #[test]
6866 fn content_type_maps_mode_and_lexer() {
6867 assert_eq!(mime_of("text/html; charset=utf-8"), "text/html");
6868 assert_eq!(content_type_mode("text/markdown"), Some(Mode::Markdown));
6869 assert_eq!(
6870 content_type_mode("application/json; charset=utf-8"),
6871 Some(Mode::Json)
6872 );
6873 assert_eq!(content_type_mode("text/csv"), Some(Mode::Csv));
6874 assert_eq!(content_type_mode("text/html"), None); assert_eq!(content_type_lexer("text/html"), Some("html"));
6876 assert_eq!(content_type_lexer("text/x-python"), Some("python"));
6877 assert_eq!(content_type_lexer("text/plain"), None);
6878 }
6879
6880 #[test]
6881 fn join_source_handles_string_and_array() {
6882 use serde_json::json;
6883 assert_eq!(join_source(&json!("a\nb")), "a\nb");
6884 assert_eq!(join_source(&json!(["a\n", "b"])), "a\nb");
6886 assert_eq!(join_source(&json!(null)), "");
6887 }
6888
6889 #[test]
6890 fn ipynb_extension_auto_detects() {
6891 assert_eq!(detect_mode(Some("notebook.ipynb")), Mode::Ipynb);
6892 }
6893
6894 #[test]
6895 fn io_label_builds_execution_count() {
6896 assert_eq!(
6898 io_label("In ", Some(1), "green", "#66ff00").plain(),
6899 "In [1]:"
6900 );
6901 assert_eq!(io_label("Out", None, "red", "#ee4b2b").plain(), "Out[ ]:");
6902 }
6903
6904 #[test]
6905 fn parse_padding_unpacks_like_upstream() {
6906 assert_eq!(parse_padding("2"), Ok((2, 2, 2, 2)));
6907 assert_eq!(parse_padding("1,2"), Ok((1, 2, 1, 2)));
6908 assert_eq!(parse_padding("1,2,3,4"), Ok((1, 2, 3, 4)));
6909 assert_eq!(parse_padding(" 1 , 2 "), Ok((1, 2, 1, 2)));
6910 assert!(parse_padding("1,2,3").is_err()); assert!(parse_padding("x").is_err());
6912 }
6913
6914 #[test]
6915 fn parse_box_maps_names() {
6916 assert!(matches!(parse_box("rounded"), Ok(Some(_))));
6917 assert!(matches!(parse_box("HEAVY"), Ok(Some(_))));
6918 assert!(matches!(parse_box("none"), Ok(None)));
6920 assert!(parse_box("bogus").is_err());
6921 }
6922
6923 #[test]
6924 fn paging_policy_respects_viewport_and_redirection() {
6925 assert!(!should_page(true, true, false, 100, 24));
6926 assert!(!should_page(false, true, true, 23, 24));
6927 assert!(should_page(false, true, true, 24, 24));
6928 assert!(should_page(true, false, true, 1, 24));
6929 assert!(!should_page(false, false, true, 100, 24));
6930 }
6931
6932 #[test]
6933 fn worker_arguments_preserve_resources_named_like_commands() {
6934 for source in [["--markdown", "json", "b.md"], ["markdown", "json", "b.md"]] {
6935 let args: Vec<String> = source.into_iter().map(str::to_string).collect();
6936 assert_eq!(worker_args(&args), ["--no-config", "--markdown"]);
6937 }
6938 }
6939
6940 #[test]
6941 fn worker_arguments_keep_option_values_and_remove_batch_operands() {
6942 let args: Vec<String> = [
6943 "--batch",
6944 "--title",
6945 "--batch",
6946 "--jobs",
6947 "2",
6948 "json",
6949 "--export-html",
6950 "out.html",
6951 "--",
6952 "--pager",
6953 ]
6954 .into_iter()
6955 .map(str::to_string)
6956 .collect();
6957 assert_eq!(
6958 worker_args(&args),
6959 ["--no-config", "--title", "--batch", "--json"]
6960 );
6961 }
6962
6963 #[test]
6965 fn worker_arguments_keep_the_code_highlighter() {
6966 let args: Vec<String> = [
6967 "--batch",
6968 "--highlighter",
6969 "syntect",
6970 "--code-theme",
6971 "ansi_dark",
6972 "a.py",
6973 ]
6974 .into_iter()
6975 .map(str::to_string)
6976 .collect();
6977 assert_eq!(
6978 worker_args(&args),
6979 [
6980 "--no-config",
6981 "--highlighter",
6982 "syntect",
6983 "--code-theme",
6984 "ansi_dark"
6985 ]
6986 );
6987 }
6988
6989 #[test]
6990 fn parses_pager_flag() {
6991 let s = |v: &str| v.to_string();
6992 assert!(parse(&[s("--pager"), s("x")]).unwrap().unwrap().pager);
6993 assert!(!parse(&[s("x")]).unwrap().unwrap().pager);
6994 }
6995
6996 #[test]
6997 fn parses_watch_options_and_rejects_orphans() {
6998 let s = |v: &str| v.to_string();
6999 let cli = parse(&[
7000 s("--watch"),
7001 s("--watch-interval"),
7002 s("0.25"),
7003 s("--watch-cache"),
7004 s("file.md"),
7005 ])
7006 .unwrap()
7007 .unwrap();
7008 assert!(cli.watch);
7009 assert_eq!(cli.watch_interval, 0.25);
7010 assert!(cli.watch_cache);
7011 assert!(parse(&[s("--watch-cache"), s("file.md")]).is_err());
7012 assert!(parse(&[s("--watch-interval"), s("2"), s("file.md")]).is_err());
7013 }
7014
7015 #[test]
7016 fn parses_multi_file_watch_options() {
7017 let s = |v: &str| v.to_string();
7018 let parse = parse_inner;
7019 let cli = parse(&[
7020 s("--watch"),
7021 s("--watch-debounce"),
7022 s("0.3"),
7023 s("--watch-poll"),
7024 s("--watch-exit-on-error"),
7025 s("a.md"),
7026 s("b.json"),
7027 ])
7028 .unwrap()
7029 .unwrap();
7030 assert_eq!(cli.resources, ["a.md", "b.json"]);
7031 assert_eq!(cli.watch_debounce, 0.3);
7032 assert!(cli.watch_poll && cli.watch_exit_on_error);
7033 let default = parse(&[s("--watch"), s("a.md")]).unwrap().unwrap();
7034 assert_eq!(default.watch_debounce, watch::DEFAULT_DEBOUNCE_SECONDS);
7035 assert!(!default.watch_poll && !default.watch_exit_on_error);
7036 for orphan in [
7037 vec![s("--watch-debounce"), s("0.2"), s("a.md")],
7038 vec![s("--watch-poll"), s("a.md")],
7039 vec![s("--watch-exit-on-error"), s("a.md")],
7040 ] {
7041 assert!(parse(&orphan)
7042 .err()
7043 .unwrap()
7044 .contains("only has an effect with --watch"));
7045 }
7046 for bad in ["-0.1", "nan", "inf", "soon", "3601"] {
7047 assert!(parse(&[s("--watch"), s("--watch-debounce"), s(bad), s("a.md")]).is_err());
7048 }
7049 assert!(parse(&[s("a.md"), s("b.md")]).is_err());
7051 }
7052
7053 #[test]
7054 fn watch_fingerprint_distinguishes_missing_and_replaced_files() {
7055 let path = std::env::temp_dir().join(format!("rs-rich-watch-{}.txt", std::process::id(),));
7056 let _ = std::fs::remove_file(&path);
7057 let missing = watch_fingerprint(path.to_str().unwrap(), false, None);
7058 std::fs::write(&path, "first").unwrap();
7059 let first = watch_fingerprint(path.to_str().unwrap(), false, None);
7060 std::fs::write(&path, "second content").unwrap();
7061 let second = watch_fingerprint(path.to_str().unwrap(), false, None);
7062 let _ = std::fs::remove_file(&path);
7063 assert_eq!(missing, "missing");
7064 assert_ne!(first, missing);
7065 assert_ne!(first, second);
7066 }
7067
7068 #[test]
7069 fn parses_batch_controls_and_cli_values_override_config_defaults() {
7070 let s = |v: &str| v.to_string();
7071 let cli = parse(&[
7072 s("--no-config"),
7073 s("--batch"),
7074 s("--jobs"),
7075 s("3"),
7076 s("-o"),
7077 s("out.html"),
7078 s("--continue-on-error"),
7079 s("--collision"),
7080 s("suffix"),
7081 s("input"),
7082 ])
7083 .unwrap()
7084 .unwrap();
7085 assert!(cli.batch);
7086 assert_eq!(cli.jobs, 3);
7087 assert!(cli.continue_on_error);
7088 assert_eq!(cli.collision, CollisionPolicy::Suffix);
7089 }
7090
7091 #[test]
7092 fn batch_expansion_is_recursive_sorted_and_deduplicated() {
7093 let root = std::env::temp_dir().join(format!("rich-batch-test-{}", std::process::id()));
7094 let _ = std::fs::remove_dir_all(&root);
7095 let nested = root.join("nested");
7096 std::fs::create_dir_all(&nested).unwrap();
7097 std::fs::write(root.join("richbatchonly.md"), "# b").unwrap();
7098 std::fs::write(nested.join("richbatchnested.md"), "# a").unwrap();
7099
7100 let root_arg = root.to_string_lossy().into_owned();
7101 let glob_arg = root
7102 .join("*richbatchonly.md")
7103 .to_string_lossy()
7104 .into_owned();
7105 let inputs = expand_batch_resources(&[root_arg, glob_arg]).unwrap();
7106 assert_eq!(inputs.len(), 2);
7107 assert!(inputs[0].ends_with("richbatchnested.md"));
7108 assert!(inputs[1].ends_with("richbatchonly.md"));
7109
7110 std::fs::remove_dir_all(root).unwrap();
7111 }
7112
7113 #[test]
7114 fn batch_output_suffixes_are_deterministic_for_collisions() {
7115 let inputs = ["one.md", "two.md"];
7116 let first = batch_export_path(Some("out.html"), inputs[0], 0, 2).unwrap();
7117 let second = batch_export_path(Some("out.html"), inputs[1], 1, 2).unwrap();
7118 assert_eq!(first, "one.html");
7119 assert_eq!(second, "two.html");
7120 assert_eq!(
7121 Path::new("one.html")
7122 .with_file_name("one-2.html")
7123 .to_string_lossy(),
7124 "one-2.html"
7125 );
7126 }
7127
7128 #[test]
7129 fn config_profiles_support_profiles_section_and_source_errors() {
7130 let root = std::env::temp_dir().join(format!("rich-config-test-{}", std::process::id()));
7131 std::fs::create_dir_all(&root).unwrap();
7132 let config = root.join("config.toml");
7133 std::fs::write(&config, "[profiles.ci]\nmode = \"json\"\njobs = 4\n").unwrap();
7134 let args = vec![
7135 "--config".to_string(),
7136 config.to_string_lossy().into_owned(),
7137 "--profile".to_string(),
7138 "ci".to_string(),
7139 ];
7140 let merged = config_args(
7141 &args,
7142 &ConfigRoots {
7143 home: None,
7144 cwd: root.clone(),
7145 no_color_env: false,
7146 },
7147 )
7148 .unwrap();
7149 assert!(merged.windows(1).any(|arg| arg[0] == "--json"));
7150 assert!(merged
7151 .windows(2)
7152 .any(|pair| pair[0] == "--jobs" && pair[1] == "4"));
7153
7154 let bad = root.join("bad.toml");
7155 std::fs::write(&bad, "[defaults]\nnot-a-setting = true\n").unwrap();
7156 let error = config_args(
7157 &["--config".to_string(), bad.to_string_lossy().into_owned()],
7158 &ConfigRoots {
7159 home: None,
7160 cwd: root.clone(),
7161 no_color_env: false,
7162 },
7163 )
7164 .unwrap_err();
7165 assert!(error.contains("config "));
7166 assert!(error.contains("unknown key"));
7167 std::fs::remove_dir_all(root).unwrap();
7168 }
7169
7170 #[test]
7171 fn batch_rejects_modes_that_consume_their_whole_resource_list() {
7172 let s = |v: &str| v.to_string();
7173 let error = parse(&[s("--no-config"), s("--batch"), s("--diff"), s("a"), s("b")])
7174 .err()
7175 .unwrap();
7176 assert!(error.contains("--batch cannot be combined with --diff"));
7177 let error = parse(&[s("--no-config"), s("--batch"), s("--gif"), s("a")])
7178 .err()
7179 .unwrap();
7180 assert!(error.contains("--batch cannot be combined with --gif"));
7181 let error = parse(&[s("--no-config"), s("--batch"), s("--pager"), s("a")])
7183 .err()
7184 .unwrap();
7185 assert!(error.contains("--pager cannot be combined with --batch"));
7186 }
7187
7188 #[test]
7189 fn line_counts_follow_python_splitlines() {
7190 assert_eq!(python_line_count(""), 0);
7191 assert_eq!(python_line_count("a"), 1);
7192 assert_eq!(python_line_count("a\n"), 1);
7193 assert_eq!(python_line_count("a\n\n"), 2);
7194 assert_eq!(python_line_count("a\r\nb"), 2);
7195 assert_eq!(
7196 python_line_count("a\rb\x0bc\x0cd\x1ce\u{85}f\u{2028}g\u{2029}h"),
7197 8
7198 );
7199 }
7200
7201 #[test]
7202 fn glob_matches_metacharacters_rather_than_substrings() {
7203 assert!(glob_match("*.md", "notes.md"));
7204 assert!(!glob_match("*.md", "notes.md.bak"));
7206 assert!(glob_match("a?c.md", "abc.md"));
7207 assert!(!glob_match("a?c.md", "ac.md"));
7208 assert!(glob_match("*", "anything"));
7209 assert!(glob_match("a*b*c", "axxbyyc"));
7210 assert!(!glob_match("a*b*c", "axxbyy"));
7211 }
7212
7213 #[test]
7214 fn batch_expansion_skips_symlinked_directories() {
7215 let root = std::env::temp_dir().join(format!("rich-batch-link-{}", std::process::id()));
7216 let _ = std::fs::remove_dir_all(&root);
7217 std::fs::create_dir_all(&root).unwrap();
7218 std::fs::write(root.join("a.md"), "# a").unwrap();
7219 #[cfg(unix)]
7221 let linked = std::os::unix::fs::symlink(&root, root.join("loop")).is_ok();
7222 #[cfg(windows)]
7223 let linked = std::os::windows::fs::symlink_dir(&root, root.join("loop")).is_ok();
7224 let inputs = expand_batch_resources(&[root.to_string_lossy().into_owned()]).unwrap();
7225 if linked {
7226 assert_eq!(inputs.len(), 1, "symlinked directory must not be followed");
7227 }
7228 assert!(inputs[0].ends_with("a.md"));
7229 let _ = std::fs::remove_dir_all(&root);
7230 }
7231
7232 #[test]
7233 fn subcommand_mode_wins_over_config_mode_default() {
7234 let s = |v: &str| v.to_string();
7235 assert!(selects_mode_explicitly(&[s("json"), s("file.json")]));
7236 assert!(selects_mode_explicitly(&[s("--markdown"), s("file.md")]));
7237 assert!(!selects_mode_explicitly(&[
7239 s("--title"),
7240 s("json"),
7241 s("file")
7242 ]));
7243 assert!(!selects_mode_explicitly(&[s("file.md")]));
7244
7245 let root = std::env::temp_dir().join(format!("rich-config-mode-{}", std::process::id()));
7246 let _ = std::fs::remove_dir_all(&root);
7247 std::fs::create_dir_all(&root).unwrap();
7248 let config = root.join("config.toml");
7249 std::fs::write(&config, "[defaults]\nmode = \"markdown\"\n").unwrap();
7250 let merged = config_args(
7251 &[
7252 "--config".to_string(),
7253 config.to_string_lossy().into_owned(),
7254 "json".to_string(),
7255 "file.json".to_string(),
7256 ],
7257 &ConfigRoots {
7258 home: None,
7259 cwd: root.clone(),
7260 no_color_env: false,
7261 },
7262 )
7263 .unwrap();
7264 assert!(
7265 !merged.iter().any(|arg| arg == "--markdown"),
7266 "config mode must not override an explicit subcommand: {merged:?}"
7267 );
7268 let _ = std::fs::remove_dir_all(&root);
7269 }
7270
7271 #[test]
7272 fn suffix_collisions_step_past_files_already_on_disk() {
7273 let root = std::env::temp_dir().join(format!("rich-batch-suffix-{}", std::process::id()));
7274 let _ = std::fs::remove_dir_all(&root);
7275 std::fs::create_dir_all(&root).unwrap();
7276 let target = root.join("out.html");
7277 std::fs::write(&target, "existing").unwrap();
7278 let target = target.to_string_lossy().into_owned();
7279
7280 let cli = parse(&[
7281 "--no-config".to_string(),
7282 "--batch".to_string(),
7283 "--collision".to_string(),
7284 "suffix".to_string(),
7285 "x".to_string(),
7286 ])
7287 .unwrap()
7288 .unwrap();
7289 let mut taken = std::collections::BTreeSet::new();
7290 let first = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7291 assert!(
7292 first.ends_with("out-2.html"),
7293 "existing file must be kept: {first}"
7294 );
7295 let second = resolve_destination(&cli, target.clone(), &mut taken).unwrap();
7296 assert!(second.ends_with("out-3.html"));
7297
7298 let mut cli_error = cli.clone();
7300 cli_error.collision = CollisionPolicy::Error;
7301 let mut taken = std::collections::BTreeSet::new();
7302 let (class, message) =
7303 resolve_destination(&cli_error, target.clone(), &mut taken).unwrap_err();
7304 assert_eq!(class, ExitClass::Input);
7305 assert!(message.contains("--overwrite"));
7306
7307 let mut cli_overwrite = cli.clone();
7309 cli_overwrite.overwrite = true;
7310 let mut taken = std::collections::BTreeSet::new();
7311 assert_eq!(
7312 resolve_destination(&cli_overwrite, target.clone(), &mut taken).unwrap(),
7313 target
7314 );
7315 let _ = std::fs::remove_dir_all(&root);
7316 }
7317
7318 #[test]
7319 fn batch_outputs_keep_their_export_kind() {
7320 let outputs = BatchOutputs {
7322 html: batch_export_path(Some("result.out"), "a.md", 0, 1),
7323 ..BatchOutputs::default()
7324 };
7325 assert_eq!(outputs.html.as_deref(), Some("result.out"));
7326 assert!(outputs.svg.is_none());
7327 }
7328
7329 #[test]
7330 fn parses_export_flags() {
7331 let s = |v: &str| v.to_string();
7332 let cli = parse(&[s("--export-svg"), s("out.svg"), s("x")])
7334 .unwrap()
7335 .unwrap();
7336 assert_eq!(cli.export_svg.as_deref(), Some("out.svg"));
7337 assert!(cli.export_html.is_none());
7338 assert_eq!(cli.resource.as_deref(), Some("x"));
7339
7340 let cli = parse(&[s("-o"), s("out.html"), s("x")]).unwrap().unwrap();
7341 assert_eq!(cli.export_html.as_deref(), Some("out.html"));
7342
7343 let cli = parse(&[
7345 s("--export-html"),
7346 s("a.html"),
7347 s("--export-svg"),
7348 s("b.svg"),
7349 s("x"),
7350 ])
7351 .unwrap()
7352 .unwrap();
7353 assert_eq!(cli.export_html.as_deref(), Some("a.html"));
7354 assert_eq!(cli.export_svg.as_deref(), Some("b.svg"));
7355
7356 assert!(parse(&[s("--export-html")]).is_err());
7358 }
7360
7361 #[test]
7362 fn is_number_matches_pattern() {
7363 for ok in [
7368 "0", "42", "-7", "3.14", "-0.5", "", "-", ".", "-.", "5.", ".5",
7369 ] {
7370 assert!(is_number(ok), "{ok:?} should be numeric");
7371 }
7372 for no in [" 12 ", "1.2.3", "1e5", "abc", "5%"] {
7373 assert!(!is_number(no), "{no:?} should not be numeric");
7374 }
7375 }
7376
7377 #[test]
7378 fn render_csv_matches_upstream() {
7379 let table = build_csv_table(
7383 "Name,Age,City\nAlice,30,NYC\nBob,25,LA\n",
7384 Some(','),
7385 None,
7386 None,
7387 (None, None),
7388 false,
7389 )
7390 .expect("the sniffer reads this one");
7391 let out = Console::builder()
7392 .force_terminal(true)
7393 .color_system(Some(ColorSystem::Truecolor))
7394 .width(40)
7395 .no_color(false)
7396 .build()
7397 .render_to_string(&table);
7398 let expected = concat!(
7399 "\x1b[34m┏━━━━━━━┳━━━━━┳━━━━━━┓\x1b[0m\n",
7400 "\x1b[34m┃\x1b[0m\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m",
7401 "\x1b[1;32m \x1b[0m\x1b[1;32mAge\x1b[0m\x1b[1;32m \x1b[0m\x1b[34m┃\x1b[0m",
7402 "\x1b[1m \x1b[0m\x1b[1mCity\x1b[0m\x1b[1m \x1b[0m\x1b[34m┃\x1b[0m\n",
7403 "\x1b[34m┡━━━━━━━╇━━━━━╇━━━━━━┩\x1b[0m\n",
7404 "\x1b[34m│\x1b[0m Alice \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 30\x1b[0m",
7405 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m NYC \x1b[34m│\x1b[0m\n",
7406 "\x1b[34m│\x1b[0m Bob \x1b[34m│\x1b[0m\x1b[1;32m \x1b[0m\x1b[1;32m 25\x1b[0m",
7407 "\x1b[1;32m \x1b[0m\x1b[34m│\x1b[0m LA \x1b[34m│\x1b[0m\n",
7408 "\x1b[34m└───────┴─────┴──────┘\x1b[0m",
7409 );
7410 assert_eq!(out, expected);
7411 }
7412}