Repository: yshavit/mdq
Pull request #337 (2025-04-13 19:39:00)

expose Cli args as RunOptions
- CliOptions still exists, but is hidden
- RunOptions is visible, and that's what people should use
- define the args via a macro, so that they're in sync

This doesn't hide clap from the dependencies, but it does hide it from the public-facing API.

In service of #307.

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

The changes update the command-line interface by replacing the original `Cli` type with a new `RunOptions` type. This involves renaming the main structure, updating its field visibilities from restricted to public, and adding a default implementation along with a new `set_br` method for managing line break options. Code modifications span multiple files: the main module updates its import and parsing call, the run module adjusts function parameter types and related validations, and integration tests now instantiate `RunOptions` instead of `Cli`. The overall functionality remains intact while the interface for command-line parsing and configuration is restructured.

Grading: A

</details>

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between dd78373fc4387bb93ef9b14d24eb59916ab37fbb and 66f1d4b14e2dbaf52337e3da505b6421213e7277.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `src/main.rs` (2 hunks)
* `src/run/cli.rs` (8 hunks)
* `src/run/run.rs` (3 hunks)
* `tests/integ_test.rs` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (2)</summary>

<details>
<summary>src/main.rs (2)</summary><blockquote>

<details>
<summary>tests/integ_test.rs (1)</summary>

* `run` (66-81)

</details>
<details>
<summary>src/run/run.rs (1)</summary>

* `run` (133-144)

</details>

</blockquote></details>
<details>
<summary>src/run/run.rs (1)</summary><blockquote>

<details>
<summary>tests/integ_test.rs (1)</summary>

* `run` (66-81)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (14)</summary><blockquote>

<details>
<summary>src/main.rs (2)</summary>

`2-2`: **Great replacement of Cli with RunOptions in imports. Grade: A**

The import statement has been updated to use `RunOptions` instead of `Cli`, consistent with the changes in the rest of the codebase. This is a clean implementation.

---

`31-31`: **Excellent adaptation of the parse call. Grade: A**

The code correctly updates `Cli::parse()` to `RunOptions::parse()`, maintaining compatibility while using the new type name. This ensures the command-line parsing mechanism uses the newly exposed options structure.

</details>
<details>
<summary>tests/integ_test.rs (1)</summary>

`68-68`: **Well-implemented update to the integration test. Grade: A**

The change from `mdq::run::Cli::try_parse_from` to `mdq::run::RunOptions::try_parse_from` maintains consistent naming across the codebase. The test functionality remains intact while properly using the renamed structure.

</details>
<details>
<summary>src/run/run.rs (3)</summary>

`4-4`: **Clean import update with OutputFormat addition. Grade: A**

The import has been properly updated to include both `OutputFormat` and `RunOptions` instead of just `Cli`. This ensures all necessary types are available in this module.

---

`133-133`: **Consistent function signature update. Grade: A**

The `run` function signature has been updated to accept `&RunOptions` instead of `&Cli`, ensuring compatibility with the renamed structure throughout the codebase.

---

`146-146`: **Proper adaptation of run_or_error signature. Grade: A**

The `run_or_error` function signature has been updated to use `&RunOptions` instead of `&Cli`, maintaining consistency with the refactoring while preserving functionality.

</details>
<details>
<summary>src/run/cli.rs (8)</summary>

`7-9`: **Great structure renaming with preserved attributes. Grade: A**

The struct has been properly renamed from `Cli` to `RunOptions`, with all attributes (`Parser`, `Debug`, etc.) maintained. This naming better reflects its purpose as configuration options for running commands.

---

`15-15`: **Excellent visibility changes to expose fields. Grade: A**

Fields have been changed from `pub(crate)` to `pub`, making them accessible outside the crate. This fulfills the PR objective of exposing CLI arguments as run options that can be constructed programmatically.



Also applies to: 19-19, 22-22, 25-25, 29-29, 60-60, 76-76, 80-80, 93-93

---

`96-115`: **Well-implemented Default trait. Grade: A**

The `Default` implementation for `RunOptions` provides sensible defaults for all fields, making it easier to create new instances with minimal configuration. This improves API usability for consumers.

---

`117-126`: **Good addition of the set_br method. Grade: B+**

