Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin: Rewrite CLI tests to not require a binary
Erik Kundt committed 8 months ago
commit b4dd057f455b367cc2e43503f7ae10e18cf34965
parent 86e34ac
4 files changed +313 -372
modified bin/commands/inbox.rs
@@ -41,19 +41,22 @@ Other options
"#,
};

+
#[derive(Debug, PartialEq)]
pub struct Options {
    op: Operation,
}

+
#[derive(Debug, PartialEq)]
pub enum Operation {
    List { opts: ListOptions },
    Other { args: Vec<OsString> },
+
    Unknown { args: Vec<OsString> },
}

#[derive(PartialEq, Eq)]
pub enum OperationName {
    List,
-
    Other,
+
    Unknown,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
@@ -133,7 +136,14 @@ impl Args for Options {

                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
                    "list" => op = OperationName::List,
-
                    _ => op = OperationName::Other,
+
                    _ => {
+
                        op = OperationName::Unknown;
+
                        // Only enable forwarding if it was not already disabled explicitly
+
                        forward = match forward {
+
                            Some(false) => Some(false),
+
                            _ => Some(true),
+
                        };
+
                    }
                },
                _ => {
                    if op == OperationName::List {
@@ -169,6 +179,7 @@ impl Args for Options {
            OperationName::List if !forward => Operation::List {
                opts: ListOptions { json, ..list_opts },
            },
+
            OperationName::Unknown if !forward => Operation::Unknown { args },
            _ => Operation::Other { args },
        };

@@ -228,6 +239,9 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
        Operation::Other { args } => {
            let _ = crate::terminal::run_rad(Some("inbox"), &args);
        }
+
        Operation::Unknown { .. } => {
+
            anyhow::bail!("unknown operation provided");
+
        }
    }

    Ok(())
@@ -235,149 +249,138 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul

#[cfg(test)]
mod cli {
-
    use std::process::Command;
-

-
    use assert_cmd::prelude::*;
-

-
    use predicates::prelude::*;
-

-
    mod assert {
-
        use predicates::prelude::*;
-
        use predicates::str::ContainsPredicate;
+
    use radicle_cli::terminal::args::Error;
+
    use radicle_cli::terminal::Args;

-
        pub fn is_tui() -> ContainsPredicate {
-
            predicate::str::contains("Inappropriate ioctl for device")
-
        }
-

-
        pub fn is_rad_manual() -> ContainsPredicate {
-
            predicate::str::contains("rad-inbox")
-
        }
-

-
        pub fn is_inbox_help() -> ContainsPredicate {
-
            predicate::str::contains("Terminal interfaces for notifications")
-
        }
-
    }
+
    use super::{ListOptions, Operation, Options};

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.arg("inbox");
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let args = vec![];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore]
-
    fn empty_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.arg("inbox");
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["--help".into(), "--no-forward".into()];

-
        cmd.args(["inbox", "--help"]);
-
        cmd.assert().success().stdout(assert::is_rad_manual());
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["inbox", "--help", "--no-forward"]);
-
        cmd.assert().success().stdout(assert::is_inbox_help());
+
        let args = vec!["--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["inbox", "--no-forward"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let args = vec!["list".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["inbox", "list"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let args = vec!["list".into(), "--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["inbox", "list", "--no-forward"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;

-
        cmd.args(["inbox", "list", "--help"]);
-
        cmd.assert().success().stdout(assert::is_rad_manual());
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_with_help_should_not_be_forwarded_reversed(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;

-
        cmd.args(["inbox", "list", "--help", "--no-forward"]);
-
        cmd.assert().success().stdout(assert::is_inbox_help());
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_not_forwarded_reversed() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["inbox", "list", "--no-forward", "--help"]);
-
        cmd.assert().success().stdout(assert::is_inbox_help());
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn unknown_operation_show_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.args(["inbox", "show"]);
-
        cmd.assert()
-
            .success()
-
            .stdout(predicate::str::contains("a Notification ID must be given"));
+
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into(), "--no-forward".into()];
+
        let expected_op = Operation::Unknown { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }
modified bin/commands/issue.rs
@@ -48,20 +48,23 @@ Other options
"#,
};

+
#[derive(Debug, PartialEq)]
pub struct Options {
    op: Operation,
    repo: Option<RepoId>,
}

+
#[derive(Debug, PartialEq)]
pub enum Operation {
    List { opts: ListOptions },
    Other { args: Vec<OsString> },
+
    Unknown { args: Vec<OsString> },
}

#[derive(PartialEq, Eq)]
pub enum OperationName {
    List,
-
    Other,
+
    Unknown,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
@@ -145,7 +148,14 @@ impl Args for Options {

                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
                    "list" => op = OperationName::List,
-
                    _ => op = OperationName::Other,
+
                    _ => {
+
                        op = OperationName::Unknown;
+
                        // Only enable forwarding if it was not already disabled explicitly
+
                        forward = match forward {
+
                            Some(false) => Some(false),
+
                            _ => Some(true),
+
                        };
+
                    }
                },
                _ => {
                    if op == OperationName::List {
@@ -169,6 +179,7 @@ impl Args for Options {
            OperationName::List if !forward => Operation::List {
                opts: ListOptions { json, ..list_opts },
            },
+
            OperationName::Unknown if !forward => Operation::Unknown { args },
            _ => Operation::Other { args },
        };

@@ -231,6 +242,9 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
        Operation::Other { args } => {
            let _ = crate::terminal::run_rad(Some("issue"), &args);
        }
+
        Operation::Unknown { .. } => {
+
            anyhow::bail!("unknown operation provided");
+
        }
    }

    Ok(())
@@ -238,175 +252,138 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul

#[cfg(test)]
mod cli {
-
    use std::process::Command;
-

-
    use assert_cmd::prelude::*;
-

-
    use predicates::prelude::*;
-

-
    mod assert {
-
        use predicates::prelude::*;
-
        use predicates::str::ContainsPredicate;
-

-
        pub fn is_tui() -> ContainsPredicate {
-
            predicate::str::contains("Inappropriate ioctl for device")
-
        }
-

-
        pub fn is_rad_manual() -> ContainsPredicate {
-
            predicate::str::contains("rad-issue")
-
        }
-

-
        pub fn is_issue_help() -> ContainsPredicate {
-
            predicate::str::contains("Terminal interfaces for issues")
-
        }
-
    }
-

-
    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.arg("issue");
-
        cmd.assert().failure().stdout(assert::is_tui());
+
    use radicle_cli::terminal::args::Error;
+
    use radicle_cli::terminal::Args;

-
        Ok(())
-
    }
-

-
    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.arg("issue");
-
        cmd.assert().failure().stdout(assert::is_tui());
-

-
        Ok(())
-
    }
+
    use super::{ListOptions, Operation, Options};

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["issue", "--help"]);
-
        cmd.assert().success().stdout(assert::is_rad_manual());
+
        let args = vec![];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["issue", "--help", "--no-forward"]);
