aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/populate/src
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/populate/src')
-rw-r--r--pkgs/populate/src/check.rs64
-rw-r--r--pkgs/populate/src/cli.rs212
-rw-r--r--pkgs/populate/src/main.rs65
-rw-r--r--pkgs/populate/src/run.rs89
-rw-r--r--pkgs/populate/src/spec.rs162
5 files changed, 501 insertions, 91 deletions
diff --git a/pkgs/populate/src/check.rs b/pkgs/populate/src/check.rs
index cb67865..ebe5dbf 100644
--- a/pkgs/populate/src/check.rs
+++ b/pkgs/populate/src/check.rs
@@ -1,13 +1,16 @@
-//! Checks of the spec, the same as checks.nix makes during evaluation, so a
-//! spec from anywhere else gets checked as well. Messages are the same.
+//! Checks of the sources and the target, the same as checks.nix makes during
+//! evaluation, so sources from anywhere else get checked as well. Messages
+//! are the same.
use crate::spec::{Source, Spec};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
-/// The spec's checks in the order checks.nix makes them, the first failing
-/// one's message.
+/// The checks in the order checks.nix makes them, the first failing one's
+/// message. The target's host and user, which a command line gives, come
+/// first.
pub fn spec(spec: &Spec) -> Result<(), String> {
+ target_host(&spec.target.host, spec.target.user.as_deref())?;
target_path(&spec.target.path)?;
for (name, source) in &spec.source {
source_name(name)?;
@@ -16,6 +19,24 @@ pub fn spec(spec: &Spec) -> Result<(), String> {
inputs(&spec.source)
}
+/// The target's host and user, which ssh gets as arguments, mustn't be
+/// taken for its options, and the host can't be empty.
+fn target_host(host: &str, user: Option<&str>) -> Result<(), String> {
+ if host.is_empty() || host.starts_with('-') {
+ return Err(format!(
+ "krops: target host {} can't be used: it must not be empty or start with -",
+ quote(host)
+ ));
+ }
+ if user.is_some_and(|u| u.starts_with('-')) {
+ return Err(format!(
+ "krops: target user {} can't be used: it must not start with -",
+ quote(user.unwrap_or_default())
+ ));
+ }
+ Ok(())
+}
+
/// What a setting holds.
#[derive(Clone, Copy)]
enum Kind {
@@ -365,14 +386,37 @@ mod tests {
}
#[test]
+ fn target_hosts() {
+ assert!(target_host("host", Some("root")).is_ok());
+ assert!(target_host("2001:db8::1", None).is_ok());
+ assert_eq!(
+ target_host("-oProxyCommand=x", None).unwrap_err(),
+ r#"krops: target host "-oProxyCommand=x" can't be used: it must not be empty or start with -"#
+ );
+ assert!(target_host("", None).is_err());
+ assert!(target_host("host", Some("-x")).is_err());
+ }
+
+ #[test]
fn settings() {
let spec = |file: &str| -> Spec {
- serde_json::from_str(&format!(
- r#"{{"version": 1, "target": {{"host": "h", "path": "/a"}},
- "source": {{"x": {{"type": "file", "file": {}}}}}}}"#,
- file
- ))
- .unwrap()
+ Spec {
+ backup: false,
+ force: false,
+ prune: false,
+ source: serde_json::from_str(&format!(
+ r#"{{"x": {{"type": "file", "file": {file}}}}}"#
+ ))
+ .unwrap(),
+ target: crate::spec::Target {
+ user: None,
+ host: "h".to_string(),
+ port: None,
+ path: "/a".to_string(),
+ sudo: false,
+ extra_options: vec![],
+ },
+ }
};
assert!(super::spec(&spec(r#"{"path": "/src"}"#)).is_ok());
assert!(super::spec(&spec(r#"{"path": "--rsync-path=x"}"#)).is_err());
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<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());
+ }
+}
diff --git a/pkgs/populate/src/main.rs b/pkgs/populate/src/main.rs
index 2884fd0..eeed1ef 100644
--- a/pkgs/populate/src/main.rs
+++ b/pkgs/populate/src/main.rs
@@ -1,7 +1,9 @@
-//! populate SPEC: delivers sources to a target directory, as the spec, a
-//! JSON file or - for standard input, says. See docs/design.md.
+//! populate [OPTION]... SOURCES [TARGET]: delivers the sources in the JSON
+//! file SOURCES, or standard input for -, to the target directory TARGET.
+//! See README.md.
mod check;
+mod cli;
mod flake;
mod flake_input;
mod populate;
@@ -13,15 +15,36 @@ mod spec;
use std::io::Read;
use std::process::ExitCode;
-/// Exit status when the spec can't be read, isn't JSON, or isn't a spec.
+/// Exit status when the command line is wrong, or the sources can't be read
+/// or aren't sources.
const USAGE: u8 = 2;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
- let [path] = args.as_slice() else {
- eprintln!("usage: populate SPEC");
- return ExitCode::from(USAGE);
+ let options = match cli::parse(&args) {
+ Ok(cli::Command::Help) => {
+ print!("{}", cli::USAGE);
+ return ExitCode::SUCCESS;
+ }
+ Ok(cli::Command::Version) => {
+ println!("populate {}", env!("CARGO_PKG_VERSION"));
+ return ExitCode::SUCCESS;
+ }
+ Ok(cli::Command::Populate(options)) => options,
+ Err(message) => {
+ eprintln!("populate: {message}");
+ eprint!(
+ "{}",
+ cli::USAGE
+ .lines()
+ .next()
+ .map(|l| format!("{l}\n"))
+ .unwrap_or_default()
+ );
+ return ExitCode::from(USAGE);
+ }
};
+ let path = &options.sources;
let text = if path == "-" {
let mut text = String::new();
std::io::stdin().read_to_string(&mut text).map(|_| text)
@@ -35,29 +58,31 @@ fn main() -> ExitCode {
return ExitCode::from(USAGE);
}
};
- let version = serde_json::from_str::<serde_json::Value>(&text)
- .ok()
- .and_then(|value| value.get("version").and_then(|v| v.as_u64()));
- if let Some(version) = version.filter(|v| *v != spec::VERSION) {
- eprintln!(
- "populate: {path} is a spec of version {version}, this populate reads version {}",
- spec::VERSION
- );
- return ExitCode::from(USAGE);
- }
- let spec: spec::Spec = match serde_json::from_str(&text) {
- Ok(spec) => spec,
+ let source: spec::Sources = match serde_json::from_str(&text) {
+ Ok(source) => source,
Err(e) => {
- eprintln!("populate: {path} isn't a spec: {e}");
+ eprintln!("populate: {path} doesn't contain sources: {e}");
return ExitCode::from(USAGE);
}
};
+ let origin = run::Origin::from_env();
+ let target = options
+ .target
+ .complete(&origin, options.sudo, options.ssh_args);
+ let local = options.local.unwrap_or_else(|| origin.is_local(&target));
+ let spec = spec::Spec {
+ backup: options.backup,
+ force: options.force,
+ prune: options.prune,
+ source,
+ target,
+ };
if let Err(message) = check::spec(&spec) {
eprintln!("{message}");
return ExitCode::FAILURE;
}
run::trap_signals();
- let context = match run::Context::new(spec.target.clone(), spec.target.local) {
+ let context = match run::Context::new(spec.target.clone(), local) {
Ok(context) => context,
Err(failure) => return exit(failure),
};
diff --git a/pkgs/populate/src/run.rs b/pkgs/populate/src/run.rs
index a16ffe4..c9bd9fb 100644
--- a/pkgs/populate/src/run.rs
+++ b/pkgs/populate/src/run.rs
@@ -111,8 +111,7 @@ pub struct Context {
impl Context {
/// Creates the temporary directory, and the ssh script in it.
- pub fn new(target: Target, local: Option<bool>) -> Result<Context> {
- let local = local.unwrap_or_else(|| is_local(&target));
+ pub fn new(target: Target, local: bool) -> Result<Context> {
let output = Command::new("mktemp")
.args(["-dt", "populate.XXXXXXXX"])
.stderr(Stdio::inherit())
@@ -358,37 +357,50 @@ pub fn output(command: &mut Command) -> Result<String> {
}
}
-/// Whether the target is the machine populate runs on, as the user running
-/// it, like krops' lib's isLocalTarget decides during evaluation.
-fn is_local(target: &Target) -> bool {
- let etc_hostname = fs::read_to_string("/etc/hostname").ok();
- is_local_with(
- target,
- std::env::var("LOGNAME").ok().as_deref(),
- std::env::var("HOSTNAME").ok().as_deref(),
- etc_hostname.as_deref(),
- )
+/// The user running populate, and the name of the machine it runs on, like
+/// krops' lib's mkTarget and isLocalTarget take them.
+#[derive(Clone, Debug)]
+pub struct Origin {
+ pub user: Option<String>,
+ pub host: String,
}
-/// is_local, given LOGNAME, HOSTNAME, and the contents of /etc/hostname.
-/// Like lib.maybeEnv, an empty variable counts as unset. The user running
-/// populate is LOGNAME, if set, and the machine's name is HOSTNAME, or else
-/// the only line of /etc/hostname that is a label, or else localhost. The
-/// target is local if its user is that user, and its host is that name or
-/// localhost.
-fn is_local_with(
- target: &Target,
- logname: Option<&str>,
- hostname: Option<&str>,
- etc_hostname: Option<&str>,
-) -> bool {
- let user = logname.filter(|v| !v.is_empty());
- let host = hostname
- .filter(|v| !v.is_empty())
- .map(str::to_string)
- .or_else(|| etc_hostname.and_then(host_name))
- .unwrap_or_else(|| "localhost".to_string());
- target.user.as_deref() == user && (target.host == "localhost" || target.host == host)
+impl Origin {
+ /// From LOGNAME, HOSTNAME, and /etc/hostname.
+ pub fn from_env() -> Origin {
+ let etc_hostname = fs::read_to_string("/etc/hostname").ok();
+ Origin::from(
+ std::env::var("LOGNAME").ok().as_deref(),
+ std::env::var("HOSTNAME").ok().as_deref(),
+ etc_hostname.as_deref(),
+ )
+ }
+
+ /// Given LOGNAME, HOSTNAME, and the contents of /etc/hostname. Like
+ /// lib.maybeEnv, an empty variable counts as unset. The user is
+ /// LOGNAME, if set, and the machine's name is HOSTNAME, or else the only
+ /// line of /etc/hostname that is a label, or else localhost.
+ pub fn from(
+ logname: Option<&str>,
+ hostname: Option<&str>,
+ etc_hostname: Option<&str>,
+ ) -> Origin {
+ Origin {
+ user: logname.filter(|v| !v.is_empty()).map(str::to_string),
+ host: hostname
+ .filter(|v| !v.is_empty())
+ .map(str::to_string)
+ .or_else(|| etc_hostname.and_then(host_name))
+ .unwrap_or_else(|| "localhost".to_string()),
+ }
+ }
+
+ /// Whether target is the machine populate runs on, as the user running
+ /// it: its user is that user, and its host is the machine's name or
+ /// localhost.
+ pub fn is_local(&self, target: &Target) -> bool {
+ target.user == self.user && (target.host == "localhost" || target.host == self.host)
+ }
}
/// The machine's name in the contents of /etc/hostname: its only line that
@@ -425,15 +437,20 @@ mod tests {
}
fn target(user: Option<&str>, host: &str) -> Target {
- serde_json::from_value(serde_json::json!({
- "user": user, "host": host, "path": "/var/src"
- }))
- .unwrap()
+ Target {
+ user: user.map(str::to_string),
+ host: host.to_string(),
+ port: None,
+ path: "/var/src".to_string(),
+ sudo: false,
+ extra_options: vec![],
+ }
}
#[test]
fn local_targets() {
- let local = |t: &Target, logname, hostname, etc| is_local_with(t, logname, hostname, etc);
+ let local =
+ |t: &Target, logname, hostname, etc| Origin::from(logname, hostname, etc).is_local(t);
// An empty LOGNAME counts as unset, i.e. the user null.
assert!(local(&target(None, "localhost"), Some(""), None, None));
assert!(!local(&target(None, "localhost"), Some("tv"), None, None));
diff --git a/pkgs/populate/src/spec.rs b/pkgs/populate/src/spec.rs
index 8fc74fc..cde5e29 100644
--- a/pkgs/populate/src/spec.rs
+++ b/pkgs/populate/src/spec.rs
@@ -1,12 +1,11 @@
-//! The spec: populate's arguments as JSON, as docs/design.md describes them.
+//! What populate delivers where: the sources, as JSON, and the target, as
+//! the command line gives it. See README.md.
+use crate::run::Origin;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
-/// The version of the spec this populate reads.
-pub const VERSION: u64 = 1;
-
/// The source types populate knows.
pub const SOURCE_TYPES: &[&str] = &[
"derivation",
@@ -20,39 +19,86 @@ pub const SOURCE_TYPES: &[&str] = &[
"symlink",
];
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
+/// The sources, by name, as a JSON object.
+pub type Sources = BTreeMap<String, Source>;
+
+#[derive(Debug)]
pub struct Spec {
- /// Checked before the rest, so it's only part of the shape here.
- #[allow(dead_code)]
- pub version: u64,
- #[serde(default)]
pub backup: bool,
- #[serde(default)]
pub force: bool,
- #[serde(default)]
pub prune: bool,
- pub source: BTreeMap<String, Source>,
+ pub source: Sources,
pub target: Target,
}
-#[derive(Clone, Debug, Deserialize)]
-#[serde(deny_unknown_fields, rename_all = "camelCase")]
+#[derive(Clone, Debug)]
pub struct Target {
- #[serde(default)]
pub user: Option<String>,
pub host: String,
- #[serde(default)]
pub port: Option<String>,
pub path: String,
- #[serde(default)]
pub sudo: bool,
- #[serde(default)]
pub extra_options: Vec<String>,
- /// Whether the target is the machine populate runs on, or null to
- /// decide that when it runs.
- #[serde(default)]
- pub local: Option<bool>,
+}
+
+/// The parts of a target that [USER@][HOST][:PORT][PATH] gives, like krops'
+/// lib's mkTarget parses it.
+#[derive(Debug, Default, PartialEq)]
+pub struct TargetParts {
+ pub user: Option<String>,
+ pub host: Option<String>,
+ pub port: Option<String>,
+ pub path: Option<String>,
+}
+
+impl TargetParts {
+ /// Parses text like the regular expression
+ /// (([^@]*)@)?(([^:/]+))?(:([^/]+))?(/.*)? of mkTarget: the user up to
+ /// the first @, the host up to a : or a /, the port up to a /, and the
+ /// path from there.
+ pub fn parse(text: &str) -> Result<TargetParts, String> {
+ let invalid = || format!("invalid target {text:?}: not [USER@][HOST][:PORT][PATH]");
+ let (user, rest) = match text.split_once('@') {
+ Some((user, rest)) => (Some(user.to_string()), rest),
+ None => (None, text),
+ };
+ let end = rest.find([':', '/']).unwrap_or(rest.len());
+ let (host, rest) = rest.split_at(end);
+ let (port, path) = match rest.strip_prefix(':') {
+ Some(rest) => {
+ let end = rest.find('/').unwrap_or(rest.len());
+ let (port, path) = rest.split_at(end);
+ if port.is_empty() {
+ return Err(invalid());
+ }
+ (Some(port.to_string()), path)
+ }
+ None => (None, rest),
+ };
+ Ok(TargetParts {
+ user,
+ host: Some(host.to_string()).filter(|h| !h.is_empty()),
+ port,
+ path: Some(path.to_string()).filter(|p| !p.is_empty()),
+ })
+ }
+
+ /// The target, with what's missing as mkTarget defaults it: the user
+ /// running populate, the machine's name, and /var/src. An empty user
+ /// means ssh's default.
+ pub fn complete(self, origin: &Origin, sudo: bool, extra_options: Vec<String>) -> Target {
+ Target {
+ user: match self.user {
+ Some(user) => Some(user).filter(|u| !u.is_empty()),
+ None => origin.user.clone(),
+ },
+ host: self.host.unwrap_or_else(|| origin.host.clone()),
+ port: self.port,
+ path: self.path.unwrap_or_else(|| "/var/src".to_string()),
+ sudo,
+ extra_options,
+ }
+ }
}
/// A source: its type, and the settings of that type, under the type's name.
@@ -97,8 +143,8 @@ pub struct Follows {
}
impl Source {
- /// The inputs this source draws from other sources, like krops' lib's
- /// flakeFollows.
+ /// The inputs this source draws from other sources, like flakeFollows
+ /// of populate's Nix library.
pub fn follows(&self) -> Vec<Follows> {
fn walk(prefix: &[String], inputs: &Map<String, Value>, out: &mut Vec<Follows>) {
for (name, input) in inputs {
@@ -122,3 +168,69 @@ impl Source {
out
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn parts(text: &str) -> TargetParts {
+ TargetParts::parse(text).unwrap()
+ }
+
+ fn some(s: &str) -> Option<String> {
+ Some(s.to_string())
+ }
+
+ #[test]
+ fn target_syntax() {
+ assert_eq!(parts(""), TargetParts::default());
+ assert_eq!(
+ parts("/srv/src"),
+ TargetParts {
+ path: some("/srv/src"),
+ ..Default::default()
+ }
+ );
+ assert_eq!(
+ parts("host"),
+ TargetParts {
+ host: some("host"),
+ ..Default::default()
+ }
+ );
+ assert_eq!(
+ parts("tv@host:22/x"),
+ TargetParts {
+ user: some("tv"),
+ host: some("host"),
+ port: some("22"),
+ path: some("/x")
+ }
+ );
+ // The user ends at the first @, the port at the first /.
+ assert_eq!(parts("a@b@c").host, some("b@c"));
+ assert_eq!(parts("h:p:q/r").port, some("p:q"));
+ assert_eq!(parts(":22").host, None);
+ assert!(TargetParts::parse("host:").is_err());
+ assert!(TargetParts::parse("host:/x").is_err());
+ }
+
+ #[test]
+ fn target_defaults() {
+ let origin = Origin {
+ user: some("tv"),
+ host: "ni".to_string(),
+ };
+ let target = parts("").complete(&origin, false, vec![]);
+ assert_eq!(
+ (
+ target.user.as_deref(),
+ target.host.as_str(),
+ target.path.as_str()
+ ),
+ (Some("tv"), "ni", "/var/src")
+ );
+ // An empty user means ssh's default.
+ assert_eq!(parts("@h").complete(&origin, true, vec![]).user, None);
+ }
+}