diff --git a/Cargo.lock b/Cargo.lock index df915c6..e03c6dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "termimad", "thiserror", "tokio", "toml_edit 0.22.27", @@ -449,6 +450,24 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "coolor" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" +dependencies = [ + "crossterm", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -484,12 +503,115 @@ dependencies = [ "libc", ] +[[package]] +name = "crokey" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" +dependencies = [ + "crokey-proc_macros", + "crossterm", + "once_cell", + "serde", + "strict", +] + +[[package]] +name = "crokey-proc_macros" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" +dependencies = [ + "crossterm", + "proc-macro2", + "quote", + "strict", + "syn", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -529,6 +651,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -563,6 +707,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1164,6 +1317,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "lazy-regex" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1197,6 +1373,21 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1240,6 +1431,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimad" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b688969b16915f3ecadc7829d5b7779dee4977e503f767f34136803d5c06f" +dependencies = [ + "once_cell", +] + [[package]] name = "mio" version = "1.2.0" @@ -1247,6 +1447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1376,6 +1577,29 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -1616,6 +1840,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1725,6 +1958,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1810,6 +2052,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "secret-service" version = "4.0.0" @@ -1985,6 +2233,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2039,6 +2308,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" + [[package]] name = "strsim" version = "0.11.1" @@ -2095,6 +2370,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termimad" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "889a9370996b74cf46016ce35b96c248a9ac36d69aab1d112b3e09bc33affa49" +dependencies = [ + "coolor", + "crokey", + "crossbeam", + "lazy-regex", + "minimad", + "serde", + "thiserror", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -2348,6 +2639,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2541,6 +2844,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 6f6ef36..0f98f83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ thiserror = "2.0.17" toml_edit = { version = "0.22", features = ["serde"] } tokio = { version = "1.48.0", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tracing = "0.1.43" +termimad = "0.34.1" [target.'cfg(target_os = "linux")'.dependencies] keyring = { version = "3.6.1", optional = true, default-features = false, features = ["async-secret-service", "tokio", "crypto-rust"] } diff --git a/docs/concepts.md b/docs/concepts.md index bdcfe5b..c10156b 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -566,6 +566,21 @@ bytes)` pairs for embedded guides from `include_bytes!`, `include_str!`, or a bu manifest. Modules can also call `Module::with_guides_from_markdown` or `ModuleContext::add_guides_from_markdown`. +### Rendering and authoring + +For human output (the default when stdout is a terminal) `guide ` renders the markdown body with `termimad`, wrapping text to the current terminal width at word boundaries. For `--output json` and `--output toon` the raw markdown body is returned unchanged, so machine-readable output stays byte-for-byte identical to the source file. + +The markdown renderer is line-oriented: it preserves every newline in the source and does not join hard-wrapped lines back into a flowing paragraph. Follow these rules so guides reflow cleanly at any width: + +- Write each paragraph as a single physical line. Do not hard-wrap prose at a fixed column (~80/100) — a hard-wrapped paragraph keeps its authored breaks on wide terminals and only re-wraps the leftover fragments on narrow ones. A one-line paragraph fills whatever width the reader's terminal has. +- Separate blocks (paragraphs, lists, headings) with a blank line. +- Put code inside fenced code blocks. Code is laid out verbatim, never reflowed, so it is the right place for the only line breaks that must survive as authored. +- Use `* ` for bullet lists and keep each item on a single line. A `* ` item that wraps keeps a hanging indent under its text, which is what you want. + +#### Known issues + +The renderer only recognizes `* `-prefixed bullets (with 0–3 leading spaces for nesting) as list items. Ordered/numbered lists (`1.`) and `-`/`+` bullets are treated as ordinary paragraphs, so when one of their items wraps, the continuation lines fall back to the left margin instead of indenting under the item text. Prefer `* ` bullets where wrapping matters; for a numbered sequence, either accept the flush-left wrap or hard-wrap the egregious items by hand. Tracked upstream at [Canop/termimad#75](https://github.com/Canop/termimad/issues/75). + ## Search `--search` searches command metadata, aliases, guides, and extra registered search documents. Search diff --git a/src/cli.rs b/src/cli.rs index dff033b..8017b78 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,7 +30,7 @@ use crate::{ extract_command_path, extract_output_format, extract_search_query, global_flags_from_matches, has_true_schema_flag, register_global_flags, }, - guide::guide_content, + guide::{guide_content, render_guide_human}, module::{Module, ModuleContext}, output::{ HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry, format_help_section, @@ -1497,7 +1497,7 @@ impl Cli { { return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)); } - return self.finish_run(self.render_guide(&matches)); + return self.finish_run(self.render_guide(&matches, &flags.output_format)); } if command_path == "completion" { let args = completion_args(&matches); @@ -1883,14 +1883,45 @@ impl Cli { ) } - fn render_guide(&self, matches: &ArgMatches) -> CliRunOutput { + fn render_guide(&self, matches: &ArgMatches, output_format: &str) -> CliRunOutput { + use std::io::IsTerminal; + + // Reject an invalid explicit `--output` here too, matching the normal + // command path and `render_root`; otherwise an unrecognized value (e.g. + // `--output yaml`) would silently fall through and emit raw content. + if !crate::output::is_valid_output_format(output_format) { + let err = CliCoreError::InvalidOutputFormat(output_format.to_owned()); + return CliRunOutput { + exit_code: exit_code_for_error(&err), + rendered: err.to_string(), + }; + } + let leaf = leaf_matches(matches); let topic = leaf.get_one::("topic").map(String::as_str); match guide_content(&self.guide_entries, topic) { - Ok(rendered) => CliRunOutput { - exit_code: 0, - rendered, - }, + Ok(rendered) => { + // Only reflow an actual guide topic body, and only for human output. + // The topic list is plain text (not markdown) and json/toon keep the + // raw markdown so their output stays deterministic. + let rendered = if topic.is_some() && output_format == "human" { + let is_tty = std::io::stdout().is_terminal(); + // Use the live terminal width when interactive; otherwise a fixed + // width with no color so a piped `--human` remains deterministic. + let width = if is_tty { + usize::from(termimad::terminal_size().0) + } else { + 80 + }; + render_guide_human(&rendered, width, is_tty) + } else { + rendered + }; + CliRunOutput { + exit_code: 0, + rendered, + } + } Err(err) => CliRunOutput { exit_code: 1, rendered: err, diff --git a/src/guide.rs b/src/guide.rs index 4c5f804..4f38986 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -123,6 +123,35 @@ pub fn parse_front_matter(content: &str) -> (String, String) { (summary, body.to_owned()) } +/// Renders guide markdown into a terminal-friendly string. +/// +/// Each source line is individually wrapped to `width` columns, breaking +/// between words rather than mid-word. (A single token longer than `width` — +/// a long URL, say — can still overflow, since it has no interior break +/// point.) The +/// underlying parser is line-oriented and **preserves every source newline**: +/// it does not join soft-wrapped lines into flowing paragraphs. Author guide +/// markdown with each paragraph on a single physical line so it reflows to the +/// terminal width; a paragraph that is hard-wrapped in the source stays wrapped +/// at its authored breaks. See `docs/concepts.md` ("Guides") for authoring +/// guidance. +/// +/// `color` selects a styled skin (`true`, for an interactive terminal) or a +/// plain, unstyled skin (`false`) whose output contains no ANSI escapes and is +/// therefore deterministic for pipes and tests. Fenced code blocks and tables +/// are laid out by the renderer rather than reflowed as prose, so their +/// structure is preserved. +#[must_use] +pub fn render_guide_human(content: &str, width: usize, color: bool) -> String { + let skin = if color { + termimad::MadSkin::default() + } else { + // no_style emits no ANSI escapes — deterministic for pipes and tests. + termimad::MadSkin::no_style() + }; + skin.text(content, Some(width)).to_string() +} + /// Renders the guide topic list. #[must_use] pub fn list_guides(entries: &[GuideEntry]) -> String { @@ -153,3 +182,44 @@ pub fn guide_content(entries: &[GuideEntry], topic: Option<&str>) -> Result 1, + "expected long line to wrap into multiple lines: {rendered:?}", + ); + + // ...without splitting any word across a line boundary. + for word in source.split_whitespace() { + assert!( + rendered.lines().any(|line| line.contains(word)), + "word was split across lines: {word:?}", + ); + } + } +} diff --git a/tests/foundation.rs b/tests/foundation.rs index 087d849..1ea7c15 100644 --- a/tests/foundation.rs +++ b/tests/foundation.rs @@ -1033,6 +1033,81 @@ async fn cli_runtime_guide_command_lists_topics_and_renders_content() { assert_eq!(topic.rendered, "# Deploy\n"); } +#[tokio::test] +async fn cli_runtime_guide_human_reflows_long_lines_but_other_formats_stay_raw() { + use std::io::IsTerminal; + + let long_line = "This is a deliberately long single line of guide prose that should be \ + reflowed by the renderer instead of wrapping mid-word inside a narrow terminal window."; + let content = format!("# Deploy\n\n{long_line}\n"); + + let mut cli = Cli::new(CliConfig { + name: "my-cli".to_owned(), + short: "Developer tooling".to_owned(), + ..CliConfig::default() + }); + cli.add_guides([GuideEntry { + name: "deploy".to_owned(), + summary: "Deploy safely".to_owned(), + content: content.clone(), + }]); + + // json (and the non-TTY default) keep the raw markdown body verbatim. + let raw = cli + .run(["my-cli", "guide", "deploy", "--output", "json"]) + .await; + assert_eq!(raw.exit_code, 0); + assert_eq!(raw.rendered, content); + + // An invalid explicit --output is rejected, matching normal commands + // rather than silently emitting raw content. + let invalid = cli + .run(["my-cli", "guide", "deploy", "--output", "yaml"]) + .await; + assert_ne!(invalid.exit_code, 0); + assert!( + invalid.rendered.contains("invalid output format"), + "expected invalid-output-format error, got: {:?}", + invalid.rendered, + ); + + let human = cli.run(["my-cli", "guide", "deploy", "--human"]).await; + assert_eq!(human.exit_code, 0); + + // The remaining checks describe the deterministic non-TTY path (no color, + // fixed width 80). If the test runs attached to a real terminal (e.g. with + // `-- --nocapture`), `stdout().is_terminal()` is true and the renderer uses + // ANSI color and the live width instead, so skip the strict assertions. + if std::io::stdout().is_terminal() { + return; + } + + assert_ne!(human.rendered, content, "human output should be reflowed"); + assert!( + !human.rendered.contains('\u{1b}'), + "no-color human output must not contain ANSI escapes", + ); + for line in human.rendered.lines() { + assert!( + line.trim_end().chars().count() <= 80, + "human line exceeds width: {line:?}", + ); + } + // The long source line must have been split across several visible lines... + assert!( + human.rendered.lines().count() > content.lines().count(), + "expected the long line to wrap: {:?}", + human.rendered, + ); + // ...without breaking any word across a line boundary. + for word in long_line.split_whitespace() { + assert!( + human.rendered.lines().any(|line| line.contains(word)), + "word was split across lines: {word:?}", + ); + } +} + #[tokio::test] async fn cli_config_registers_modules_guides_views_and_init_once() { #[derive(Debug)]