From b775f84f5adda40c7f2b5f94d00f14d07cdf0efd Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Tue, 3 Nov 2026 10:36:56 +0200 Subject: [PATCH 1/2] Make fsfork say only where the fork is when it starts a shell Everyone who uses a shell knows that `exit` leaves it, so "exit to come back" said nothing new, and made the line read unlike `confine`'s. --- cmd/fsfork/fsfork.rs | 2 +- cmd/fsfork/tests/fsfork.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/fsfork/fsfork.rs b/cmd/fsfork/fsfork.rs index 58b2f57..3dafdc9 100644 --- a/cmd/fsfork/fsfork.rs +++ b/cmd/fsfork/fsfork.rs @@ -70,7 +70,7 @@ fn print(fork: PathBuf) -> io::Result<()> { /// Runs the shell in `fork`, in place of this process. If it cannot be run, /// removes the fork, and returns why. fn start_shell(fork: &Path) -> io::Result<()> { - eprintln!("fsfork: forked to {}, exit to come back", fork.display()); + eprintln!("fsfork: forked to {}", fork.display()); let fork = fork.canonicalize().unwrap_or_else(|_| fork.to_path_buf()); report_cwd(&fork); let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); diff --git a/cmd/fsfork/tests/fsfork.rs b/cmd/fsfork/tests/fsfork.rs index a52ab33..917480d 100644 --- a/cmd/fsfork/tests/fsfork.rs +++ b/cmd/fsfork/tests/fsfork.rs @@ -39,8 +39,11 @@ fn on_a_terminal_starts_a_shell_in_the_fork() { read(&world.dir, "pwd"), format!("{0}\n{0}\n", fork.display()) ); - assert!( - stderr(&out).contains(&format!("forked to {},", fork.display())), + // fsfork says one line before the shell starts; what follows is the + // shell's own, such as dash's "can't access tty" on Linux. + assert_eq!( + stderr(&out).lines().next(), + Some(format!("fsfork: forked to {}", fork.display()).as_str()), "{}", stderr(&out) ); From baa7b59c87faa225a5022739670d91a7153fa0ca Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Tue, 3 Nov 2026 10:42:54 +0200 Subject: [PATCH 2/2] Make fsfork, confine and fsdiff say what went wrong in one form What the commands said on stderr was in many forms: the system's errors capitalized, with Rust's "(os error 17)", and ours in lowercase; paths made absolute, often in the temporary directory, whatever was typed; a taken name said as the base being there, not the fork; argh's errors with no command name and a blank line; and conflicting options explained at length. Now everything is `prog: subject: problem`, on one line, in lowercase: fsfork: p.x: file exists fsfork: p.x: its base .p.x.base is left over; remove it fsfork: nope: no such file or directory fsdiff: .: not a fork: no .p.base next to it confine: nosuchcmd: command not found A path is as it was typed, or, if made by the command, as it would be typed from the working directory. `forked to` still says where the fork is in full, as it is said once, and the command then runs elsewhere. Wrong arguments are said with how to give them: fsfork: -n cannot be given with a fork usage: fsfork [--no-ignore] [--print] [-n name] [dir [fork]] `confine` looks for the command before running it, and before forking, so that one that is not there is said to be not found, exits 127 as the man page says, and leaves no fork, rather than being reported by `sandbox-exec` with 71. On Linux, the Landlock error no longer says "confine" twice. The warning when git cannot tell what is ignored is said by the command that forked, `confine` too, with the first line of git's error. The forms are kept in `fsfork::message`, which the three share. --- Cargo.lock | 1 + cmd/confine/confine.rs | 104 +++++++++++++++++++-------- cmd/confine/linux.rs | 2 +- cmd/confine/tests/confine.rs | 39 +++++++++- cmd/fsdiff/fsdiff.rs | 74 +++++++++---------- cmd/fsdiff/git.rs | 10 +-- cmd/fsdiff/tests/fsdiff.rs | 19 ++++- cmd/fsfork/fsfork.rs | 59 +++++++++------ cmd/fsfork/tests/fsfork.rs | 57 +++++++++++++-- fsfork/Cargo.toml | 1 + fsfork/fsfork.rs | 40 +++++++---- fsfork/ignored.rs | 24 ++++--- fsfork/message.rs | 135 +++++++++++++++++++++++++++++++++++ 13 files changed, 437 insertions(+), 128 deletions(-) create mode 100644 fsfork/message.rs diff --git a/Cargo.lock b/Cargo.lock index 4ce6fc9..3eb4a95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,7 @@ dependencies = [ name = "fsfork" version = "0.1.0" dependencies = [ + "argh", "libc", ] diff --git a/cmd/confine/confine.rs b/cmd/confine/confine.rs index 55440cf..093bf34 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -20,13 +20,19 @@ use std::{ env, ffi::OsStr, io, - os::unix::process::{CommandExt, ExitStatusExt}, + os::unix::{ + fs::PermissionsExt, + process::{CommandExt, ExitStatusExt}, + }, path::{Path, PathBuf}, process::{ExitCode, ExitStatus}, }; use argh::FromArgs; -use fsfork::Contents; +use fsfork::{Contents, at, message}; + +/// How to give the arguments, as in the man page. +const USAGE: &str = "confine [-w path]... [-r path]... [-n name] [--no-ignore] [--rm | -d dir] [command [argument ...]]"; /// Directories under the home directory that the command may write to, so /// that builds and package managers keep working. @@ -106,32 +112,36 @@ enum Ran { } fn main() -> ExitCode { - let args: Args = argh::from_env(); - if args.rm && args.dir.is_some() { - eprintln!("confine: --rm removes a fork, and -d runs the command without one"); - return ExitCode::FAILURE; - } - if args.no_ignore && args.dir.is_some() { - eprintln!( - "confine: --no-ignore is about what is in a fork, and -d runs the command without one" - ); - return ExitCode::FAILURE; - } - if args.name.is_some() && args.dir.is_some() { - eprintln!("confine: --name names a fork, and -d runs the command without one"); - return ExitCode::FAILURE; + let args: Args = match message::parse(USAGE, 1) { + Ok(args) => args, + Err(code) => return code, + }; + if args.dir.is_some() { + let conflict = if args.rm { + Some("--rm") + } else if args.no_ignore { + Some("--no-ignore") + } else if args.name.is_some() { + Some("-n") + } else { + None + }; + if let Some(option) = conflict { + let problem = format!("{option} cannot be given with -d, which makes no fork"); + return message::usage_error(&problem, USAGE, 1); + } } let e = match confine(&args) { Ok(Ran::Exited(status)) => return exit_as(status), Ok(Ran::Failed(e)) => e, Err(e) => { - eprintln!("confine: {e}"); + message::say(message::describe(&e)); return ExitCode::FAILURE; } }; // As env(1) does: 127 if the command was not found, 126 if it could not // be run. - eprintln!("confine: {}: {e}", args.command.first().map_or("", |c| c)); + message::say(message::describe(&e)); if e.kind() == io::ErrorKind::NotFound { ExitCode::from(127) } else { @@ -167,13 +177,15 @@ fn confine(args: &Args) -> io::Result { } else { args.command.clone() }; + // Before forking, so that a command that is not there makes no fork. + if let Err(e) = find_in_path(&command[0]) { + return Ok(Ran::Failed(e)); + } if let Some(dir) = &args.dir { + let typed = dir; let dir = dir.canonicalize().map_err(|e| at(dir, e))?; if !dir.is_dir() { - return Err(io::Error::other(format!( - "{}: not a directory", - dir.display() - ))); + return Err(at(typed, io::Error::from_raw_os_error(libc::ENOTDIR))); } return run(&command, &dir, &writable, &readable, &hidden, false); } @@ -191,7 +203,7 @@ fn confine(args: &Args) -> io::Result { if (args.rm || !matches!(ran, Ok(Ran::Exited(_)))) && let Err(e) = fsfork::remove(&fork) { - eprintln!("confine: {e}"); + message::say(message::describe(&e)); } ran } @@ -206,12 +218,17 @@ fn run( hidden: &[PathBuf], wait: bool, ) -> io::Result { + // So that it is said to be not found by `confine`, rather than by the + // sandbox, which is what is run. + if let Err(e) = find_in(&command[0], dir) { + return Ok(Ran::Failed(e)); + } let mut confined = os::confine(command, dir, writable, readable, hidden)?; // Claude Code would otherwise ask whether to trust every new directory, // and accepting would write to `~/.claude.json`, which it may not. confined.current_dir(dir).env("CLAUDE_CODE_SANDBOXED", "1"); if !wait { - return Ok(Ran::Failed(confined.exec())); + return Ok(Ran::Failed(at(Path::new(&command[0]), confined.exec()))); } for signal in SIGNALS { unsafe { libc::signal(signal, libc::SIG_IGN) }; @@ -227,7 +244,7 @@ fn run( } Ok(match confined.status() { Ok(status) => Ran::Exited(status), - Err(e) => Ran::Failed(e), + Err(e) => Ran::Failed(at(Path::new(&command[0]), e)), }) } @@ -244,7 +261,38 @@ fn exit_as(status: ExitStatus) -> ExitCode { ExitCode::from(status.code().unwrap_or(1) as u8) } -/// `e`, saying it is about `path`. -fn at(path: &Path, e: io::Error) -> io::Error { - io::Error::new(e.kind(), format!("{}: {e}", path.display())) +/// Finds `command`, if it has no `/` in it, in `$PATH`, as a shell would. +fn find_in_path(command: &str) -> io::Result<()> { + if command.contains('/') { + return Ok(()); + } + let path = env::var_os("PATH").unwrap_or_default(); + let dirs: Vec = env::split_paths(&path).collect(); + // A relative directory is in the one the command runs in, not this one. + let found = dirs.iter().any(|dir| { + dir.is_relative() + || dir + .join(command) + .metadata() + .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) + }); + if found { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("{command}: command not found"), + )) + } +} + +/// Finds `command`, if it has a `/` in it, from `dir`, where it runs. +fn find_in(command: &str, dir: &Path) -> io::Result<()> { + if !command.contains('/') { + return Ok(()); + } + dir.join(command) + .metadata() + .map(|_| ()) + .map_err(|e| at(Path::new(command), e)) } diff --git a/cmd/confine/linux.rs b/cmd/confine/linux.rs index cad43a4..c5d4b0d 100644 --- a/cmd/confine/linux.rs +++ b/cmd/confine/linux.rs @@ -96,7 +96,7 @@ pub fn confine( }; if abi < MIN_ABI { return Err(io::Error::other( - "confine needs Landlock, in Linux 5.19 or later, enabled", + "Landlock is not available; it needs Linux 5.19 or later", )); } let mut handled = READ | WRITE; diff --git a/cmd/confine/tests/confine.rs b/cmd/confine/tests/confine.rs index b24365e..8f36097 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -151,7 +151,7 @@ fn name_cannot_be_given_with_d() { let dir = world.dir("project"); assert_fails( &world.confine(&dir, &["--name", "x", "-d", ".", "true"]), - "--name", + "-n cannot be given with -d", ); assert_no_forks(&world); } @@ -504,3 +504,40 @@ fn assert_fails(out: &Output, message: &str) { stderr(out) ); } + +#[test] +fn says_a_command_that_is_not_there_is_not_found_and_makes_no_fork() { + let world = World::new(); + let dir = world.dir("project"); + let out = world.confine(&dir, &["no-such-command"]); + assert_eq!(out.status.code(), Some(127), "{}", stderr(&out)); + assert_eq!( + stderr(&out), + "confine: no-such-command: command not found\n" + ); + assert_no_forks(&world); + let out = world.confine(&dir, &["-d", ".", "./missing"]); + assert_eq!(out.status.code(), Some(127), "{}", stderr(&out)); + assert_eq!( + stderr(&out), + "confine: ./missing: no such file or directory\n" + ); +} + +#[test] +fn says_what_is_wrong_with_the_arguments_and_how_to_give_them() { + let world = World::new(); + let dir = world.dir("project"); + let out = world.confine(&dir, &["--rm", "-d", ".", "true"]); + let said = stderr(&out); + let mut lines = said.lines(); + assert_eq!( + lines.next(), + Some("confine: --rm cannot be given with -d, which makes no fork") + ); + assert!( + lines.next().unwrap().starts_with("usage: confine "), + "{said}" + ); + assert_eq!(lines.next(), None); +} diff --git a/cmd/fsdiff/fsdiff.rs b/cmd/fsdiff/fsdiff.rs index 54fdcc7..e8eb881 100644 --- a/cmd/fsdiff/fsdiff.rs +++ b/cmd/fsdiff/fsdiff.rs @@ -4,12 +4,16 @@ mod git; use std::{ - env, io, + io, path::{Path, PathBuf}, process::ExitCode, }; use argh::FromArgs; +use fsfork::{at, message}; + +/// How to give the arguments, as in the man page, a line for each form. +const USAGE: &str = "fsdiff [--stat] [fork]\nfsdiff [--stat] old new"; /// Show what changed in a fork since it was made, or between two /// directories, as a patch. @@ -30,7 +34,7 @@ struct Args { const TROUBLE: u8 = 2; fn main() -> ExitCode { - let args = match parse() { + let args: Args = match message::parse(USAGE, TROUBLE) { Ok(args) => args, Err(code) => return code, }; @@ -42,72 +46,58 @@ fn main() -> ExitCode { match fsdiff(&args) { Ok(false) => ExitCode::SUCCESS, Ok(true) => ExitCode::from(1), - Err(e) => { - eprintln!("fsdiff: {e}"); + Err(Wrong::Usage(problem)) => message::usage_error(problem, USAGE, TROUBLE), + Err(Wrong::Failed(e)) => { + message::say(message::describe(&e)); ExitCode::from(TROUBLE) } } } -/// The arguments `fsdiff` was given, or the code to exit with if they were -/// not right, or asked for help. -fn parse() -> Result { - let strings: Vec = env::args().collect(); - let command = Path::new(&strings[0]) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("fsdiff"); - let strs: Vec<&str> = strings[1..].iter().map(String::as_str).collect(); - Args::from_args(&[command], &strs).map_err(|exit| match exit.status { - Ok(()) => { - print!("{}", exit.output); - ExitCode::SUCCESS - } - Err(()) => { - eprint!("{}", exit.output); - ExitCode::from(TROUBLE) - } - }) +/// Why there is no difference to show. +enum Wrong { + /// The arguments were not right, for this reason. + Usage(&'static str), + /// Showing it failed. + Failed(io::Error), +} + +impl From for Wrong { + fn from(e: io::Error) -> Self { + Wrong::Failed(e) + } } /// Shows the difference `args` ask for, and returns whether there is one. -fn fsdiff(args: &Args) -> io::Result { +fn fsdiff(args: &Args) -> Result { let (old, new) = match &args.paths[..] { [old, new] => (directory(old)?, directory(new)?), [] | [_] => { let fork = args.paths.first().map_or(Path::new("."), PathBuf::as_path); + let typed = fork; let fork = directory(fork)?; let base = fsfork::base_of(&fork); if !base.is_dir() { - return Err(io::Error::other(format!( - "{}: no base at {}; is it a fork?", - fork.display(), - base.display() - ))); + let name = base.file_name().unwrap_or_default().to_string_lossy(); + return Err(Wrong::Failed(io::Error::other(format!( + "{}: not a fork: no {name} next to it", + typed.display() + )))); } (base, fork) } - _ => { - return Err(io::Error::other( - "too many arguments: a fork, or two directories", - )); - } + _ => return Err(Wrong::Usage("too many arguments")), }; let repo = git::Repo::new()?; let (before, after) = repo.record(&old, &new)?; - repo.diff(&before, &after, args.stat) + Ok(repo.diff(&before, &after, args.stat)?) } /// `path`, canonical, if it is a directory. fn directory(path: &Path) -> io::Result { - let dir = path - .canonicalize() - .map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", path.display())))?; + let dir = path.canonicalize().map_err(|e| at(path, e))?; if !dir.is_dir() { - return Err(io::Error::other(format!( - "{}: not a directory", - dir.display() - ))); + return Err(at(path, io::Error::from_raw_os_error(libc::ENOTDIR))); } Ok(dir) } diff --git a/cmd/fsdiff/git.rs b/cmd/fsdiff/git.rs index b1e9e19..4cc73d0 100644 --- a/cmd/fsdiff/git.rs +++ b/cmd/fsdiff/git.rs @@ -14,6 +14,8 @@ use std::{ process::{self, Command, ExitStatus}, }; +use fsfork::message::shown; + /// The signals from the terminal, which `fsdiff` ignores and git does not. pub const SIGNALS: [i32; 3] = [libc::SIGINT, libc::SIGQUIT, libc::SIGHUP]; @@ -111,8 +113,8 @@ impl Repo { )?; if !added.success() { return Err(io::Error::other(format!( - "git could not read {}", - tree.display() + "{}: git could not read it", + shown(tree).display() ))); } let written = self @@ -122,8 +124,8 @@ impl Repo { .output()?; if !written.status.success() { return Err(io::Error::other(format!( - "git could not record {}", - tree.display() + "{}: git could not record it", + shown(tree).display() ))); } Ok(String::from_utf8_lossy(&written.stdout).trim().to_string()) diff --git a/cmd/fsdiff/tests/fsdiff.rs b/cmd/fsdiff/tests/fsdiff.rs index 6ba1738..5b054ed 100644 --- a/cmd/fsdiff/tests/fsdiff.rs +++ b/cmd/fsdiff/tests/fsdiff.rs @@ -69,7 +69,10 @@ fn exits_with_2_for_a_directory_that_is_not_a_fork() { let dir = world.project(); let out = world.fsdiff(&dir, &[]); assert_eq!(out.status.code(), Some(2)); - assert!(stderr(&out).contains("is it a fork?"), "{}", stderr(&out)); + assert_eq!( + stderr(&out), + "fsdiff: .: not a fork: no .project.base next to it\n" + ); let out = world.fsdiff(&dir, &["missing"]); assert_eq!(out.status.code(), Some(2)); let out = world.fsdiff(&dir, &["--no-such-option"]); @@ -318,3 +321,17 @@ fn stdout(out: &Output) -> String { fn stderr(out: &Output) -> String { String::from_utf8_lossy(&out.stderr).into_owned() } + +#[test] +fn says_what_is_wrong_with_the_arguments_and_how_to_give_them() { + let world = World::new(); + let dir = world.project(); + let out = world.fsdiff(&dir, &["a", "b", "c"]); + assert_eq!(out.status.code(), Some(2)); + assert_eq!( + stderr(&out), + "fsdiff: too many arguments\n\ + usage: fsdiff [--stat] [fork]\n \ + fsdiff [--stat] old new\n" + ); +} diff --git a/cmd/fsfork/fsfork.rs b/cmd/fsfork/fsfork.rs index 3dafdc9..63bfe67 100644 --- a/cmd/fsfork/fsfork.rs +++ b/cmd/fsfork/fsfork.rs @@ -12,7 +12,10 @@ use std::{ }; use argh::FromArgs; -use fsfork::Contents; +use fsfork::{Contents, message}; + +/// How to give the arguments, as in the man page. +const USAGE: &str = "fsfork [--no-ignore] [--print] [-n name] [dir [fork]]"; /// Fork a directory: make a copy-on-write clone of it, and start a shell there /// or, if not on a terminal, print where it is. @@ -39,22 +42,31 @@ struct Args { } fn main() -> ExitCode { - let args: Args = argh::from_env(); + let args: Args = match message::parse(USAGE, 1) { + Ok(args) => args, + Err(code) => return code, + }; // A shell needs both: with stdout captured, as in `cd $(fsfork)`, the // path is wanted, and with stdin not a terminal, a shell would run what // it reads from there as commands. let shell = !args.print && io::stdin().is_terminal() && io::stdout().is_terminal(); - let forked = fsfork(args).and_then(|fork| { - if shell { - start_shell(&fork) - } else { - print(fork) + let fork = match fsfork(args) { + Ok(fork) => fork, + Err(Wrong::Usage(problem)) => return message::usage_error(problem, USAGE, 1), + Err(Wrong::Failed(e)) => { + message::say(message::describe(&e)); + return ExitCode::FAILURE; } - }); - match forked { + }; + let done = if shell { + start_shell(&fork) + } else { + print(fork) + }; + match done { Ok(()) => ExitCode::SUCCESS, Err(e) => { - eprintln!("fsfork: {e}"); + message::say(message::describe(&e)); ExitCode::FAILURE } } @@ -79,9 +91,9 @@ fn start_shell(fork: &Path) -> io::Result<()> { .env("PWD", &fork) .exec(); if let Err(e) = fsfork::remove(&fork) { - eprintln!("fsfork: {e}"); + message::say(message::describe(&e)); } - Err(io::Error::other(format!("{shell}: {e}"))) + Err(fsfork::at(Path::new(&shell), e)) } /// Tells the terminal, on stdout, that its working directory is `dir`, with @@ -117,24 +129,29 @@ fn hostname() -> String { String::from_utf8_lossy(&name[..len]).into_owned() } +/// Why the directory was not forked. +enum Wrong { + /// The arguments were not right, for this reason. + Usage(&'static str), + /// Forking it failed. + Failed(io::Error), +} + /// Forks the directory `args` give, and returns where the fork is. -fn fsfork(args: Args) -> io::Result { +fn fsfork(args: Args) -> Result { let contents = if args.no_ignore { Contents::Everything } else { Contents::NotIgnored }; - match (&args.name, &args.paths[..]) { + let forked = match (&args.name, &args.paths[..]) { (None, []) => fsfork::fork(Path::new("."), contents), (None, [dir]) => fsfork::fork(dir, contents), (None, [dir, fork]) => fsfork::fork_to(dir, fork, contents), (Some(name), []) => fsfork::fork_named(Path::new("."), OsStr::new(name), contents), (Some(name), [dir]) => fsfork::fork_named(dir, OsStr::new(name), contents), - (Some(_), [_, _]) => Err(io::Error::other( - "--name names the fork next to the directory, and a second argument says where to put it; give one or the other", - )), - _ => Err(io::Error::other( - "too many arguments: a directory, and a fork", - )), - } + (Some(_), [_, _]) => return Err(Wrong::Usage("-n cannot be given with a fork")), + _ => return Err(Wrong::Usage("too many arguments")), + }; + forked.map_err(Wrong::Failed) } diff --git a/cmd/fsfork/tests/fsfork.rs b/cmd/fsfork/tests/fsfork.rs index 917480d..4f7f45c 100644 --- a/cmd/fsfork/tests/fsfork.rs +++ b/cmd/fsfork/tests/fsfork.rs @@ -221,14 +221,17 @@ fn fails_if_the_name_is_taken_or_is_no_name() { let world = World::new(); let dir = world.project(); write(&world.path("project.swebench"), "mine", "mine\n"); - assert_fails(&fsfork(&dir, &["--name", "swebench"]), "exists"); + assert_fails( + &fsfork(&dir, &["--name", "swebench"]), + "fsfork: ../project.swebench: file exists\n", + ); assert_eq!(read(&world.path("project.swebench"), "mine"), "mine\n"); assert!(!world.path(".project.swebench.base").exists()); assert_fails(&fsfork(&dir, &["--name", ""]), "empty"); assert_fails(&fsfork(&dir, &["--name", "a/b"]), "/"); assert_fails( &fsfork(&world.dir, &["--name", "x", "project", "parser"]), - "--name", + "fsfork: -n cannot be given with a fork\nusage: fsfork ", ); assert!(!world.path("parser").exists()); assert!(!world.path("project.x").exists()); @@ -442,10 +445,16 @@ fn fails_if_the_fork_or_its_base_exists_with_something_ignored() { write(&world.path("parser"), "mine", "mine\n"); write(&world.path(".lexer.base"), "mine", "mine\n"); let args = |fork| ["project", fork]; - assert_fails(&fsfork(&world.dir, &args("parser")), "exists"); + assert_fails( + &fsfork(&world.dir, &args("parser")), + "fsfork: parser: file exists\n", + ); assert_eq!(read(&world.path("parser"), "mine"), "mine\n"); assert!(!world.path(".parser.base").exists()); - assert_fails(&fsfork(&world.dir, &args("lexer")), "exists"); + assert_fails( + &fsfork(&world.dir, &args("lexer")), + "fsfork: lexer: its base .lexer.base is left over; remove it\n", + ); assert_eq!(read(&world.path(".lexer.base"), "mine"), "mine\n"); assert!(!world.path("lexer").exists()); } @@ -469,10 +478,16 @@ fn fails_if_the_fork_or_its_base_exists() { let world = World::new(); world.project(); fs::create_dir(world.path("parser")).unwrap(); - assert_fails(&fsfork(&world.dir, &["project", "parser"]), "exists"); + assert_fails( + &fsfork(&world.dir, &["project", "parser"]), + "fsfork: parser: file exists\n", + ); assert!(!world.path(".parser.base").exists()); fs::create_dir(world.path(".lexer.base")).unwrap(); - assert_fails(&fsfork(&world.dir, &["project", "lexer"]), "exists"); + assert_fails( + &fsfork(&world.dir, &["project", "lexer"]), + "fsfork: lexer: its base .lexer.base is left over; remove it\n", + ); assert!(!world.path("lexer").exists()); } @@ -676,3 +691,33 @@ fn assert_fails(out: &Output, message: &str) { stderr(out) ); } + +#[test] +fn says_what_is_wrong_with_the_arguments_and_how_to_give_them() { + let world = World::new(); + let dir = world.project(); + let usage = "usage: fsfork [--no-ignore] [--print] [-n name] [dir [fork]]\n"; + let out = fsfork(&dir, &["--bogus"]); + assert_eq!(out.status.code(), Some(1)); + assert_eq!( + stderr(&out), + format!("fsfork: unrecognized argument: --bogus\n{usage}") + ); + let out = fsfork(&dir, &["-n"]); + assert_eq!( + stderr(&out), + format!("fsfork: no value provided for option '-n'\n{usage}") + ); + let out = fsfork(&world.dir, &["a", "b", "c"]); + assert_eq!(stderr(&out), format!("fsfork: too many arguments\n{usage}")); +} + +#[test] +fn says_what_is_wrong_in_lowercase_with_paths_as_typed() { + let world = World::new(); + world.project(); + let out = fsfork(&world.dir, &["missing"]); + assert_eq!(stderr(&out), "fsfork: missing: no such file or directory\n"); + let out = fsfork(&world.dir, &["project/a"]); + assert_eq!(stderr(&out), "fsfork: project/a: not a directory\n"); +} diff --git a/fsfork/Cargo.toml b/fsfork/Cargo.toml index 07e251d..1f9c4eb 100644 --- a/fsfork/Cargo.toml +++ b/fsfork/Cargo.toml @@ -9,4 +9,5 @@ license.workspace = true path = "fsfork.rs" [dependencies] +argh.workspace = true libc.workspace = true diff --git a/fsfork/fsfork.rs b/fsfork/fsfork.rs index 32bff75..748d01d 100644 --- a/fsfork/fsfork.rs +++ b/fsfork/fsfork.rs @@ -7,6 +7,7 @@ mod darwin; mod ignored; #[cfg(target_os = "linux")] mod linux; +pub mod message; #[cfg(target_os = "macos")] use darwin as os; @@ -24,6 +25,8 @@ use std::{ }; use ignored::Ignored; +pub use message::at; +use message::shown; /// What of a directory a fork of it has. #[derive(Clone, Copy)] @@ -111,16 +114,30 @@ fn next_to(dir: &Path, suffix: &OsStr) -> io::Result { fn clone(dir: &Path, fork: &Path, contents: Contents) -> io::Result<()> { if fork.starts_with(dir) { return Err(io::Error::other(format!( - "{}: a fork cannot be inside the directory it is a fork of", - fork.display() + "{}: cannot be inside the directory it forks", + shown(fork).display() ))); } + if fork.symlink_metadata().is_ok() { + return Err(at(fork, io::Error::from_raw_os_error(libc::EEXIST))); + } let base = base_of(fork); match contents { Contents::Everything => os::clone(dir, &base), Contents::NotIgnored => clone_not_ignored(dir, &base), } - .map_err(|e| at(&base, e))?; + .map_err(|e| match e.kind() { + // A fork removed without its base. + io::ErrorKind::AlreadyExists => io::Error::new( + e.kind(), + format!( + "{}: its base {} is left over; remove it", + shown(fork).display(), + shown(&base).display() + ), + ), + _ => at(&base, e), + })?; if let Err(e) = os::clone(&base, fork) { let _ = fs::remove_dir_all(&base); return Err(at(fork, e)); @@ -135,7 +152,10 @@ fn clone_not_ignored(dir: &Path, to: &Path) -> io::Result<()> { let ignored = match ignored::ignored(dir) { Ok(ignored) => ignored, Err(e) => { - eprintln!("fsfork: cannot tell what is ignored, so forking everything: {e}"); + message::say(format!( + "forking everything, as git cannot tell what is ignored: {}", + message::describe(&e) + )); return os::clone(dir, to); } }; @@ -179,10 +199,7 @@ fn clone_around(dir: &Path, to: &Path, ignored: &Ignored) -> io::Result<()> { fn directory(path: &Path) -> io::Result { let dir = path.canonicalize().map_err(|e| at(path, e))?; if !dir.is_dir() { - return Err(io::Error::other(format!( - "{}: not a directory", - dir.display() - ))); + return Err(at(path, io::Error::from_raw_os_error(libc::ENOTDIR))); } Ok(dir) } @@ -194,17 +211,12 @@ fn resolve(path: &Path) -> io::Result { let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { return Err(io::Error::other(format!( "{}: not a place for a fork", - path.display() + shown(&path).display() ))); }; Ok(parent.canonicalize().map_err(|e| at(parent, e))?.join(name)) } -/// `e`, saying it is about `path`. -fn at(path: &Path, e: io::Error) -> io::Error { - io::Error::new(e.kind(), format!("{}: {e}", path.display())) -} - #[cfg(test)] mod tests { use super::*; diff --git a/fsfork/ignored.rs b/fsfork/ignored.rs index ccd0d8f..fae1c8b 100644 --- a/fsfork/ignored.rs +++ b/fsfork/ignored.rs @@ -17,6 +17,8 @@ use std::{ process::{self, Command}, }; +use crate::message::describe; + /// The paths in a directory that are ignored, and the directories that have /// some in them. pub struct Ignored { @@ -123,14 +125,11 @@ impl Repo { .arg(dir) .current_dir(root) .output() - .map_err(|e| io::Error::other(format!("cannot run git: {e}")))?; + .map_err(|e| io::Error::other(format!("git: {}", describe(&e))))?; match checked.status.code() { Some(0) => Ok(true), Some(1) => Ok(false), - _ => Err(io::Error::other(format!( - "git: {}", - String::from_utf8_lossy(&checked.stderr).trim() - ))), + _ => Err(failed(&checked.stderr)), } } } @@ -159,16 +158,21 @@ fn git() -> Command { fn run(command: &mut Command) -> io::Result> { let out = command .output() - .map_err(|e| io::Error::other(format!("cannot run git: {e}")))?; + .map_err(|e| io::Error::other(format!("git: {}", describe(&e))))?; if !out.status.success() { - return Err(io::Error::other(format!( - "git: {}", - String::from_utf8_lossy(&out.stderr).trim() - ))); + return Err(failed(&out.stderr)); } Ok(out.stdout) } +/// Git having failed, saying why in `stderr`, of which the first line says +/// enough. +fn failed(stderr: &[u8]) -> io::Error { + let stderr = String::from_utf8_lossy(stderr); + let why = stderr.lines().next().unwrap_or("failed").trim(); + io::Error::other(format!("git: {why}")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/fsfork/message.rs b/fsfork/message.rs new file mode 100644 index 0000000..44c8c97 --- /dev/null +++ b/fsfork/message.rs @@ -0,0 +1,135 @@ +//! What the commands say, on stderr, all in one form: `prog: subject: +//! problem`, on one line, in lowercase, with the subject a path as it would +//! be typed from the working directory. + +use std::{ + env, io, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use argh::FromArgs; + +/// The name the command was run as. +pub fn prog() -> String { + env::args_os() + .next() + .as_deref() + .and_then(|arg0| Path::new(arg0).file_name()) + .map_or_else( + || "fsfork".into(), + |name| name.to_string_lossy().into_owned(), + ) +} + +/// Says `message` on stderr, after the command's name. +pub fn say(message: impl std::fmt::Display) { + eprintln!("{}: {message}", prog()); +} + +/// What went wrong in `e`: for an error from the system, its description, +/// in lowercase and without its number, as in "no such file or directory". +pub fn describe(e: &io::Error) -> String { + let text = e.to_string(); + let Some(code) = e.raw_os_error() else { + return text; + }; + let text = text + .strip_suffix(&format!(" (os error {code})")) + .unwrap_or(&text); + lowercase_first(text) +} + +/// `e`, saying it is about `path`. +pub fn at(path: &Path, e: io::Error) -> io::Error { + io::Error::new( + e.kind(), + format!("{}: {}", shown(path).display(), describe(&e)), + ) +} + +/// `path` as it would be typed from the working directory: relative to it if +/// it is in it or next to it, as forks are, and otherwise as it is. +pub fn shown(path: &Path) -> PathBuf { + if path.is_relative() { + return path.to_path_buf(); + } + let Ok(cwd) = env::current_dir().and_then(|cwd| cwd.canonicalize()) else { + return path.to_path_buf(); + }; + if let Ok(inside) = path.strip_prefix(&cwd) { + return if inside.as_os_str().is_empty() { + PathBuf::from(".") + } else { + inside.to_path_buf() + }; + } + if let Some(parent) = cwd.parent().filter(|parent| parent.parent().is_some()) + && let Ok(next) = path.strip_prefix(parent) + { + return Path::new("..").join(next); + } + path.to_path_buf() +} + +/// The command's arguments, or, if they are not right, the code to exit +/// with, having said why and how to give them, as in `usage`, its synopsis +/// with a line for each form. +pub fn parse(usage: &str, failure: u8) -> Result { + let args: Vec = env::args().skip(1).collect(); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let prog = prog(); + T::from_args(&[&prog], &args).map_err(|exit| match exit.status { + Ok(()) => { + print!("{}", exit.output); + ExitCode::SUCCESS + } + Err(()) => { + let problem = exit.output.lines().next().unwrap_or_default(); + usage_error(problem.trim_end_matches(['.', ':']), usage, failure) + } + }) +} + +/// Says `problem` with how to give the arguments, as in `usage`, and returns +/// the code to exit with, `failure`. +pub fn usage_error(problem: &str, usage: &str, failure: u8) -> ExitCode { + say(lowercase_first(problem)); + for (i, form) in usage.lines().enumerate() { + let label = if i == 0 { "usage:" } else { " " }; + eprintln!("{label} {form}"); + } + ExitCode::from(failure) +} + +fn lowercase_first(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_lowercase().chain(chars).collect(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn describes_system_errors_in_lowercase_without_their_number() { + let e = io::Error::from_raw_os_error(libc::ENOENT); + assert_eq!(describe(&e), "no such file or directory"); + let e = io::Error::other("Mine as it is"); + assert_eq!(describe(&e), "Mine as it is"); + } + + #[test] + fn shows_paths_from_the_working_directory() { + let cwd = env::current_dir().unwrap().canonicalize().unwrap(); + let parent = cwd.parent().unwrap(); + assert_eq!(shown(Path::new("as/typed")), Path::new("as/typed")); + assert_eq!(shown(&cwd), Path::new(".")); + assert_eq!(shown(&cwd.join("a")), Path::new("a")); + assert_eq!(shown(&parent.join("x.1")), Path::new("../x.1")); + assert_eq!(shown(Path::new("/")), Path::new("/")); + } +}