mod links;
use super::tables::WRITE_LAST_PATH;
use crate::paths;
const TARGET_DIRECTORY_PROGRAMS: &[&str] = &["cp", "mv", "ln"];
const DIRECTORY_WORDS: &[&str] = &["~", ".", "..", "$HOME", "${HOME}"];
const REMOTE_PROGRAMS: &[&str] = &["rsync", "scp"];
const UNKNOWN_NAME: &[char] = &['$', '`', '*', '?', '[', '{', ':'];
#[derive(Debug)]
pub(super) struct Destination<'a> {
pub index: usize,
pub word: &'a str,
directory: bool,
}
pub(super) fn destination<'a>(argv: &'a [String], program: &str) -> Option<Destination<'a>> {
if !(WRITE_LAST_PATH.contains(&program) || program == "mv")
|| (program == "pax" && !pax_copies(argv))
{
return None;
}
let gnu = TARGET_DIRECTORY_PROGRAMS.contains(&program);
if gnu && let Some(target) = target_option(argv) {
return Some(target);
}
let index = (1..argv.len()).rev().find(|&i| !argv[i].starts_with('-'))?;
let word = argv[index].as_str();
if REMOTE_PROGRAMS.contains(&program) && is_remote(word) {
return None;
}
let no_target = gnu && argv[1..index].iter().any(|a| is_no_target(a));
let directory = !no_target
&& (word.ends_with('/')
|| word.ends_with("/.")
|| word.ends_with("/..")
|| DIRECTORY_WORDS.contains(&word));
Some(Destination {
index,
word,
directory,
})
}
pub(super) fn written(dest: &Destination<'_>, sources: &[&str]) -> Vec<String> {
let names: Option<Vec<&str>> = sources.iter().map(|s| entry_name(s)).collect();
match names {
Some(names) if dest.directory && !names.is_empty() => {
let dir = dest.word.trim_end_matches('/');
names.iter().map(|name| format!("{dir}/{name}")).collect()
}
_ => vec![dest.word.to_owned()],
}
}
pub(super) fn writes(
argv: &[String],
program: &str,
dest: &Destination<'_>,
sources: &[&str],
) -> Vec<String> {
let mut out = written(dest, sources);
if matches!(program, "ditto" | "pax") {
out.extend(merged(dest, program, sources));
}
if links::hard_links(argv, program) {
out.extend(sources.iter().map(|s| (*s).to_owned()));
}
if program == "rsync" {
out.extend(link_dests(argv).map(|dir| relative_to(dest.word, dir)));
}
out
}
fn merged(dest: &Destination<'_>, program: &str, sources: &[&str]) -> Vec<String> {
let mut out = vec![dest.word.to_owned()];
if program == "pax" {
let dir = dest.word.trim_end_matches('/');
out.extend(sources.iter().map(|source| {
let relative = source.trim_start_matches("./").trim_start_matches('/');
format!("{dir}/{relative}")
}));
}
out
}
fn pax_copies(argv: &[String]) -> bool {
let options = || {
argv.iter()
.skip(1)
.filter(|a| a.starts_with('-') && !a.starts_with("--"))
};
options().any(|a| a.contains('r')) && options().any(|a| a.contains('w'))
}
fn link_dests(argv: &[String]) -> impl Iterator<Item = &str> {
let name = |long: &str| long.starts_with("link-") && "link-dest".starts_with(long);
argv.iter()
.enumerate()
.skip(1)
.take_while(|(_, a)| *a != "--")
.filter_map(move |(i, arg)| {
let long = arg.strip_prefix("--")?;
match long.split_once('=') {
Some((key, dir)) => name(key).then_some(dir),
None => argv.get(i + 1).map(String::as_str).filter(|_| name(long)),
}
})
}
fn relative_to(dest: &str, dir: &str) -> String {
if paths::is_absolute(dir) || dir.starts_with(['~', '$']) {
dir.to_owned()
} else {
format!("{}/{dir}", dest.trim_end_matches('/'))
}
}
fn entry_name(source: &str) -> Option<&str> {
let name = source.rsplit('/').next().unwrap_or(source);
let known = !name.is_empty() && !DIRECTORY_WORDS.contains(&name);
(known && !source.contains(UNKNOWN_NAME)).then_some(name)
}
fn target_option(argv: &[String]) -> Option<Destination<'_>> {
for (i, arg) in argv.iter().enumerate().skip(1) {
if arg == "--" {
return None;
}
let inline = if let Some(long) = arg.strip_prefix("--") {
let (name, value) = long
.split_once('=')
.map_or((long, None), |(n, v)| (n, Some(v)));
if name.is_empty() || !"target-directory".starts_with(name) {
continue;
}
value
} else if let Some((_, rest)) = arg.strip_prefix('-').and_then(|s| s.split_once('t')) {
Some(rest).filter(|r| !r.is_empty())
} else {
continue;
};
let (index, word) = match inline {
Some(value) => (i, value),
None => (i + 1, argv.get(i + 1)?.as_str()),
};
return Some(Destination {
index,
word,
directory: true,
});
}
None
}
fn is_remote(word: &str) -> bool {
let head = word.split('/').next().unwrap_or(word);
head.contains(':') && !paths::is_absolute(word)
}
fn is_no_target(arg: &str) -> bool {
match arg.strip_prefix("--") {
Some(long) => long.len() > 2 && "no-target-directory".starts_with(long),
None => arg.starts_with('-') && arg.contains('T'),
}
}
#[cfg(test)]
mod tests {
use super::{destination, written};
fn of(cmd: &str, all: bool) -> Vec<String> {
let argv: Vec<String> = cmd.split(' ').map(str::to_owned).collect();
let dest = destination(&argv, &argv[0]).expect("a copy");
let end = argv.iter().position(|a| a == "--");
let sources: Vec<&str> = argv
.iter()
.enumerate()
.skip(1)
.filter(|(i, a)| {
*i != dest.index
&& *a != "--"
&& (end.is_some_and(|e| *i > e) || !a.starts_with('-'))
})
.map(|(_, a)| a.as_str())
.collect();
if all {
super::writes(&argv, &argv[0], &dest, &sources)
} else {
written(&dest, &sources)
}
}
fn writes(cmd: &str) -> Vec<String> {
of(cmd, false)
}
#[test]
fn a_hard_link_also_writes_the_file_it_links_to() {
assert_eq!(
of("ln ~/.claude/settings.json s.json", true),
["s.json", "~/.claude/settings.json"]
);
assert_eq!(of("ln -f -- ~/.moat/x y", true), ["y", "~/.moat/x"]);
assert_eq!(of("ln -t . ~/.moat/x", true), ["./x", "~/.moat/x"]);
assert_eq!(of("link ~/.moat/x y", true), ["y", "~/.moat/x"]);
assert_eq!(of("cp -al ~/.moat ./m", true), ["./m", "~/.moat"]);
assert_eq!(of("cp --li ~/.moat/x y", true), ["y", "~/.moat/x"]);
assert_eq!(
of("rsync -a --link-dest=/h/.moat src/ out/", true),
["out/", "/h/.moat"]
);
assert_eq!(
of("rsync -a --link-dest ../prev src/ out/", true),
["out/", "out/../prev"]
);
}
#[test]
fn a_symbolic_link_or_a_plain_copy_writes_only_its_destination() {
for cmd in [
"ln -s ~/.moat/x y",
"ln -sf ~/.moat/x y",
"ln --sym ~/.moat/x y",
"cp ~/.moat/x y",
"cp -L ~/.moat/x y",
"cp --links-not-an-option ~/.moat/x y",
"rsync -a --links src/ out/",
] {
assert_eq!(of(cmd, true), of(cmd, false), "{cmd}");
}
assert_eq!(of("ln -s -S.l ~/.moat/x y", true), ["y"]);
assert_eq!(of("cp -t/tmp/l ~/.moat/x", true), ["/tmp/l/x"]);
}
#[test]
fn into_a_directory_writes_the_entry() {
assert_eq!(writes("cp f ~/"), ["~/f"]);
assert_eq!(writes("mv src/a.rs ~"), ["~/a.rs"]);
assert_eq!(writes("cp -r assets /"), ["/assets"]);
assert_eq!(writes("cp a b ./out/"), ["./out/a", "./out/b"]);
assert_eq!(writes("rsync -a dist ~/"), ["~/dist"]);
assert_eq!(writes("cp -t ~ f"), ["~/f"]);
assert_eq!(writes("cp --target-directory=~ f"), ["~/f"]);
assert_eq!(writes("mv --target ~ f"), ["~/f"]);
assert_eq!(writes("cp -rt ~ f"), ["~/f"]);
}
#[test]
fn otherwise_the_destination_itself() {
assert_eq!(writes("mv dir newname"), ["newname"]);
assert_eq!(writes("cp -T f ~/"), ["~/"]);
assert_eq!(writes("mv --no-target-directory f ~"), ["~"]);
assert_eq!(writes("rsync -a dist/ ~/"), ["~/"]);
assert_eq!(writes("cp -r ~ /backup/"), ["/backup/"]);
assert_eq!(writes("cp * ~/"), ["~/"]);
assert_eq!(writes("cp $X ~/"), ["~/"]);
assert_eq!(writes("cp ~/"), ["~/"]);
assert_eq!(writes("scp host:.zshrc notes ~/"), ["~/"]);
assert!(destination(&["rm".into(), "~".into()], "rm").is_none());
}
#[test]
fn a_remote_destination_is_no_local_write() {
for cmd in [
"scp key host:/tmp/",
"scp key u@host:",
"rsync -a d host::m/",
"rsync d rsync://host/m",
] {
let argv: Vec<String> = cmd.split(' ').map(str::to_owned).collect();
assert!(destination(&argv, &argv[0]).is_none(), "{cmd}");
}
assert_eq!(writes("scp f C:/backup/"), ["C:/backup/f"]);
assert_eq!(writes("cp f host:/tmp/"), ["host:/tmp/f"]);
}
}