pub(crate) const FOLLOW_INTERVAL_FLOOR_SECONDS: u64 = 1;
#[derive(Debug, clap::Args)]
pub struct FlockArgs {
#[arg(short = 'f', long, action = clap::ArgAction::SetTrue)]
pub follow: bool,
#[arg(
short = 'i',
long,
default_value_t = FOLLOW_INTERVAL_FLOOR_SECONDS,
value_name = "SECONDS",
value_parser = clap::value_parser!(u64).range(FOLLOW_INTERVAL_FLOOR_SECONDS..),
requires = "follow"
)]
pub interval: u64,
}
#[derive(Debug, clap::Args)]
pub struct FoldArgs {
pub name: String,
}
const DOG_TAB: &str = "dogs";
const CATALOGUE_SCREEN: &str = "catalogue";
#[derive(Debug)]
pub struct LookoutArgs {
pub read_only: bool,
pub sheep: Option<String>,
pub tab: Option<String>,
pub dog: Option<String>,
pub catalogue: bool,
}
#[derive(Debug, clap::Args)]
struct CatalogueSpec {
#[arg(short = 'R', long)]
read_only: bool,
}
#[derive(Debug, clap::Subcommand)]
enum LookoutScreen {
#[command(name = CATALOGUE_SCREEN)]
Catalogue(CatalogueSpec),
}
#[derive(Debug, clap::Args)]
#[command(args_conflicts_with_subcommands = true, disable_help_subcommand = true)]
struct LookoutSpec {
#[arg(short = 'R', long)]
read_only: bool,
#[arg(value_name = "SHEEP")]
sheep: Option<String>,
#[arg(
value_name = "TAB",
value_parser = clap::builder::PossibleValuesParser::new(
shep_core::config::GROUP_ORDER.iter().copied()
)
)]
tab: Option<String>,
#[arg(value_name = "DOG")]
dog: Option<String>,
#[command(subcommand)]
screen: Option<LookoutScreen>,
}
impl clap::Args for LookoutArgs {
fn augment_args(cmd: clap::Command) -> clap::Command {
<LookoutSpec as clap::Args>::augment_args(cmd)
}
fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
<LookoutSpec as clap::Args>::augment_args_for_update(cmd)
}
}
impl clap::FromArgMatches for LookoutArgs {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
Self::finish(<LookoutSpec as clap::FromArgMatches>::from_arg_matches(
matches,
)?)
}
fn from_arg_matches_mut(matches: &mut clap::ArgMatches) -> Result<Self, clap::Error> {
Self::finish(<LookoutSpec as clap::FromArgMatches>::from_arg_matches_mut(
matches,
)?)
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = Self::from_arg_matches(matches)?;
Ok(())
}
fn update_from_arg_matches_mut(
&mut self,
matches: &mut clap::ArgMatches,
) -> Result<(), clap::Error> {
*self = Self::from_arg_matches_mut(matches)?;
Ok(())
}
}
impl LookoutArgs {
fn finish(spec: LookoutSpec) -> Result<Self, clap::Error> {
if let (Some(tab), Some(dog)) = (spec.tab.as_deref(), spec.dog.as_ref())
&& tab != DOG_TAB
{
return Err(usage_error(format!(
"{dog} names a dog's table, and {tab} has none: the third positional needs `dogs` as its tab"
)));
}
Ok(Self {
read_only: spec.read_only
|| matches!(
spec.screen,
Some(LookoutScreen::Catalogue(ref args)) if args.read_only
),
sheep: spec.sheep,
tab: spec.tab,
dog: spec.dog,
catalogue: matches!(spec.screen, Some(LookoutScreen::Catalogue(_))),
})
}
}
fn usage_error(why: String) -> clap::Error {
let mut cmd = <LookoutSpec as clap::Args>::augment_args(
clap::Command::new("lookout").bin_name("shep lookout"),
);
clap::Error::raw(clap::error::ErrorKind::ValueValidation, why).format(&mut cmd)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{Cli, Commands};
#[test]
fn flock_follows_only_when_asked_and_defaults_to_a_one_second_interval() {
use clap::Parser;
let Commands::Flock(args) = Cli::try_parse_from(["shep", "flock"]).unwrap().command else {
panic!("expected flock")
};
assert!(!args.follow, "a bare listing is still one moment");
assert_eq!(args.interval, FOLLOW_INTERVAL_FLOOR_SECONDS);
let Commands::Flock(args) =
Cli::try_parse_from(["shep", "flock", "--follow", "--interval", "5"])
.unwrap()
.command
else {
panic!("expected flock")
};
assert!(args.follow);
assert_eq!(args.interval, 5);
assert!(
Cli::try_parse_from(["shep", "flock", "--interval", "5"]).is_err(),
"an interval with nothing to pace is a usage error"
);
}
#[test]
fn a_zero_interval_is_refused() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "flock", "--follow", "--interval", "0"]).is_err());
}
#[test]
fn actions_are_on_unless_read_only_says_otherwise() {
use clap::Parser;
let Commands::Lookout(default) = Cli::try_parse_from(["shep", "lookout"]).unwrap().command
else {
panic!("lookout parses to its own variant")
};
assert!(!default.read_only);
let Commands::Lookout(flagged) = Cli::try_parse_from(["shep", "lookout", "--read-only"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert!(flagged.read_only);
}
#[test]
fn allow_control_no_longer_parses() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "lookout", "--allow-control"]).is_err());
}
#[test]
fn lookout_takes_up_to_three_positionals() {
use clap::Parser;
let focus = |argv: &[&str]| {
let Commands::Lookout(args) = Cli::try_parse_from(argv).unwrap().command else {
panic!("lookout parses to its own variant")
};
(args.sheep, args.tab, args.dog, args.catalogue)
};
assert_eq!(
focus(&["shep", "lookout"]),
(None, None, None, false),
"bare lookout is still the dashboard"
);
assert_eq!(
focus(&["shep", "lookout", "web"]),
(Some("web".to_string()), None, None, false)
);
assert_eq!(
focus(&["shep", "lookout", "web", "watch"]),
(
Some("web".to_string()),
Some("watch".to_string()),
None,
false
)
);
assert_eq!(
focus(&["shep", "lookout", "web", "dogs", "jobs"]),
(
Some("web".to_string()),
Some("dogs".to_string()),
Some("jobs".to_string()),
false
)
);
}
#[test]
fn every_tab_is_a_group_the_pane_draws_and_nothing_else_parses() {
use clap::Parser;
for group in shep_core::config::GROUP_ORDER {
let Commands::Lookout(args) = Cli::try_parse_from(["shep", "lookout", "web", group])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert_eq!(args.tab.as_deref(), Some(*group), "{group}");
}
for argv in [
vec!["shep", "lookout", "web", "processes"],
vec!["shep", "lookout", "web", "Dogs"],
vec!["shep", "lookout", "web", ""],
] {
let err = Cli::try_parse_from(&argv).expect_err(&format!("{argv:?}"));
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue, "{argv:?}");
assert!(err.to_string().contains("possible values"), "{err}");
}
}
#[test]
fn web_dogs_kelpie_parses_with_all_three_slots_set() {
use clap::Parser;
let Commands::Lookout(args) =
Cli::try_parse_from(["shep", "lookout", "web", "dogs", "kelpie"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert_eq!(args.sheep.as_deref(), Some("web"));
assert_eq!(args.tab.as_deref(), Some("dogs"));
assert_eq!(args.dog.as_deref(), Some("kelpie"));
assert!(!args.catalogue, "three positionals are not a screen");
}
#[test]
fn web_config_dogs_is_refused_as_it_always_was() {
use clap::Parser;
let err = Cli::try_parse_from(["shep", "lookout", "web", "config", "dogs"])
.expect_err("config is no tab the pane draws");
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
let rendered = err.to_string();
assert!(rendered.contains("config"), "{rendered}");
for group in shep_core::config::GROUP_ORDER {
assert!(rendered.contains(group), "{rendered}");
}
}
#[test]
fn a_third_positional_after_another_tab_is_refused() {
use clap::Parser;
for tab in shep_core::config::GROUP_ORDER.iter().copied() {
let argv = vec!["shep", "lookout", "web", tab, "jobs"];
let parsed = Cli::try_parse_from(&argv);
if tab == DOG_TAB {
assert!(parsed.is_ok(), "{argv:?}");
continue;
}
let err = parsed.expect_err(&format!("{argv:?}"));
assert_eq!(
err.kind(),
clap::error::ErrorKind::ValueValidation,
"{argv:?}"
);
let rendered = err.to_string();
assert!(rendered.contains("jobs"), "{rendered}");
assert!(rendered.contains(tab), "{rendered}");
assert!(rendered.contains("shep lookout"), "{rendered}");
}
}
#[test]
fn the_alias_takes_the_same_positionals() {
use clap::Parser;
let Commands::Lookout(args) = Cli::try_parse_from(["shep", "dash", "web", "dogs", "jobs"])
.unwrap()
.command
else {
panic!("dash parses to lookout's variant")
};
assert_eq!(args.sheep.as_deref(), Some("web"));
assert_eq!(args.tab.as_deref(), Some("dogs"));
assert_eq!(args.dog.as_deref(), Some("jobs"));
let Commands::Lookout(args) = Cli::try_parse_from(["shep", "dash", "catalogue"])
.unwrap()
.command
else {
panic!("dash carries the subcommand too")
};
assert!(args.catalogue);
}
#[test]
fn the_catalogue_subcommand_takes_the_gate_flag_and_no_positional() {
use clap::Parser;
let Commands::Lookout(args) = Cli::try_parse_from(["shep", "lookout", "catalogue"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert!(args.catalogue);
assert!(!args.read_only);
assert_eq!(
(
args.sheep.as_deref(),
args.tab.as_deref(),
args.dog.as_deref()
),
(None, None, None),
"a sheep named `catalogue` is reached from the dashboard, not from here"
);
let Commands::Lookout(args) =
Cli::try_parse_from(["shep", "lookout", "catalogue", "--read-only"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert!(args.catalogue);
assert!(args.read_only);
assert_eq!(args.sheep, None);
let Commands::Lookout(args) =
Cli::try_parse_from(["shep", "--host", "amp2", "lookout", "catalogue"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert!(args.catalogue);
assert_eq!(args.sheep, None);
}
#[test]
fn a_name_that_reads_like_the_catalogue_still_names_one() {
use clap::Parser;
let Commands::Lookout(args) =
Cli::try_parse_from(["shep", "lookout", "web", "dogs", "catalogue"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert_eq!(args.sheep.as_deref(), Some("web"));
assert_eq!(args.tab.as_deref(), Some("dogs"));
assert_eq!(args.dog.as_deref(), Some("catalogue"));
assert!(
!args.catalogue,
"the third positional is a dog, not a screen"
);
let Commands::Lookout(args) = Cli::try_parse_from(["shep", "lookout", "--", "catalogue"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert_eq!(args.sheep.as_deref(), Some("catalogue"));
assert!(!args.catalogue, "`--` is what makes the name a name again");
for argv in [
vec!["shep", "lookout", "--read-only", "catalogue"],
vec!["shep", "lookout", "--read-only", "--", "catalogue"],
] {
let Commands::Lookout(args) = Cli::try_parse_from(&argv).unwrap().command else {
panic!("lookout parses to its own variant")
};
assert_eq!(args.sheep.as_deref(), Some("catalogue"), "{argv:?}");
assert!(args.read_only, "{argv:?}");
assert!(!args.catalogue, "{argv:?}");
}
}
#[test]
fn a_tab_or_dog_after_the_catalogue_is_a_usage_error() {
use clap::Parser;
for argv in [
vec!["shep", "lookout", "catalogue", "web"],
vec!["shep", "lookout", "catalogue", "web", "dogs"],
vec!["shep", "lookout", "catalogue", "web", "dogs", "jobs"],
] {
let err = Cli::try_parse_from(&argv).expect_err(&format!("{argv:?}"));
assert_eq!(
err.kind(),
clap::error::ErrorKind::UnknownArgument,
"{argv:?}"
);
let rendered = err.to_string();
assert!(rendered.contains("shep lookout"), "{rendered}");
assert!(rendered.contains(CATALOGUE_SCREEN), "{rendered}");
}
let err = Cli::try_parse_from(["shep", "lookout", "web", "catalogue"])
.expect_err("catalogue is no tab the pane draws");
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
let err = Cli::try_parse_from(["shep", "lookout", "web", "watch", "catalogue"])
.expect_err("a dog's table is the dogs tab's own");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn a_sheep_or_dog_named_help_stays_a_name() {
use clap::Parser;
let Commands::Lookout(args) = Cli::try_parse_from(["shep", "lookout", "help"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert_eq!(args.sheep.as_deref(), Some("help"));
assert!(!args.catalogue);
let Commands::Lookout(args) =
Cli::try_parse_from(["shep", "lookout", "web", "dogs", "help"])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert_eq!(args.sheep.as_deref(), Some("web"));
assert_eq!(args.dog.as_deref(), Some("help"));
assert!(!args.catalogue);
}
#[test]
fn the_catalogue_subcommand_is_the_only_one_lookout_has() {
use clap::CommandFactory;
let lookout = Cli::command().find_subcommand("lookout").unwrap().clone();
let names: Vec<&str> = lookout
.get_subcommands()
.map(|sub| sub.get_name())
.collect();
assert_eq!(names, [CATALOGUE_SCREEN]);
}
#[test]
fn the_dogs_tab_is_one_of_the_groups_the_pane_draws() {
assert!(
shep_core::config::GROUP_ORDER.contains(&DOG_TAB),
"{DOG_TAB} is not a config pane group"
);
}
#[test]
fn the_positionals_sit_alongside_the_flags() {
use clap::Parser;
let Commands::Lookout(args) = Cli::try_parse_from([
"shep",
"--host",
"amp2",
"lookout",
"web",
"dogs",
"jobs",
"--read-only",
])
.unwrap()
.command
else {
panic!("lookout parses to its own variant")
};
assert!(args.read_only);
assert_eq!(args.sheep.as_deref(), Some("web"));
assert_eq!(args.tab.as_deref(), Some("dogs"));
assert_eq!(args.dog.as_deref(), Some("jobs"));
}
}