#![forbid(unsafe_code)]
use anyhow::Result;
use std::path::PathBuf;
pub(crate) fn parse_hosts_list(raw: &str) -> Vec<String> {
crate::vps::dedupe_host_names(raw.split(',').map(|s| s.trim().to_string()).collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ExecTargetError {
NoActiveVps,
Invalid(String),
}
impl std::fmt::Display for ExecTargetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoActiveVps => {
f.write_str("no active VPS; run `connect <name>` or pass `exec <VPS> <COMMAND>`")
}
Self::Invalid(s) => f.write_str(s),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExecTargetPlan {
pub(crate) selection: crate::vps::HostSelection,
pub(crate) command: String,
pub(crate) source: crate::json_wire::TargetSource,
}
const EXEC_USAGE: &str = concat!(
"designate the target explicitly: `<VPS> <COMMAND>`, ",
"or a selector with one positional (`--all`/`--hosts`/`--tags` `<COMMAND>`), ",
"or the active marker deliberately (`--use-active <COMMAND>`)"
);
fn selector_arity_error(flag: &str) -> ExecTargetError {
ExecTargetError::Invalid(format!(
"with {flag} pass only the shell command (exactly one positional); {EXEC_USAGE}"
))
}
fn only_command(target: Vec<String>, err: ExecTargetError) -> Result<String, ExecTargetError> {
target.into_iter().next().ok_or(err)
}
pub(crate) fn parse_exec_target(
all: bool,
hosts: Option<String>,
tags: Option<String>,
use_active: bool,
target: Vec<String>,
active_vps: Option<String>,
) -> Result<ExecTargetPlan, ExecTargetError> {
use crate::domain::{try_tags, VpsName};
use crate::json_wire::TargetSource;
use crate::vps::HostSelection;
let invalid = |s: String| ExecTargetError::Invalid(s);
let modes = u8::from(all) + u8::from(hosts.is_some()) + u8::from(tags.is_some());
if modes > 1 {
return Err(invalid(
"--all, --hosts, and --tags are mutually exclusive".into(),
));
}
if use_active && modes > 0 {
return Err(invalid(
"--use-active conflicts with --all, --hosts and --tags".into(),
));
}
if all {
let err = selector_arity_error("--all");
if target.len() != 1 {
return Err(err);
}
return Ok(ExecTargetPlan {
selection: HostSelection::All,
command: only_command(target, err)?,
source: TargetSource::Selector,
});
}
if let Some(h) = hosts {
let names = parse_hosts_list(&h);
if names.is_empty() {
return Err(invalid("--hosts requires at least one host name".into()));
}
let names = names
.into_iter()
.map(|n| VpsName::try_new(n).map_err(|e| invalid(e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
let err = selector_arity_error("--hosts");
if target.len() != 1 {
return Err(err);
}
return Ok(ExecTargetPlan {
selection: HostSelection::Named(names),
command: only_command(target, err)?,
source: TargetSource::Selector,
});
}
if let Some(t) = tags {
let tag_list = parse_hosts_list(&t);
if tag_list.is_empty() {
return Err(invalid("--tags requires at least one tag".into()));
}
let tag_list = try_tags(tag_list).map_err(|e| invalid(e.to_string()))?;
let err = selector_arity_error("--tags");
if target.len() != 1 {
return Err(err);
}
return Ok(ExecTargetPlan {
selection: HostSelection::Tagged(tag_list),
command: only_command(target, err)?,
source: TargetSource::Selector,
});
}
if use_active {
let err = selector_arity_error("--use-active");
if target.len() != 1 {
return Err(err);
}
let command = only_command(target, err)?;
let name = active_vps.ok_or(ExecTargetError::NoActiveVps)?;
let vps = VpsName::try_new(name).map_err(|e| invalid(e.to_string()))?;
return Ok(ExecTargetPlan {
selection: HostSelection::Single(vps),
command,
source: TargetSource::ActiveMarker,
});
}
if target.len() != 2 {
return Err(invalid(format!(
"expected exactly two positionals `<VPS> <COMMAND>`, got {}; {EXEC_USAGE}",
target.len()
)));
}
let mut it = target.into_iter();
let missing = || invalid("expected VPS and COMMAND".to_string());
let vps = it.next().ok_or_else(missing)?;
let cmd = it.next().ok_or_else(missing)?;
let vps = VpsName::try_new(vps).map_err(|e| invalid(e.to_string()))?;
Ok(ExecTargetPlan {
selection: HostSelection::Single(vps),
command: cmd,
source: TargetSource::Argv,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ScpPathPlan {
Single {
selection: crate::vps::HostSelection,
path_a: PathBuf,
path_b: PathBuf,
},
MultiFile {
vps: String,
sources: Vec<PathBuf>,
dest_dir: PathBuf,
},
MultiHostMultiFile {
selection: crate::vps::HostSelection,
sources: Vec<PathBuf>,
dest_dir: PathBuf,
},
}
const TRANSFER_USAGE: &str = concat!(
"designate the slots explicitly: single host `<VPS> <SRC>... <DEST>`, ",
"fleet one-file `--all`/`--hosts` `<SRC> <DEST>`, ",
"or the named slots `--src <PATH>` (repeatable) with `--dest <DIR>`"
);
fn selector_positional_ambiguity() -> String {
format!(
"ambiguous argv: with --all/--hosts and three or more positionals the first token \
would change role (host slot under single-host, source path under a selector); \
{TRANSFER_USAGE}"
)
}
#[derive(Debug, Clone, Default)]
pub(crate) struct TransferSlots {
pub(crate) src: Vec<String>,
pub(crate) dest: Option<String>,
}
impl TransferSlots {
#[must_use]
pub(crate) fn new(src: Vec<String>, dest: Option<String>) -> Self {
Self { src, dest }
}
fn is_named(&self) -> bool {
!self.src.is_empty() || self.dest.is_some()
}
}
fn transfer_host_slot(
all: bool,
hosts: Option<String>,
) -> Result<Option<crate::vps::HostSelection>, String> {
use crate::domain::VpsName;
use crate::vps::HostSelection;
if all && hosts.is_some() {
return Err("--all conflicts with --hosts".into());
}
if all {
return Ok(Some(HostSelection::All));
}
let Some(list) = hosts else {
return Ok(None);
};
let names = parse_hosts_list(&list);
if names.is_empty() {
return Err("--hosts requires at least one host name".into());
}
let names = names
.into_iter()
.map(|n| VpsName::try_new(n).map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(HostSelection::Named(names)))
}
fn plan_from_named_slots(
selection: Option<crate::vps::HostSelection>,
slots: TransferSlots,
target: Vec<String>,
) -> Result<ScpPathPlan, String> {
use crate::domain::VpsName;
if slots.src.is_empty() {
return Err(format!(
"--dest requires at least one --src; {TRANSFER_USAGE}"
));
}
let Some(dest) = slots.dest else {
return Err(format!("--src requires --dest; {TRANSFER_USAGE}"));
};
let sources: Vec<PathBuf> = slots.src.into_iter().map(PathBuf::from).collect();
let dest_dir = PathBuf::from(dest);
match selection {
Some(selection) => {
if !target.is_empty() {
return Err(format!(
"with --all/--hosts and --src/--dest the host and path slots are already \
named, so positionals have no slot to fill; {TRANSFER_USAGE}"
));
}
Ok(ScpPathPlan::MultiHostMultiFile {
selection,
sources,
dest_dir,
})
}
None => {
if target.len() != 1 {
return Err(format!(
"with --src/--dest and no selector pass exactly one positional, the VPS name; \
got {}; {TRANSFER_USAGE}",
target.len()
));
}
let vps = target
.into_iter()
.next()
.ok_or_else(|| "expected the VPS name".to_string())?;
let _ = VpsName::try_new(&vps).map_err(|e| e.to_string())?;
Ok(ScpPathPlan::MultiFile {
vps,
sources,
dest_dir,
})
}
}
}
pub(crate) fn parse_scp_target(
all: bool,
hosts: Option<String>,
slots: TransferSlots,
target: Vec<String>,
) -> Result<ScpPathPlan, String> {
use crate::domain::VpsName;
use crate::vps::HostSelection;
let selection = transfer_host_slot(all, hosts)?;
if slots.is_named() {
return plan_from_named_slots(selection, slots, target);
}
if let Some(selection) = selection {
return match target.len() {
0 | 1 => Err(format!(
"with --all/--hosts pass the two path slots `<SRC> <DEST>`, or use \
--src/--dest for more than one source; {TRANSFER_USAGE}"
)),
2 => {
let mut it = target.into_iter();
let a = PathBuf::from(
it.next()
.ok_or_else(|| "with --all/--hosts pass SRC DEST paths".to_string())?,
);
let b = PathBuf::from(
it.next()
.ok_or_else(|| "with --all/--hosts pass SRC DEST paths".to_string())?,
);
Ok(ScpPathPlan::Single {
selection,
path_a: a,
path_b: b,
})
}
_ => Err(selector_positional_ambiguity()),
};
}
match target.len() {
0 | 1 => Err(format!(
"expected the host slot and the path slots \
(upload: VPS LOCAL... REMOTE; download: VPS REMOTE... LOCAL); {TRANSFER_USAGE}"
)),
2 => Err(format!(
"missing a path slot (single host needs VPS plus two paths, \
or VPS plus sources plus a destination directory); {TRANSFER_USAGE}"
)),
3 => {
let mut it = target.into_iter();
let vps = it
.next()
.ok_or_else(|| "expected VPS and two paths".to_string())?;
let a = PathBuf::from(
it.next()
.ok_or_else(|| "expected VPS and two paths".to_string())?,
);
let b = PathBuf::from(
it.next()
.ok_or_else(|| "expected VPS and two paths".to_string())?,
);
let vps = VpsName::try_new(vps).map_err(|e| e.to_string())?;
Ok(ScpPathPlan::Single {
selection: HostSelection::Single(vps),
path_a: a,
path_b: b,
})
}
_ => {
let mut it = target.into_iter();
let vps = it
.next()
.ok_or_else(|| "expected VPS and multi-file paths".to_string())?;
let _ = VpsName::try_new(&vps).map_err(|e| e.to_string())?;
let mut paths: Vec<PathBuf> = it.map(PathBuf::from).collect();
let dest_dir = paths
.pop()
.ok_or_else(|| "multi-file scp requires DEST_DIR".to_string())?;
if paths.is_empty() {
return Err("multi-file scp requires at least one source path".into());
}
Ok(ScpPathPlan::MultiFile {
vps,
sources: paths,
dest_dir,
})
}
}
}
#[cfg(test)]
#[path = "path_parse_tests.rs"]
mod tests;