The new `set_br` method provides a convenient way to configure line break options based on an optional boolean input. This enhances the usability of the API by simplifying line break configuration.

---

`155-156`: **Consistent command() method updates. Grade: A**

Command generation calls have been updated from `Cli::command()` to `RunOptions::command()`, maintaining consistent naming throughout the error handling code.



Also applies to: 168-169

---

`241-242`: **Proper test imports and verification updates. Grade: A**

The test imports and command verification have been updated to use `RunOptions` instead of `Cli`, ensuring tests remain functional with the new type name.



Also applies to: 248-249

---

`259-265`: **Excellent additional test for default implementation. Grade: A**

This new test verifies that the default implementation is equivalent to parsing with no arguments, ensuring consistency between both ways of creating a `RunOptions` instance.

---

`269-273`: **Properly updated remaining tests. Grade: A**

All test instantiations have been updated from `Cli::try_parse_from` to `RunOptions::try_parse_from`, and the `check_err` function signature has been updated accordingly. This maintains test coverage for the renamed structure.



Also applies to: 277-281, 285-288, 292-307, 308-315

</details>

</blockquote></details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yshavit/mdq&utm_content=337):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->

Patch:
diff --git a/src/main.rs b/src/main.rs
index 93a0b95..04f2355 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,5 @@
 use clap::Parser;
-use mdq::run::{Cli, Error, OsFacade};
+use mdq::run::{CliOptions, Error, OsFacade};
 use std::io;
 use std::io::{stdin, stdout, Read};
 use std::process::ExitCode;
