From 8f08426ae3a2e5f0ecc5d010bc6469b41e3d7678 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 14 Nov 2026 00:24:45 +0000 Subject: populate: a command line instead of a spec The program takes populate's arguments on its command line, like other programs: populate [OPTION]... SOURCES [TARGET], with the sources as JSON in the file SOURCES, or on standard input for -. TARGET has the syntax of krops' lib's mkTarget, [USER@][HOST][:PORT][PATH], parsed the same way, and what it leaves out gets filled in when populate runs, as mkTarget would, from LOGNAME and the machine's name. Options set what the syntax can't express, or override its parts: --sudo, --ssh-arg, --user, --host, --port, --path, and --local or --remote for whether the target is the machine populate runs on, which populate otherwise decides like isLocalTarget. The sources carry no version. pkgs.populate passes a target attribute set, as mkTarget returns it, part by part, with --local or --remote as evaluation decides, so nothing changes for krops. A target string goes to the program as it is. The program also refuses target hosts and users starting with -, which ssh would take for options, now that a command line can give them. --- pkgs/populate/src/cli.rs | 212 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 pkgs/populate/src/cli.rs (limited to 'pkgs/populate/src/cli.rs') diff --git a/pkgs/populate/src/cli.rs b/pkgs/populate/src/cli.rs new file mode 100644 index 0000000..c059c0f --- /dev/null +++ b/pkgs/populate/src/cli.rs @@ -0,0 +1,212 @@ +//! populate's command line. + +use crate::spec::TargetParts; + +/// What the command line asks for. +#[derive(Debug, PartialEq)] +pub enum Command { + Help, + Version, + Populate(Options), +} + +/// The arguments of populating. +#[derive(Debug, Default, PartialEq)] +pub struct Options { + pub backup: bool, + pub force: bool, + pub prune: bool, + /// The file the sources are in, or - for standard input. + pub sources: String, + /// The target, as given, with the options changing its parts applied. + pub target: TargetParts, + pub sudo: bool, + pub ssh_args: Vec, + /// Whether the target is the machine populate runs on, if --local or + /// --remote says so. + pub local: Option, +} + +pub const USAGE: &str = "\ +usage: populate [OPTION]... SOURCES [TARGET] + +Delivers the sources in the file SOURCES, a JSON object, or standard input +if SOURCES is -, to the target directory TARGET. + +TARGET is [USER@][HOST][:PORT][PATH]. USER defaults to $LOGNAME, HOST to +the name of the machine, and PATH to /var/src. A target is local if its +user is $LOGNAME, and its host is localhost or the name of the machine; +populate reaches other targets via ssh. + +Options: + --backup copy the target directory to a backup first + --force create the target directory's sentinel if it's missing + --prune remove what isn't a source from the target directory + --sudo deliver as root, via sudo on the target + --ssh-arg ARG pass ARG to ssh; repeatable + --user USER the target's user; empty for ssh's default + --host HOST the target's host + --port PORT the port ssh connects to + --path PATH the target directory + --local the target is the machine populate runs on + --remote the target isn't, even if it looks so + -h, --help print this and exit + -V, --version print populate's version and exit +"; + +/// The command line, without the program's name. Options and the other +/// arguments may come in any order, up to --. +pub fn parse(args: &[String]) -> Result { + let mut options = Options::default(); + let mut positional: Vec = Vec::new(); + let mut overrides: Vec<(&'static str, String)> = Vec::new(); + let mut args = args.iter(); + while let Some(arg) = args.next() { + if arg == "--" { + positional.extend(args.by_ref().cloned()); + break; + } + if arg == "-" || !arg.starts_with('-') { + positional.push(arg.clone()); + continue; + } + let (name, inline) = match arg.split_once('=') { + Some((name, value)) if name.starts_with("--") => (name, Some(value.to_string())), + _ => (arg.as_str(), None), + }; + let mut value = |name: &str| -> Result { + match &inline { + Some(value) => Ok(value.clone()), + None => args + .next() + .cloned() + .ok_or_else(|| format!("option {name} needs an argument")), + } + }; + match name { + "-h" | "--help" => return Ok(Command::Help), + "-V" | "--version" => return Ok(Command::Version), + "--backup" => options.backup = true, + "--force" => options.force = true, + "--prune" => options.prune = true, + "--sudo" => options.sudo = true, + "--local" => options.local = Some(true), + "--remote" => options.local = Some(false), + "--ssh-arg" => options.ssh_args.push(value(name)?), + "--user" => overrides.push(("user", value(name)?)), + "--host" => overrides.push(("host", value(name)?)), + "--port" => overrides.push(("port", value(name)?)), + "--path" => overrides.push(("path", value(name)?)), + _ => return Err(format!("unknown option {name}")), + } + if inline.is_some() + && matches!( + name, + "--backup" | "--force" | "--prune" | "--sudo" | "--local" | "--remote" + ) + { + return Err(format!("option {name} takes no argument")); + } + } + let (sources, target) = match positional.as_slice() { + [sources] => (sources.clone(), String::new()), + [sources, target] => (sources.clone(), target.clone()), + [] => return Err("SOURCES is missing".to_string()), + _ => return Err("too many arguments".to_string()), + }; + options.sources = sources; + options.target = TargetParts::parse(&target)?; + for (part, value) in overrides { + let target = &mut options.target; + match part { + "user" => target.user = Some(value), + "host" => target.host = Some(value), + "port" => target.port = Some(value), + _ => target.path = Some(value), + } + } + if let Some(path) = &options.target.path { + if path.is_empty() { + return Err("the target's path is empty".to_string()); + } + } + Ok(Command::Populate(options)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + super::parse(&args.iter().map(|a| a.to_string()).collect::>()) + } + + fn options(args: &[&str]) -> Options { + match parse(args) { + Ok(Command::Populate(options)) => options, + other => panic!("{other:?}"), + } + } + + #[test] + fn targets() { + let o = options(&["s.json", "root@host:2222/var/src"]); + assert_eq!(o.sources, "s.json"); + assert_eq!( + o.target, + TargetParts { + user: Some("root".to_string()), + host: Some("host".to_string()), + port: Some("2222".to_string()), + path: Some("/var/src".to_string()), + } + ); + assert_eq!(options(&["-"]).target, TargetParts::default()); + let o = options(&["--host", "2001:db8::1", "-", "root@x", "--port=22"]); + assert_eq!(o.target.user.as_deref(), Some("root")); + assert_eq!(o.target.host.as_deref(), Some("2001:db8::1")); + assert_eq!(o.target.port.as_deref(), Some("22")); + } + + #[test] + fn flags() { + let o = options(&[ + "--backup", + "--prune", + "--sudo", + "--ssh-arg", + "-o", + "--ssh-arg=X=y", + "--remote", + "s", + ]); + assert!(o.backup && o.prune && o.sudo && !o.force); + assert_eq!(o.ssh_args, ["-o", "X=y"]); + assert_eq!(o.local, Some(false)); + // After --, arguments are no options. + assert_eq!(options(&["--", "--sources"]).sources, "--sources"); + } + + #[test] + fn errors() { + assert_eq!(parse(&["--help", "--bogus"]), Ok(Command::Help)); + assert_eq!( + parse(&["--bogus"]), + Err("unknown option --bogus".to_string()) + ); + assert_eq!(parse(&[]), Err("SOURCES is missing".to_string())); + assert_eq!( + parse(&["a", "b", "c"]), + Err("too many arguments".to_string()) + ); + assert_eq!( + parse(&["s", "--user"]), + Err("option --user needs an argument".to_string()) + ); + assert_eq!( + parse(&["--prune=yes", "s"]), + Err("option --prune takes no argument".to_string()) + ); + assert!(parse(&["s", "host:"]).is_err()); + } +} -- cgit v1.3.1