-
        cmd.assert().success().stdout(assert::is_issue_help());
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["--help".into(), "--no-forward".into()];

-
        cmd.args(["issue", "--no-forward"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["issue", "list"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let args = vec!["--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["issue", "list", "--no-forward"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let args = vec!["list".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["issue", "list", "--help"]);
-
        cmd.assert().success().stdout(assert::is_rad_manual());
+
        let args = vec!["list".into(), "--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["issue", "list", "--help", "--no-forward"]);
-
        cmd.assert().success().stdout(assert::is_issue_help());
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_not_forwarded_reversed() -> Result<(), Box<dyn std::error::Error>>
+
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
    {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;

-
        cmd.args(["issue", "list", "--no-forward", "--help"]);
-
        cmd.assert().success().stdout(assert::is_issue_help());
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn unknown_operation_show_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_with_help_should_not_be_forwarded_reversed(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;

-
        cmd.args(["issue", "show"]);
-
        cmd.assert().success().stdout(predicate::str::contains(
-
            "Error: rad issue: an issue must be provided",
-
        ));
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn unknown_operation_edit_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["issue", "edit"]);
-
        cmd.assert().success().stdout(predicate::str::contains(
-
            "Error: rad issue: an issue must be provided",
-
        ));
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn unknown_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.args(["issue", "operation", "--no-forward"]);
-
        cmd.assert().success().stdout(predicate::str::contains(
-
            "Error: rad issue: unknown operation",
-
        ));
+
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into(), "--no-forward".into()];
+
        let expected_op = Operation::Unknown { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }
modified bin/commands/patch.rs
@@ -55,14 +55,17 @@ Other options
"#,
};

+
#[derive(Debug, PartialEq)]
pub struct Options {
    op: Operation,
    repo: Option<RepoId>,
}

+
#[derive(Debug, PartialEq)]
pub enum Operation {
    List { opts: ListOptions },
    Review { opts: ReviewOptions },
+
    Unknown { args: Vec<OsString> },
    Other { args: Vec<OsString> },
}

@@ -71,7 +74,7 @@ pub enum Operation {
pub enum OperationName {
    List,
    Review,
-
    Other,
+
    Unknown,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
@@ -197,7 +200,14 @@ impl Args for Options {
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
                    "list" => op = OperationName::List,
                    "review" => op = OperationName::Review,
-
                    _ => op = OperationName::Other,
+
                    _ => {
+
                        op = OperationName::Unknown;
+
                        // Only enable forwarding if it was not already disabled explicitly
+
                        forward = match forward {
+
                            Some(false) => Some(false),
+
                            _ => Some(true),
+
                        };
+
                    }
                },
                Value(val) if patch_id.is_none() => {
                    let val = string(&val);
@@ -237,6 +247,7 @@ impl Args for Options {
                },
            },
            OperationName::List if !forward => Operation::List { opts: list_opts },
+
            OperationName::Unknown if !forward => Operation::Unknown { args },
            _ => Operation::Other { args },
        };

@@ -312,6 +323,9 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
        Operation::Other { args } => {
            let _ = crate::terminal::run_rad(Some("patch"), &args);
        }
+
        Operation::Unknown { .. } => {
+
            anyhow::bail!("unknown operation provided");
+
        }
    }

    Ok(())
@@ -373,7 +387,6 @@ mod interface {

        let patch = patch::find(&profile, &repo, &patch_id)?
            .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
-

        let (_, revision) = opts.revision_or_latest(&patch, &repo)?;
        let hunks = ReviewBuilder::new(&repo).hunks(revision)?;

@@ -485,175 +498,138 @@ mod interface {

#[cfg(test)]
mod cli {
-
    use std::process::Command;
-

-
    use assert_cmd::prelude::*;
-

-
    use predicates::prelude::*;
-

-
    mod assert {
-
        use predicates::prelude::*;
-
        use predicates::str::ContainsPredicate;
-

-
        pub fn is_tui() -> ContainsPredicate {
-
            predicate::str::contains("Inappropriate ioctl for device")
-
        }
+
    use radicle_cli::terminal::args::Error;
+
    use radicle_cli::terminal::Args;

-
        pub fn is_rad_manual() -> ContainsPredicate {
-
            predicate::str::contains("RAD-PATCH(1)")
-
        }
-

-
        pub fn is_patch_help() -> ContainsPredicate {
-
            predicate::str::contains("Terminal interfaces for patches")
-
        }
-
    }
+
    use super::{ListOptions, Operation, Options};

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.arg("patch");
-
        cmd.assert().failure().stdout(assert::is_tui());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.arg("patch");
-
        cmd.assert().failure().stdout(assert::is_tui());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["patch", "--help"]);
-
        cmd.assert().success().stdout(assert::is_rad_manual());
+
        let args = vec![];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["patch", "--help", "--no-forward"]);
-
        cmd.assert().success().stdout(assert::is_patch_help());
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["--help".into(), "--no-forward".into()];

-
        cmd.args(["patch", "--no-forward"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["patch", "list"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let args = vec!["--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["patch", "list", "--no-forward"]);
-
        cmd.assert().failure().stdout(assert::is_tui());
+
        let args = vec!["list".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };

-
        cmd.args(["patch", "list", "--help"]);
-
        cmd.assert().success().stdout(assert::is_rad_manual());
+
        let args = vec!["list".into(), "--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["patch", "list", "--help", "--no-forward"]);
-
        cmd.assert().success().stdout(assert::is_patch_help());
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn list_operation_with_help_is_not_forwarded_reversed() -> Result<(), Box<dyn std::error::Error>>
+
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
    {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;

-
        cmd.args(["patch", "list", "--no-forward", "--help"]);
-
        cmd.assert().success().stdout(assert::is_patch_help());
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn unknown_operation_show_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn list_operation_with_help_should_not_be_forwarded_reversed(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;

-
        cmd.args(["patch", "show"]);
-
        cmd.assert().success().stdout(predicate::str::contains(
-
            "Error: rad patch: a patch must be provided",
-
        ));
+
        assert!(matches!(actual, Error::Help));

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn unknown_operation_edit_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into()];
+
        let expected_op = Operation::Other { args: args.clone() };

-
        cmd.args(["patch", "edit"]);
-
        cmd.assert().success().stdout(predicate::str::contains(
-
            "Error: rad patch: a patch must be provided",
-
        ));
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn unknown_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.args(["patch", "operation", "--no-forward"]);
-
        cmd.assert().success().stdout(predicate::str::contains(
-
            "Error: rad patch: unknown operation",
-
        ));
+
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into(), "--no-forward".into()];
+
        let expected_op = Operation::Unknown { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);

        Ok(())
    }
modified bin/main.rs
@@ -9,6 +9,7 @@ mod terminal;
mod test;
mod ui;

+
use std::env::args_os;
use std::ffi::OsString;
use std::io;
use std::{iter, process};
@@ -55,13 +56,13 @@ enum CommandName {
    Version,
}

-
#[derive(Debug)]
+
#[derive(Default, Debug, PartialEq)]
struct OtherOptions {
    args: Vec<OsString>,
    forward: bool,
}

-
#[derive(Debug)]
+
#[derive(Debug, PartialEq)]
enum Command {
    Other { opts: OtherOptions },
    Help,
@@ -69,7 +70,9 @@ enum Command {
}

fn main() {
-
    match parse_args().and_then(run) {
+
    let args = args_os().collect::<Vec<_>>();
+

+
    match parse_args(&args[1..]).and_then(run) {
        Ok(_) => process::exit(0),
        Err(err) => {
            match err {
@@ -83,10 +86,10 @@ fn main() {
    }
}

-
fn parse_args() -> anyhow::Result<Command, Error> {
+
fn parse_args(args: &[OsString]) -> anyhow::Result<Command, Error> {
    use lexopt::prelude::*;

-
    let mut parser = lexopt::Parser::from_env();
+
    let mut parser = lexopt::Parser::from_args(args);
    let mut command = None;
    let mut forward = true;
    let mut json = false;
@@ -227,115 +230,97 @@ fn run_other(command: Option<&str>, args: &[OsString]) -> Result<(), Error> {

#[cfg(test)]
mod cli {
-
    use assert_cmd::prelude::*;
-
    use predicates::prelude::*;
-
    use std::process::Command;
-

-
    mod assert {
-
        use predicates::prelude::*;
-
        use predicates::str::ContainsPredicate;
-

-
        pub fn is_rad_manual() -> ContainsPredicate {
-
            predicate::str::contains("Radicle CLI Manual")
-
        }
-

-
        pub fn is_rad_help() -> ContainsPredicate {
-
            predicate::str::contains("Radicle command line interface")
-
        }
-

-
        pub fn is_help() -> ContainsPredicate {
-
            predicate::str::contains("Radicle terminal interfaces")
-
        }
-
    }
+
    use crate::{parse_args, OtherOptions};

    #[test]
-
    #[ignore = "requires binary"]
-
    fn can_be_executed() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
-

-
        cmd.assert().success();
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_command_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_command_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec![];
+
        let expected = super::Command::Other {
+
            opts: OtherOptions {
+
                args: args.clone(),
+
                forward: true,
+
            },
+
        };

-
        cmd.assert().success().stdout(assert::is_rad_help());
+
        let actual = parse_args(&args)?;
+
        assert_eq!(actual, expected);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn empty_command_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn empty_command_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["--no-forward".into()];
+
        let expected = super::Command::Other {
+
            opts: OtherOptions::default(),
+
        };

-
        cmd.arg("--no-forward");
-
        cmd.assert().success().stdout(assert::is_help());
+
        let actual = parse_args(&args)?;
+
        assert_eq!(actual, expected);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn version_command_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn version_command_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["version".into()];
+
        let expected = super::Command::Other {
+
            opts: OtherOptions {
+
                args: args.clone(),
+
                forward: true,
+
            },
+
        };

-
        cmd.arg("version");
-
        cmd.assert()
-
            .success()
-
            .stdout(predicate::str::starts_with("rad "));
+
        let actual = parse_args(&args)?;
+
        assert_eq!(actual, expected);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn version_command_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn version_command_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["version".into(), "--no-forward".into()];
+
        let expected = super::Command::Version { json: false };

-
        cmd.arg("version").arg("--no-forward");
-
        cmd.assert()
-
            .success()
-
            .stdout(predicate::str::starts_with("rad-tui "));
+
        let actual = parse_args(&args)?;
+
        assert_eq!(actual, expected);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn version_command_prints_json() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn version_command_should_print_json() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["version".into(), "--no-forward".into(), "--json".into()];
+
        let expected = super::Command::Version { json: true };

-
        cmd.arg("version").arg("--no-forward").arg("--json");
-
        cmd.assert()
-
            .success()
-
            .stdout(predicate::str::contains("\"name\":\"rad-tui\""));
+
        let actual = parse_args(&args)?;
+
        assert_eq!(actual, expected);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn help_command_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn help_command_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["help".into()];
+
        let expected = super::Command::Other {
+
            opts: OtherOptions {
+
                args: args.clone(),
+
                forward: true,
+
            },
+
        };

-
        cmd.arg("help");
-
        cmd.assert().success().stdout(assert::is_rad_manual());
+
        let actual = parse_args(&args)?;
+
        assert_eq!(actual, expected);

        Ok(())
    }

    #[test]
-
    #[ignore = "requires binary"]
-
    fn help_command_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let mut cmd = Command::cargo_bin("rad-tui")?;
+
    fn help_command_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["help".into(), "--no-forward".into()];

-
        cmd.arg("help").arg("--no-forward");
-
        cmd.assert().success().stdout(assert::is_help());
+
        let actual = parse_args(&args)?;
+
        assert!(matches!(actual, super::Command::Help));

        Ok(())
    }