Repository: pacak/bpaf
Pull request #137 (2022-12-04 14:57:32)

Release 0.7.7
- generate man pages


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 91d1f82..c1f1471 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "bpaf"
-version = "0.7.6"
+version = "0.7.7"
 edition = "2021"
 categories = ["command-line-interface"]
 description = "A simple Command Line Argument Parser with parser combinators"
@@ -16,9 +16,10 @@ exclude = [".github/workflows", "tarp.sh"]
 [dependencies]
 bpaf_derive = { path = "./bpaf_derive", version = "=0.3.3", optional = true }
 owo-colors = { version = "3.5.0", features = ["supports-colors"], optional = true }
+roff = { version = "0.2.1", optional = true }
 
 [dev-dependencies]
-bpaf = { path = ".",  features = ["derive", "extradocs", "autocomplete"] }
+bpaf = { path = ".",  features = ["derive", "extradocs", "autocomplete", "manpage"] }
 
 
 [package.metadata.docs.rs]
@@ -34,6 +35,7 @@ autocomplete = []
 color = ["owo-colors"] # used internally to switch between different code generation
 bright-color = ["color"]
 dull-color = ["color"]
+manpage = ["roff"]
 
 [[test]]
 name = "completion"
diff --git a/Changelog.md b/Changelog.md
index f08d86f..b4a6f4e 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,6 +1,9 @@
 # Change Log
 
 
+## bpaf [0.7.7] - 2022-12-04
+- manpage generation
+
 ## bpaf [0.7.6] - 2022-11-29
 - fix docs.rs issues
 
