aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/populate/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-14 00:24:45 +0000
committertv <tv@krebsco.de>2026-11-14 01:43:19 +0100
commit8f08426ae3a2e5f0ecc5d010bc6469b41e3d7678 (patch)
treef6573ff55af0b9eacf77e7d58d3668c1129a589a /pkgs/populate/src
parent57d47a36129b459f0fac519a460c581870a54bbf (diff)
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.
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);
+ }
+}