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/spec.rs | 162 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 137 insertions(+), 25 deletions(-) (limited to 'pkgs/populate/src/spec.rs') 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; + +#[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, + 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, pub host: String, - #[serde(default)] pub port: Option, pub path: String, - #[serde(default)] pub sudo: bool, - #[serde(default)] pub extra_options: Vec, - /// Whether the target is the machine populate runs on, or null to - /// decide that when it runs. - #[serde(default)] - pub local: Option, +} + +/// 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, + pub host: Option, + pub port: Option, + pub path: Option, +} + +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 { + 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) -> 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 { fn walk(prefix: &[String], inputs: &Map, out: &mut Vec) { 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 { + 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); + } +} -- cgit v1.3.1