diff --git a/src/info.rs b/src/info.rs
index b1fbba9..a7b3f65 100644
--- a/src/info.rs
+++ b/src/info.rs
@@ -52,7 +52,8 @@ impl Error {
 ///
 /// No longer public, users are only interacting with it via [`OptionParser`]
 #[derive(Debug, Clone, Default)]
-pub(crate) struct Info {
+#[doc(hidden)]
+pub struct Info {
     /// version field, see [`version`][Info::version]
     pub version: Option<&'static str>,
     /// Custom description field, see [`descr`][Info::descr]
diff --git a/src/item.rs b/src/item.rs
index d9348d3..80bc966 100644
--- a/src/item.rs
+++ b/src/item.rs
@@ -1,4 +1,4 @@
-use crate::{parsers::NamedArg, Meta};
+use crate::{info::Info, parsers::NamedArg, Meta};
 
 #[doc(hidden)]
 #[derive(Clone, Debug)]
@@ -13,6 +13,7 @@ pub enum Item {
         short: Option<char>,
         help: Option<String>,
         meta: Box<Meta>,
+        info: Box<Info>,
     },
     Flag {
         name: ShortLong,
@@ -29,7 +30,7 @@ pub enum Item {
 }
 
 #[doc(hidden)]
-#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+#[derive(Copy, Clone, Debug)]
 pub enum ShortLong {
     Short(char),
     Long(&'static str),
diff --git a/src/lib.rs b/src/lib.rs
index c668843..8668729 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -357,6 +357,10 @@
 //!   dull-color = ["bpaf/dull-color"]
 //!   ```
 //!   Disabled by default.
+//!
+//! - `manpage`: generate man page from help declaration, see [`OptionParser::as_manpage`]. Disabled by default.
+//!
+//!
 
 #[macro_use]
 #[cfg(feature = "color")]
@@ -396,6 +400,8 @@ mod complete_run;
 mod complete_shell;
 mod info;
 mod item;
+#[cfg(feature = "manpage")]
+mod manpage;
 mod meta;
 mod meta_help;
 mod meta_usage;
@@ -415,6 +421,9 @@ pub use structs::{ParseBox, ParseCon};
 #[cfg(feature = "autocomplete")]
 pub use crate::complete_shell::ShellComp;
 
+#[cfg(feature = "manpage")]
+pub use manpage::Section;
+
 pub mod parsers {
     //! This module exposes parsers that accept further configuration with builder pattern
     //!
diff --git a/src/manpage.rs b/src/manpage.rs
new file mode 100644
index 0000000..3463a0a
--- /dev/null
+++ b/src/manpage.rs
@@ -0,0 +1,484 @@
+use roff::{Inline, Roff};
+
+use crate::{
+    item::ShortLong,
+    meta_help::{HelpItem, HelpItems, ShortLongHelp},
+    OptionParser,
+};
+
+struct Manpage {
+    roff: Roff,
+}
+
+#[derive(Debug, Clone, Copy)]
+/// Manual page section
+pub enum Section<'a> {
+    /// General commands
+    General,
+    /// System calls
+    SystemCall,
+    /// Library functions such as C standard library functions
+    LibraryFunction,
+    /// Special files (usually devices in /dev) and drivers
+    SpecialFile,
+    /// File formats and conventions
+    FileFormat,
+    /// Games and screensavers
+    Game,
+    /// Miscellaneous
+    Misc,
+    /// System administration commands and daemons
+    Sysadmin,
+    /// Custom section
+    Custom(&'a str),
+}
+impl Section<'_> {
+    fn as_str(&self) -> &str {
+        match self {
+            Section::General => "1",
+            Section::SystemCall => "2",
+            Section::LibraryFunction => "3",
+            Section::SpecialFile => "4",
+            Section::FileFormat => "5",
+            Section::Game => "6",
+            Section::Misc => "7",
+            Section::Sysadmin => "8",
+            Section::Custom(s) => s,
+        }
+    }
+}
+
+impl Manpage {
+    /// Create a manpage for application
+    ///
+    /// - `application_name` - binary name without path
+    /// - `section` - manpage section
+    /// - `last_update_date` - free form date when the application was last updated
+    /// - `vendor` - if a program is a part of some project or a suite - it goes here
+    /// - `application_title` - fancier, human readlable application name
+    ///
+    /// In order to specify any optional parameter you also must specify all the preceedint
+    /// optional parameters.
+    fn new(
+        application_name: &str,
+        section: Section,
+        last_update_date: Option<&str>,
+        vendor: Option<&str>,
+        application_title: Option<&str>,
+    ) -> Self {
+        let mut manpage = Self { roff: Roff::new() };
+        manpage.roff.control(
+            "TH",
+            [
+                application_name,
+                section.as_str(),
+                last_update_date.unwrap_or("-"),
+                vendor.unwrap_or("-"),
+                application_title.unwrap_or(""),
+            ]
+            .iter()
+            .copied(),
+        );
+        manpage
+    }
+
+    /// Add an unnumbered section heading
+    fn section<S>(&mut self, title: S) -> &mut Self
+    where
+        S: AsRef<str>,
+    {
+        self.roff.control("SH", [title.as_ref()]);
+        self
+    }
+
+    /// Add an unnumbered subection heading
+    fn subsection<S>(&mut self, title: S) -> &mut Self
+    where
+        S: AsRef<str>,
+    {
+        self.roff.control("SS", [title.as_ref()]);
+        self
+    }
+
+    /// Add an indented label, usually an option description
+    fn label<F>(&mut self, ops: F) -> &mut Self
+    where
+        F: FnMut(&mut Line),
+    {
+        self.roff.control("TP", []);
+        self.paragraph(ops)
+    }
+
+    /// Add a paragraph
+    fn paragraph<F>(&mut self, mut ops: F) -> &mut Self
+    where
+        F: FnMut(&mut Line),
+    {
+        let line = {
+            let mut l = Line {
+                manpage: self,
+                line: Vec::new(),
+            };
+            ops(&mut l);
+            std::mem::take(&mut l.line)
+        };
+        self.roff.text(line);
+        self
+    }
+
+    fn text(&mut self, line: impl Into<Vec<Inline>>) -> &mut Self {
+        self.roff.text(line);
+        self
+    }
+
+    fn render(&self) -> String {
+        self.roff.render()
+    }
+}
+
+struct Line<'a> {
+    manpage: &'a mut Manpage,
+    line: Vec<Inline>,
+}
+
+impl Drop for Line<'_> {
+    fn drop(&mut self) {
+        self.manpage.text(std::mem::take(&mut self.line));
+    }
+}
+impl Line<'_> {
+    fn metavar(&mut self, var: &str) -> &mut Self {
+        self.line.push(italic(var));
+        self
+    }
+
+    fn shortlong(&mut self, name: ShortLongHelp) -> &mut Self {
+        match name.0 {
+            ShortLong::Short(s) => self.line.push(bold(format!("-{}", s))),
+            ShortLong::Long(l) => self.line.push(bold(format!("--{}", l))),
+            ShortLong::ShortLong(s, l) => {
+                self.line.push(bold(format!("-{}", s)));
+                self.line.push(norm(", "));
+                self.line.push(bold(format!("--{}", l)));
+            }
+        }
+        self
+    }
+    fn env(&mut self, name: &str) -> &mut Self {
+        self.line.push(norm(", env variable "));
+        self.line.push(italic(name));
+        self
+    }
+    fn norm<S: Into<String>>(&mut self, s: S) -> &mut Self {
+        self.line.push(norm(s));
+        self
+    }
+    fn bold<S: Into<String>>(&mut self, s: S) -> &mut Self {
+        self.line.push(bold(s));
+        self
+    }
+    fn italic<S: Into<String>>(&mut self, s: S) -> &mut Self {
+        self.line.push(italic(s));
+        self
+    }
+
+    fn space(&mut self) -> &mut Self {
+        self.norm(" ")
+    }
+
+    fn usage(&mut self, usage: &crate::meta_usage::UsageMeta) -> &mut Self {
+        use crate::meta_usage::UsageMeta;
+        match usage {
+            UsageMeta::And(_) => todo!(),
+            UsageMeta::Or(xs) => {
+                for (ix, x) in xs.iter().enumerate() {
+                    if ix > 0 {
+                        self.norm(" | ");
+                    };
+                    self.usage(x);
+                }
+            }
+            UsageMeta::Required(u) => {
+                self.norm('(').usage(u).norm(')');
+            }
+            UsageMeta::Optional(meta) => {
+                self.norm('[').usage(meta).norm(']');
+            }
+            UsageMeta::Many(usage) => {
+                self.usage(usage).norm("...");
+            }
+            UsageMeta::ShortFlag(name) => {
+                self.norm('-').bold(*name);
+            }
+            UsageMeta::ShortArg(name, metavar) => {
+                self.norm('-').bold(*name).norm('=').italic(*metavar);
+            }
+            UsageMeta::LongFlag(name) => {
+                self.norm("--").bold(*name);
+            }
+            UsageMeta::LongArg(name, metavar) => {
+                self.norm("--").bold(*name).norm('=').italic(*metavar);
+            }
+            UsageMeta::Pos(x) | UsageMeta::StrictPos(x) => {
+                self.metavar(x);
+            }
+            UsageMeta::Command => {
+                self.bold("COMMAND").norm("...");
+            }
+        };
+        self
+    }
+}
+
+fn flatten_commands<'a>(help: &HelpItem<'a>, path: &str, acc: &mut Vec<(String, HelpItem<'a>)>) {
+    if let HelpItem::Command { name, meta, .. } = help {
+        acc.push((path.to_string(), *help));
+        let mut hi = HelpItems::default();
+        hi.classify(meta);
+        if !hi.cmds.is_empty() {
+            let path = format!("{} {}", path, name);
+            for help_item in &hi.cmds {
+                flatten_commands(help_item, &path, acc);
+            }
+        }
+    }
+}
+
+fn command_help(manpage: &mut Manpage, help: &HelpItem, path: &str) {
+    if let HelpItem::Command {
+        name,
+        short,
+        help,
+        meta,
+        info,
+    } = help
+    {
+        match short {
+            Some(short) => manpage.subsection(format!("{} {}, {}", path, name, short)),
+            None => manpage.subsection(format!("{} {}", path, name)),
+        };
+        if let Some(help) = help {
+            manpage.text([norm(*help)]);
+        }
+
+        if info.header.is_some() || info.footer.is_some() {
+            manpage.subsection("Description");
+            if let Some(header) = info.header {
+                manpage.text([norm(header), newline()]);
+            }
+
+            if let Some(footer) = info.footer {
+                manpage.text([norm(footer), newline()]);
+            }
+        }
+        let mut hi = HelpItems::default();
+        hi.classify(meta);
+
+        if !hi.psns.is_empty() {
+            manpage.subsection("Positional items");
+            for item in &hi.psns {
+                help_item(manpage, *item, None);
+            }
+        }
+
+        if !hi.flgs.is_empty() {
+            manpage.subsection("Option arguments and flags");
+            for item in &hi.flgs {
+                help_item(manpage, *item, None);
+            }
+        }
+    }
+}
+
+fn help_item(manpage: &mut Manpage, item: HelpItem, command_path: Option<&str>) {
+    match item {
+        HelpItem::Decor { help } => {
+            manpage.subsection(help);
+        }
+        HelpItem::BlankDecor => {
+            manpage.text([]);
+        }
+        HelpItem::Positional {
+            strict: _,
+            metavar,
+            help,
+        } => {
+            manpage.label(|l| {
+                l.metavar(metavar.0);
+            });
+            if let Some(help) = help {
+                manpage.text([norm(help)]);
+            }
+        }
+        HelpItem::Command {
+            name,
+            short: _,
+            help,
+            meta: _,
+            info: _,
+        } => {
+            if let Some(path) = command_path {
+                manpage.label(|l| {
+                    l.bold(path).space().bold(name);
+                });
+                if let Some(help) = help {
+                    manpage.text([norm(help)]);
+                }
+            }
+        }
+        HelpItem::Flag { name, help } => {
+            manpage.label(|l| {
+                l.shortlong(name);
+            });
+            if let Some(help) = help {
+                manpage.text([norm(help)]);
+            }
+        } // todo!(),
+        HelpItem::Argument {
+            name,
+            metavar: mvar,
+            env,
+            help,
+        } => {
+            manpage.label(|l| {
+                l.shortlong(name).norm("=").metavar(mvar.0);
+                if let Some(env) = env {
+                    l.env(env);
+                }
+            });
+
+            if let Some(help) = help {
+                manpage.text([norm(help)]);
+            }
+        } // todo!(),
+    }
+}
+
+fn norm<S: Into<String>>(s: S) -> Inline {
+    Inline::Roman(s.into())
+}
+
+fn bold<S: Into<String>>(s: S) -> Inline {
+    Inline::Bold(s.into())
+}
+
+fn italic<S: Into<String>>(s: S) -> Inline {
+    Inline::Italic(s.into())
+}
+
+fn newline() -> Inline {
+    Inline::LineBreak
+}
+
+impl<T> OptionParser<T> {
+    /// Render `OptionParser` as a [man page](https://en.wikipedia.org/wiki/Man_page)
+    ///
+    /// - `date` - date to display at the end of te man page, free form
+    /// - `app` - application name
+    ///
+    /// # Usage
+    /// You can generate a test file as a part of your test suite:
+    /// ```no_run
+    /// # use bpaf::*;
+    /// #[derive(Debug, Clone, Bpaf)]
+    /// #[bpaf(options)]
+    /// /// Program that performs an operation
+    /// struct Options {
+    ///     flag: bool,
+    /// }
+    ///
+    /// #[test]
+    /// fn update_test_file() {
+    ///     let manpage = options().as_manpage("sample", Section::General, "May 2020");
+    ///     std::fs::write("sample.1", manpage).expect("Unable to save manpage file");
+    /// }
+    /// ```
+    ///
+    /// Requires `manpage` feature which is disabled by default.
+    #[must_use]
+    pub fn as_manpage(&self, app: &str, section: Section, date: &str) -> String {
+        let mut hi = HelpItems::default();
+        let meta = self.inner.meta();
+        hi.classify(&meta);
+
+        let mut manpage = Manpage::new(app, section, Some(date), None, None);
+
+        manpage.section("NAME");
+        manpage.paragraph(|line| {
+            match self.info.descr {
+                Some(descr) => line.norm(app).norm(" - ").norm(descr),
+                None => line.norm(app),
+            };
+        });
+
+        manpage.section("SYNOPSIS");
+        match meta.as_usage_meta() {
+            Some(usage) => manpage.paragraph(|l| {
+                l.bold(app).space().usage(&usage);
+            }),
+            None => manpage.text([bold(app), norm(" takes no parameters")]),
+        };
+
+        manpage.section("DESCRIPTION");
+        if let Some(header) = self.info.header {
+            manpage.text([norm(header), newline()]);
+        }
+
+        if let Some(footer) = self.info.footer {
+            manpage.text([norm(footer), newline()]);
+        }
+
+        // --------------------------------------------------------------
+        if !hi.psns.is_empty() {
+            manpage.subsection("Positional items");
+            for item in &hi.psns {
+                help_item(&mut manpage, *item, None);
+            }
+        }
+
+        if !hi.flgs.is_empty() {
+            manpage.subsection("Option arguments and flags");
+            for item in &hi.flgs {
+                help_item(&mut manpage, *item, None);
+            }
+        }
+
+        if !hi.cmds.is_empty() {
+            manpage.subsection("List of all the subcommands");
+            let mut commands = Vec::new();
+            for item in &hi.cmds {
+                flatten_commands(item, app, &mut commands);
+            }
+            for (path, item) in &commands {
+                help_item(&mut manpage, *item, Some(path));
+            }
+            manpage.section("SUBCOMMANDS WITH OPTIONS");
+
+            for (path, item) in &commands {
+                command_help(&mut manpage, item, path);
+            }
+        }
+
+        // --------------------------------------------------------------
+
+        let authors = env!("CARGO_PKG_AUTHORS").replace(':', ", ");
+        if !authors.is_empty() {
+            manpage.section("AUTHORS");
+            manpage.text([norm(authors)]);
+        }
+
+        let homepage = env!("CARGO_PKG_HOMEPAGE");
+        if !homepage.is_empty() {
+            manpage.section("See also");
+            manpage.text([norm(format!("Homepage: {}", homepage))]);
+        }
+
+        let repo = env!("CARGO_PKG_REPOSITORY");
+        if !repo.is_empty() {
+            manpage.section("REPORTING BUGS");
+            manpage.text([norm(repo)]);
+        }
+
+        manpage.render()
+    }
+}
diff --git a/src/meta_help.rs b/src/meta_help.rs
index eb0a4d5..32759ac 100644
--- a/src/meta_help.rs
+++ b/src/meta_help.rs
@@ -1,5 +1,7 @@
 #![allow(clippy::write_with_newline)]
 #![allow(clippy::match_like_matches_macro)]
+use std::collections::BTreeSet;
+
 use crate::{
     info::Info,
     item::{Item, ShortLong},
@@ -7,8 +9,8 @@ use crate::{
 };
 
 #[doc(hidden)]
-#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
-pub(crate) struct Metavar(&'static str);
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct Metavar(pub(crate) &'static str);
 
 impl std::fmt::Display for Metavar {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -19,7 +21,7 @@ impl std::fmt::Display for Metavar {
     }
 }
 
-#[derive(Debug, Ord, PartialEq, PartialOrd, Eq, Copy, Clone)]
+#[derive(Debug, Copy, Clone)]
 pub(crate) enum HelpItem<'a> {
     Decor {
         help: &'a str,
@@ -34,6 +36,8 @@ pub(crate) enum HelpItem<'a> {
         name: &'static str,
         short: Option<char>,
         help: Option<&'a str>,
+        meta: &'a Meta,
+        info: &'a Info,
     },
     Flag {
         name: ShortLongHelp,
@@ -49,13 +53,13 @@ pub(crate) enum HelpItem<'a> {
 
 #[derive(Default, Debug)]
 pub(crate) struct HelpItems<'a> {
-    cmds: Vec<HelpItem<'a>>,
+    pub(crate) cmds: Vec<HelpItem<'a>>,
     pub(crate) psns: Vec<HelpItem<'a>>,
     pub(crate) flgs: Vec<HelpItem<'a>>,
 }
 
 impl HelpItem<'_> {
-    pub fn is_decor(&self) -> bool {
+    fn is_decor(&self) -> bool {
         match self {
             HelpItem::Decor { .. } | HelpItem::BlankDecor => true,
             _ => false,
@@ -63,9 +67,11 @@ impl HelpItem<'_> {
     }
 }
 
-fn dedup(items: &mut Vec<HelpItem>) {
-    let mut cur = std::collections::BTreeSet::new();
-    items.retain(move |i| i.is_decor() || cur.insert(*i));
+fn dedup(items: &mut BTreeSet<String>, payload: &mut String, prev: usize) {
+    let new = payload[prev..payload.len()].to_owned();
+    if !items.insert(new) {
+        payload.truncate(prev);
+    }
 }
 
 impl<'a> HelpItems<'a> {
@@ -89,10 +95,14 @@ impl<'a> HelpItems<'a> {
                 name,
                 short,
                 help,
-                meta: _,
+
+                info,
+                meta,
             } => {
                 self.cmds.push(HelpItem::Command {
                     name,
+                    info,
+                    meta,
                     short: *short,
                     help: help.as_deref(),
                 });
@@ -159,8 +169,8 @@ impl<'a> HelpItems<'a> {
     }
 }
 
-#[derive(Debug, Ord, PartialEq, PartialOrd, Eq, Copy, Clone)]
-pub(crate) struct ShortLongHelp(ShortLong);
+#[derive(Debug, Copy, Clone)]
+pub(crate) struct ShortLongHelp(pub(crate) ShortLong);
 
 impl ShortLongHelp {
     #[inline]
@@ -248,6 +258,8 @@ impl std::fmt::Display for HelpItem<'_> {
                 name,
                 help: _,
                 short,
+                meta: _,
+                info: _,
             } => match short {
                 Some(s) => write!(f, "    {}, {}", w_flag!(name), w_flag!(s)),
                 None => write!(f, "    {}", w_flag!(name)),
@@ -288,11 +300,14 @@ impl<'a> From<&'a crate::item::Item> for HelpItem<'a> {
                 name,
                 short,
                 help,
-                meta: _,
+                meta,
+                info,
             } => Self::Command {
                 name,
                 short: *short,
                 help: help.as_deref(),
+                meta,
+                info,
             },
             crate::item::Item::Flag {
                 name,
@@ -383,9 +398,7 @@ pub(crate) fn render_help(
     let mut items = HelpItems::default();
     items.classify(parser_meta);
     items.classify(help_meta);
-    dedup(&mut items.psns);
-    dedup(&mut items.flgs);
-    dedup(&mut items.cmds);
+    let mut dedup_cache = BTreeSet::new();
     if !items.psns.is_empty() {
         let max_width = items
             .psns
@@ -395,7 +408,9 @@ pub(crate) fn render_help(
             .unwrap_or(0);
         w_section!(res, "\nAvailable positional items:\n")?;
         for i in &items.psns {
+            let len = res.len();
             write!(res, "{:padding$}\n", i, padding = max_width)?;
+            dedup(&mut dedup_cache, &mut res, len);
         }
     }
 
@@ -408,7 +423,9 @@ pub(crate) fn render_help(
             .unwrap_or(0);
         w_section!(res, "\nAvailable options:\n")?;
         for i in &items.flgs {
+            let len = res.len();
             write!(res, "{:padding$}\n", i, padding = max_width)?;
+            dedup(&mut dedup_cache, &mut res, len);
         }
     }
 
@@ -421,7 +438,9 @@ pub(crate) fn render_help(
             .max()
             .unwrap_or(0);
         for i in &items.cmds {
+            let len = res.len();
             write!(res, "{:padding$}\n", i, padding = max_width)?;
+            dedup(&mut dedup_cache, &mut res, len);
         }
     }
     if let Some(t) = info.footer {
diff --git a/src/params.rs b/src/params.rs
index 9db3608..8d046a5 100644
--- a/src/params.rs
+++ b/src/params.rs
@@ -596,6 +596,7 @@ impl<T> ParseCommand<T> {
             short: self.shorts.first().copied(),
             help: self.help.clone(),
             meta: Box::new(self.subparser.inner.meta()),
+            info: Box::new(self.subparser.info.clone()),
         }
     }
 }


Test patch:
diff --git a/tests/manpage.rs b/tests/manpage.rs
new file mode 100644
index 0000000..75aaab5
--- /dev/null
+++ b/tests/manpage.rs
@@ -0,0 +1,77 @@
+use bpaf::*;
+
+#[test]
+fn simple_manpage() {
+    let manpage = short('d')
+        .long("kraken")
+        .help("Unleash the kraken")
+        .switch()
+        .to_options()
+        .descr("I am a program and I do things")
+        .header("Sometimes they even work.")
+        .footer("Beware `-d`, dragons be here")
+        .as_manpage("simple", Section::General, "Aug 2022");
+
+    let expected = ".ie \\n(.g .ds Aq \\(aq\n.el .ds Aq '\n.TH simple 1 \"Aug 2022\" - \n.SH NAME\n\nsimple \\- I am a program and I do things\n.SH SYNOPSIS\n\n\\fBsimple\\fR [\\-\\fBd\\fR]\n.SH DESCRIPTION\nSometimes they even work.\n.br\n\nBeware `\\-d`, dragons be here\n.br\n\n.SS \"Option arguments and flags\"\n.TP\n\n\\fB\\-d\\fR, \\fB\\-\\-kraken\\fR\nUnleash the kraken\n.SH AUTHORS\nMichael Baykov <manpacket@gmail.com>\n.SH \"REPORTING BUGS\"\nhttps://github.com/pacak/bpaf\n";
+    assert_eq!(manpage, expected);
+}
+
+#[test]
+fn nested_command_manpage() {
+    let a = short('d')
+        .help("dragon")
+        .argument::<String>("y")
+        .to_options()
+        .descr("I am a program and I do things")
+        .header("Sometimes they even work. 1")
+        .footer("Beware `-d`, dragons be here 1")
+        .command("cmd");
+
+    let b = short('k')
+        .help("kraken")
+        .argument("x")
+        .to_options()
+        .descr("I am a program and I do things 2")
+        .header("Sometimes they even work. 2")
+        .footer("Beware `-d`, dragons be here 2")
+        .command("dmc")
+        .short('d');
+
+    let c = positional("C").help("Mystery file");
+
+    let d = short('d').long("ddd").help("mystery arg").argument("D");
+
+    let manpage = construct!([a, b, c, d])
+        .to_options()
+        .descr("I am a program and I do things 3")
+        .header("Sometimes they even work. 3")
+        .footer("Beware `-d`, dragons be here 3")
+        .as_manpage("nested", Section::General, "29 Nov 2022");
+
+    let expected = ".ie \\n(.g .ds Aq \\(aq\n.el .ds Aq '\n.TH nested 1 \"29 Nov 2022\" - \n.SH NAME\n\nnested \\- I am a program and I do things 3\n.SH SYNOPSIS\n\n\\fBnested\\fR (\\fBCOMMAND\\fR... | \\fIC\\fR | \\-\\fBd\\fR=\\fID\\fR)\n.SH DESCRIPTION\nSometimes they even work. 3\n.br\n\nBeware `\\-d`, dragons be here 3\n.br\n\n.SS \"Positional items\"\n.TP\n\n\\fIC\\fR\nMystery file\n.SS \"Option arguments and flags\"\n.TP\n\n\\fB\\-d\\fR, \\fB\\-\\-ddd\\fR=\\fID\\fR\nmystery arg\n.SS \"List of all the subcommands\"\n.TP\n\n\\fBnested\\fR \\fBcmd\\fR\nI am a program and I do things\n.TP\n\n\\fBnested\\fR \\fBdmc\\fR\nI am a program and I do things 2\n.SH \"SUBCOMMANDS WITH OPTIONS\"\n.SS \"nested cmd\"\nI am a program and I do things\n.SS Description\nSometimes they even work. 1\n.br\n\nBeware `\\-d`, dragons be here 1\n.br\n\n.SS \"Option arguments and flags\"\n.TP\n\n\\fB\\-d\\fR=\\fIy\\fR\ndragon\n.SS \"nested dmc, d\"\nI am a program and I do things 2\n.SS Description\nSometimes they even work. 2\n.br\n\nBeware `\\-d`, dragons be here 2\n.br\n\n.SS \"Option arguments and flags\"\n.TP\n\n\\fB\\-k\\fR=\\fIx\\fR\nkraken\n.SH AUTHORS\nMichael Baykov <manpacket@gmail.com>\n.SH \"REPORTING BUGS\"\nhttps://github.com/pacak/bpaf\n";
+    assert_eq!(manpage, expected);
+}
+
+#[test]
+fn very_nested_command() {
+    let manpage = short('k')
+        .help("Unleash the Kraken")
+        .switch()
+        .to_options()
+        .descr("lvl 4 description")
+        .command("lvl4")
+        .to_options()
+        .descr("lvl 3 description")
+        .command("lvl3")
+        .to_options()
+        .descr("lvl 2 description")
+        .command("lvl2")
+        .to_options()
+        .descr("lvl 1 description")
+        .command("lvl1")
+        .to_options()
+        .as_manpage("nested", Section::General, "29 Nov 2022");
+
+    let expected = ".ie \\n(.g .ds Aq \\(aq\n.el .ds Aq '\n.TH nested 1 \"29 Nov 2022\" - \n.SH NAME\n\nnested\n.SH SYNOPSIS\n\n\\fBnested\\fR \\fBCOMMAND\\fR...\n.SH DESCRIPTION\n.SS \"List of all the subcommands\"\n.TP\n\n\\fBnested\\fR \\fBlvl1\\fR\nlvl 1 description\n.TP\n\n\\fBnested lvl1\\fR \\fBlvl2\\fR\nlvl 2 description\n.TP\n\n\\fBnested lvl1 lvl2\\fR \\fBlvl3\\fR\nlvl 3 description\n.TP\n\n\\fBnested lvl1 lvl2 lvl3\\fR \\fBlvl4\\fR\nlvl 4 description\n.SH \"SUBCOMMANDS WITH OPTIONS\"\n.SS \"nested lvl1\"\nlvl 1 description\n.SS \"nested lvl1 lvl2\"\nlvl 2 description\n.SS \"nested lvl1 lvl2 lvl3\"\nlvl 3 description\n.SS \"nested lvl1 lvl2 lvl3 lvl4\"\nlvl 4 description\n.SS \"Option arguments and flags\"\n.TP\n\n\\fB\\-k\\fR\nUnleash the Kraken\n.SH AUTHORS\nMichael Baykov <manpacket@gmail.com>\n.SH \"REPORTING BUGS\"\nhttps://github.com/pacak/bpaf\n";
+    assert_eq!(manpage, expected);
+}