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 58b2f57..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 } } @@ -70,7 +82,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()); @@ -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 a52ab33..4f7f45c 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) ); @@ -218,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()); @@ -439,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()); } @@ -466,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()); } @@ -673,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("/")); + } +}