@@ -28,9 +28,13 @@ impl OsFacade for RealOs {
 }
 
 fn main() -> ExitCode {
-    let cli = Cli::parse();
+    let cli = CliOptions::parse();
 
-    if mdq::run::run(&cli, &mut RealOs) {
+    if !cli.extra_validation() {
+        return ExitCode::FAILURE;
+    }
+
+    if mdq::run::run(&cli.into(), &mut RealOs) {
         ExitCode::SUCCESS
     } else {
         ExitCode::FAILURE
diff --git a/src/run/cli.rs b/src/run/cli.rs
index ce25a8a..f1dab8d 100644
--- a/src/run/cli.rs
+++ b/src/run/cli.rs
@@ -1,55 +1,149 @@
 use crate::output::{LinkTransform, ReferencePlacement};
 use clap::error::ErrorKind;
 use clap::{CommandFactory, Parser, ValueEnum};
-use std::borrow::Cow;
 use std::fmt::{Display, Formatter};
 
-#[derive(Parser, Debug)]
-#[command(version, about, long_about = None)]
-#[doc(hidden)]
-pub struct Cli {
+macro_rules! create_options_structs {
+    (
+        $(
+            $(#[$meta:meta])*
+            clap $clap:tt
+            pub $name:ident : $ty:ty
+        ),* $(,)?
+    ) => {
+        #[derive(Parser, Debug)]
+        #[command(version, about, long_about = None)]
+        #[doc(hidden)]
+        pub struct CliOptions {
+            $(
+            $(#[$meta])*
+            #[arg$clap]
+            pub(crate) $name: $ty,
+            )*
+
+            // clap-only stuff:
+
+            /// Include breaks between elements in plain and markdown output mode.
+            ///
+            /// For plain, this will add a blank line between elements. For markdown, this will add a thematic break
+            /// ("-----") between elements.
+            ///
+            /// This has no effect in JSON output mode.
+            ///
+            /// This defaults to true for Markdown output, and false for plain text output.
+            // Note: this is a fake arg so we have explicit validation below to ensure it isn't invoked. Clap doesn't let us
+            // add boolean flags with 'no-' to disable, so I'm using this trick to fake that out. Basically, this fake arg is
+            // only for the help text, and then --br and --no-br are fake but hidden args.
+            #[arg(long = "[no]-br", action)]
+            pub(crate) br_umbrella: bool,
+
+            /// Negates the --br option.
+            #[arg(long, hide = true)]
+            pub(crate) br: bool,
+
+            /// Negates the --br option.
+            #[arg(long, conflicts_with = "br", hide = true)]
+            pub(crate) no_br: bool,
+
+            #[arg(
+                short = ' ',
+                hide = true,
+                group = "selectors_group",
+                value_name = "selectors starting with list"
+            )]
+            pub(crate) list_selector: Option<String>,
+
+            /// The selectors string
+            #[arg(group = "selectors_group", value_name = "selectors")]
+            pub(crate) selectors: Option<String>,
+
+            /// An optional list of Markdown files to parse, by path. If not provided, standard input will be used.
+            ///
+            /// If these are provided, mdq will act as if they were all concatenated into a single file. For example, if you
+            /// use --link-pos=doc, the link definitions for all input files will be at the very end of the output.
+            ///
+            /// A path of "-" represents standard input.
+            ///
+            /// If these are provided, standard input will not be used unless one of the arguments is "-". Files will be
+            /// processed in the order you provide them. If you provide the same file twice, mdq will process it twice, unless
+            /// that file is "-"; all but the first "-" paths are ignored.
+            #[arg()]
+            pub(crate) markdown_file_paths: Vec<String>,
+        }
+
+        #[derive(Parser, Debug, PartialEq, Eq)]
+        pub struct RunOptions {
+            $(
+            $(#[$meta])*
+            #[arg$clap]
+            pub $name: $ty,
+            )*
+
+            /// Whether to include breaks between elements. This is analogous to the `--[no-]br` option in the CLI
+            /// arguments.
+            pub add_breaks: Option<bool>,
+
+            pub selectors: String,
+
+            pub markdown_file_paths: Vec<String>
+        }
+
+        impl From<CliOptions> for RunOptions {
+            fn from(mut value: CliOptions) -> Self {
+                let add_breaks = match (value.br, value.no_br) {
+                    (false, false) => None,
+                    (true, false) => Some(true),
+                    (false, true) => Some(false),
+                    (true, true) => {
+                        // Clap will prevent this from happening. See test [tests::both_br_and_no_br] below.
+                        eprintln!("internal error when determining whether to add breaks between elements. will use default settings");
+                        None
+                    }
+                };
+                let selectors = match value.selectors.take() {
+                    Some(s) => s,
+                    None => match &value.list_selector {
+                        Some(list_selectors) => {
+                            let mut reconstructed = String::with_capacity(list_selectors.len() + 2);
+                            reconstructed.push_str("- ");
+                            reconstructed.push_str(list_selectors);
+                            reconstructed
+                        }
+                        None => String::new(),
+                    },
+                };
+                Self {
+                    $($name: value.$name,)*
+                    markdown_file_paths: value.markdown_file_paths,
+                    add_breaks,
+                    selectors,
+                }
+            }
+        }
+    };
+}
+
+create_options_structs! {
     /// Where to put link references.
     ///
     /// For links and images of style `[description][1]`, this flag controls where to put the `[1]: https://example.com`
     /// definition.
-    #[arg(long, value_enum, default_value_t=ReferencePlacement::Section)]
-    pub(crate) link_pos: ReferencePlacement,
+    clap(long, value_enum, default_value_t=ReferencePlacement::Section)
+    pub link_pos: ReferencePlacement,
 
     /// Where to put footnote references. Defaults to be same as --link-pos
-    #[arg(long, value_enum)]
-    pub(crate) footnote_pos: Option<ReferencePlacement>,
+    clap(long, value_enum)
+    pub footnote_pos: Option<ReferencePlacement>,
 
-    #[arg(long, short, value_enum, default_value_t=LinkTransform::Reference)]
-    pub(crate) link_format: LinkTransform,
+    clap(long, short, value_enum, default_value_t=LinkTransform::Reference)
+    pub link_format: LinkTransform,
 
-    #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
-    pub(crate) renumber_footnotes: bool,
+    clap(long, default_value_t = true, action = clap::ArgAction::Set)
+    pub renumber_footnotes: bool,
 
     /// Output the results as a JSON object, instead of as markdown.
-    #[arg(long, short, default_value_t = OutputFormat::Markdown)]
-    pub(crate) output: OutputFormat,
-
-    /// Include breaks between elements in plain and markdown output mode.
-    ///
-    /// For plain, this will add a blank line between elements. For markdown, this will add a thematic break
-    /// ("-----") between elements.
-    ///
-    /// This has no effect in JSON output mode.
-    ///
-    /// This defaults to true for Markdown output, and false for plain text output.
-    // Note: this is a fake arg so we have explicit validation below to ensure it isn't invoked. Clap doesn't let us
-    // add boolean flags with 'no-' to disable, so I'm using this trick to fake that out. Basically, this fake arg is
-    // only for the help text, and then --br and --no-br are fake but hidden args.
-    #[arg(long = "[no]-br", action)]
-    pub(crate) br_umbrella: bool,
-
-    /// Negates the --br option.
-    #[arg(long, hide = true)]
-    pub(crate) br: bool,
-
-    /// Negates the --br option.
-    #[arg(long, conflicts_with = "br", hide = true)]
-    pub(crate) no_br: bool,
+    clap(long, short, default_value_t = OutputFormat::Markdown)
+    pub output: OutputFormat,
 
     /// The number of characters to wrap text at. This is only valid when the output format is
     /// markdown.
@@ -57,72 +151,52 @@ pub struct Cli {
     /// Certain elements (like section headings and link definitions) will never be wrapped, and the
     /// wrapping will never break a word; it will only ever be along existing whitespace. In
     /// particular, this means the wrapping will never add hyphens, and it will never break URLs.
-    #[arg(long)]
-    pub(crate) wrap_width: Option<usize>,
-
-    #[arg(
-        short = ' ',
-        hide = true,
-        group = "selectors_group",
-        value_name = "selectors starting with list"
-    )]
-    pub(crate) list_selector: Option<String>,
-
-    /// The selectors string
-    #[arg(group = "selectors_group", value_name = "selectors")]
-    pub(crate) selectors: Option<String>,
+    clap(long)
+    pub wrap_width: Option<usize>,
 
     /// Quiet: do not print anything to stdout. The exit code will still be 0 if any elements match, and non-0 if none do.
-    #[arg(long, short)]
-    pub(crate) quiet: bool,
+    clap(long, short)
+    pub quiet: bool,
 
     // See: tree.rs > Lookups::unknown_markdown.
-    #[arg(long, hide = true)]
-    pub(crate) allow_unknown_markdown: bool,
-
-    /// An optional list of Markdown files to parse, by path. If not provided, standard input will be used.
-    ///
-    /// If these are provided, mdq will act as if they were all concatenated into a single file. For example, if you
-    /// use --link-pos=doc, the link definitions for all input files will be at the very end of the output.
-    ///
-    /// A path of "-" represents standard input.
-    ///
-    /// If these are provided, standard input will not be used unless one of the arguments is "-". Files will be
-    /// processed in the order you provide them. If you provide the same file twice, mdq will process it twice, unless
-    /// that file is "-"; all but the first "-" paths are ignored.
-    #[arg()]
-    pub(crate) markdown_file_paths: Vec<String>,
+    clap(long, hide = true)
+    pub allow_unknown_markdown: bool,
 }
 
-impl Cli {
-    pub fn selector_string(&self) -> Cow<String> {
-        match &self.selectors {
-            Some(s) => Cow::Borrowed(s),
-            None => match &self.list_selector {
-                Some(list_selectors) => {
-                    let mut reconstructed = String::with_capacity(list_selectors.len() + 2);
-                    reconstructed.push_str("- ");
-                    reconstructed.push_str(list_selectors);
-                    Cow::Owned(reconstructed)
-                }
-                None => Cow::Owned(String::new()),
-            },
+impl Default for RunOptions {
+    fn default() -> Self {
+        Self {
+            link_pos: ReferencePlacement::Section,
+            footnote_pos: None,
+            link_format: LinkTransform::Reference,
+            renumber_footnotes: true,
+            output: OutputFormat::Markdown,
+            add_breaks: None,
+            wrap_width: None,
+            selectors: "".to_string(),
+            quiet: false,
+            allow_unknown_markdown: false,
+            markdown_file_paths: vec![],
         }
     }
+}
 
+impl RunOptions {
     pub fn should_add_breaks(&self) -> bool {
-        match self.output {
+        self.add_breaks.unwrap_or_else(|| match self.output {
             OutputFormat::Json => false,
-            OutputFormat::Markdown | OutputFormat::Md => !self.no_br,
-            OutputFormat::Plain => self.br,
-        }
+            OutputFormat::Markdown | OutputFormat::Md => true,
+            OutputFormat::Plain => false,
+        })
     }
+}
 
+impl CliOptions {
     pub fn extra_validation(&self) -> bool {
         match self.output {
             OutputFormat::Json => {
                 if matches!(self.wrap_width, Some(_)) {
-                    let _ = Cli::command()
+                    let _ = CliOptions::command()
                         .error(
                             ErrorKind::ArgumentConflict,
                             "Can't set text width with JSON output format",
@@ -135,7 +209,7 @@ impl Cli {
             OutputFormat::Plain => {}
         }
         if self.br_umbrella {
-            let _ = Cli::command()
+            let _ = CliOptions::command()
                 .error(
                     ErrorKind::UnknownArgument,
                     r"invalid argument '--[no]-br'; use '--br' or '--no-br'.",
@@ -208,50 +282,64 @@ impl Display for OutputFormat {
 
 #[cfg(test)]
 mod tests {
-    use crate::run::cli::Cli;
+    use crate::run::cli::CliOptions;
+    use crate::run::RunOptions;
     use crate::util::utils_for_test::*;
     use clap::{Error, Parser};
 
     #[test]
     fn verify_cli() {
         use clap::CommandFactory;
-        Cli::command().debug_assert();
+        CliOptions::command().debug_assert();
     }
 
     #[test]
     fn no_args() {
-        let result = Cli::try_parse_from(["mdq"]);
+        let result = CliOptions::try_parse_from(["mdq"]);
         unwrap!(result, Ok(cli));
-        assert_eq!(cli.selector_string().as_str(), "");
         assert!(cli.markdown_file_paths.is_empty());
+        let run_opts: RunOptions = cli.into();
+        assert_eq!(run_opts.selectors, "");
+    }
+
+    #[test]
+    fn no_args_equals_default() {
+        let result = CliOptions::try_parse_from(["mdq"]);
+        unwrap!(result, Ok(cli));
+        let default_run_options = RunOptions::default();
+        let from_cli: RunOptions = cli.into();
+        assert_eq!(from_cli, default_run_options);
     }
 
     #[test]
     fn standard_selectors() {
-        let result = Cli::try_parse_from(["mdq", "# hello"]);
+        let result = CliOptions::try_parse_from(["mdq", "# hello"]);
         unwrap!(result, Ok(cli));
-        assert_eq!(cli.selector_string().as_str(), "# hello");
         assert!(cli.markdown_file_paths.is_empty());
+        let run_opts: RunOptions = cli.into();
+        assert_eq!(run_opts.selectors, "# hello");
     }
 
     #[test]
     fn selector_and_file() {
-        let result = Cli::try_parse_from(["mdq", "# hello", "file.txt"]);
+        let result = CliOptions::try_parse_from(["mdq", "# hello", "file.txt"]);
         unwrap!(result, Ok(cli));
-        assert_eq!(cli.selector_string().as_str(), "# hello");
         assert_eq!(cli.markdown_file_paths, ["file.txt"]);
+        let run_opts: RunOptions = cli.into();
+        assert_eq!(run_opts.selectors, "# hello");
     }
 
     #[test]
     fn start_with_list_selectors() {
-        let result = Cli::try_parse_from(["mdq", "- world"]);
+        let result = CliOptions::try_parse_from(["mdq", "- world"]);
         unwrap!(result, Ok(cli));
-        assert_eq!(cli.selector_string().as_str(), "- world");
+        let run_opts: RunOptions = cli.into();
+        assert_eq!(run_opts.selectors, "- world");
     }
 
     #[test]
     fn start_with_list_selectors_twice() {
-        let result = Cli::try_parse_from(["mdq", "- hello", "- world"]);
+        let result = CliOptions::try_parse_from(["mdq", "- hello", "- world"]);
         check_err(
             &result,
             "the argument '-  <selectors starting with list>' cannot be used multiple times",
@@ -260,14 +348,20 @@ mod tests {
 
     #[test]
     fn both_list_and_std_selectors() {
-        let result = Cli::try_parse_from(["mdq", "# hello", "- world"]);
+        let result = CliOptions::try_parse_from(["mdq", "# hello", "- world"]);
         check_err(
             &result,
             "the argument '[selectors]' cannot be used with '-  <selectors starting with list>'",
         )
     }
 
-    fn check_err(result: &Result<Cli, Error>, expect: &str) {
+    #[test]
+    fn both_br_and_no_br() {
+        let result = CliOptions::try_parse_from(["mdq", "--br", "--no-br"]);
+        check_err(&result, "the argument '--br' cannot be used with '--no-br'")
+    }
+
+    fn check_err(result: &Result<CliOptions, Error>, expect: &str) {
         unwrap!(result, Err(e));
         let e_str = e.to_string();
         let first_line = e_str.split('\n').next().expect("no error string found");
diff --git a/src/run/run.rs b/src/run/run.rs
index 2040622..79a29ef 100644
--- a/src/run/run.rs
+++ b/src/run/run.rs
@@ -1,14 +1,14 @@
 use crate::md_elem::{InvalidMd, MdDoc, MdElem, ParseOptions};
 use crate::output::MdWriter;
 use crate::query::ParseError;
-use crate::run::cli::{Cli, OutputFormat};
+use crate::run::cli::OutputFormat;
+use crate::run::RunOptions;
 use crate::select::{Selector, SelectorAdapter};
 use crate::{md_elem, output, query};
 use pest::error::ErrorVariant;
 use pest::Span;
 use std::fmt::{Display, Formatter};
 use std::io::Write;
-use std::ops::Deref;
 use std::{env, io};
 
 #[derive(Debug)]
@@ -130,10 +130,7 @@ pub trait OsFacade {
 
 // TODO: replace with a method that doesn't take OsFacade (that should be defined in main.rs), but instead takes
 //  an FnOnce(MdElem) -> R
-pub fn run(cli: &Cli, os: &mut impl OsFacade) -> bool {
-    if !cli.extra_validation() {
-        return false;
-    }
+pub fn run(cli: &RunOptions, os: &mut impl OsFacade) -> bool {
     match run_or_error(cli, os) {
         Ok(ok) => ok,
         Err(err) => {
@@ -143,7 +140,7 @@ pub fn run(cli: &Cli, os: &mut impl OsFacade) -> bool {
     }
 }
 
-fn run_or_error(cli: &Cli, os: &mut impl OsFacade) -> Result<bool, Error> {
+fn run_or_error(cli: &RunOptions, os: &mut impl OsFacade) -> Result<bool, Error> {
     let contents_str = os.read_all(&cli.markdown_file_paths)?;
     let mut options = ParseOptions::gfm();
     options.allow_unknown_markdown = cli.allow_unknown_markdown;
@@ -154,12 +151,12 @@ fn run_or_error(cli: &Cli, os: &mut impl OsFacade) -> Result<bool, Error> {
         }
     };
 
-    let selectors_str = cli.selector_string();
-    let selectors: Selector = match selectors_str.deref().try_into() {
+    let selectors_str = &cli.selectors;
+    let selectors: Selector = match selectors_str.try_into() {
         Ok(selectors) => selectors,
         Err(error) => {
             return Err(Error::QueryParse(QueryParseError {
-                query_string: selectors_str.into_owned(),
+                query_string: selectors_str.to_string(),
                 error,
             }));
         }


Test patch:
diff --git a/tests/integ_test.rs b/tests/integ_test.rs
index 8411f68..7d47ee9 100644
--- a/tests/integ_test.rs
+++ b/tests/integ_test.rs
@@ -65,14 +65,14 @@ impl<const N: usize> Case<N> {
 
     fn run(&self) -> (bool, String, String) {
         let all_cli_args = ["cmd"].iter().chain(&self.cli_args);
-        let cli = mdq::run::Cli::try_parse_from(all_cli_args).unwrap();
+        let cli = mdq::run::CliOptions::try_parse_from(all_cli_args).unwrap();
         let restore = EnvVarRestore::set_var("MDQ_PORTABLE_ERRORS", "1");
         let mut runner = CaseRunner {
             case: self,
             stdout: vec![],
             stderr: "".to_string(),
         };
-        let result = mdq::run::run(&cli, &mut runner);
+        let result = mdq::run::run(&cli.into(), &mut runner);
 
         let out_str =
             String::from_utf8(runner.stdout).unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned());