aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/populate/src/cli.rs
blob: c059c0f89b89ac532155427866f3ebf77833d7a6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
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<String>,
    /// Whether the target is the machine populate runs on, if --local or
    /// --remote says so.
    pub local: Option<bool>,
}

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<Command, String> {
    let mut options = Options::default();
    let mut positional: Vec<String> = 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<String, String> {
            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<Command, String> {
        super::parse(&args.iter().map(|a| a.to_string()).collect::<Vec<_>>())
    }

    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());
    }
}