diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 238c5eb..01cfb33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod details; mod diff; mod filters; mod git; +mod location; mod settings; use std::path::PathBuf; diff --git a/src-tauri/src/location.rs b/src-tauri/src/location.rs new file mode 100644 index 0000000..46a629d --- /dev/null +++ b/src-tauri/src/location.rs @@ -0,0 +1,133 @@ +//! Where a repository lives: a folder on this machine, or a folder on another machine +//! reached over ssh. + +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Location { + Local(PathBuf), + /// `target` is what is given to ssh, like `host` or `user@host`. + Ssh { target: String, path: String }, +} + +/// Reads `[user@]host:path` as a remote location. Everything else is local: text where the +/// part before the first `:` is a single character (a Windows drive like `C:`) or contains +/// `/` or `\`, and text that is the whole name of a folder that exists inside `cwd`. +pub fn parse(text: &str, cwd: &Path) -> Location { + let local = || Location::Local(PathBuf::from(text)); + let Some((target, path)) = text.split_once(':') else { return local() }; + if target.chars().count() <= 1 || target.contains('/') || target.contains('\\') || cwd.join(text).exists() { + return local(); + } + Location::Ssh { target: target.to_string(), path: path.to_string() } +} + +impl Location { + /// The text the user would type to open this location. + pub fn name(&self) -> String { + match self { + Location::Local(path) => path.to_string_lossy().into_owned(), + Location::Ssh { target, path } => format!("{target}:{path}"), + } + } + + /// The same machine with another folder. + pub fn with_path(&self, path: &str) -> Location { + match self { + Location::Local(_) => Location::Local(PathBuf::from(path)), + Location::Ssh { target, .. } => Location::Ssh { target: target.clone(), path: path.to_string() }, + } + } +} + +/// Wraps one word in single quotes so a shell reads it back unchanged. +pub fn quote(word: &str) -> String { + format!("'{}'", word.replace('\'', r"'\''")) +} + +/// The command line to run on the remote machine: `git -C ` followed by `args`. +/// A path starting with `~/` keeps the `~` outside the quotes so the shell still +/// replaces it with the home folder. +pub fn remote_command(path: &str, args: &[&str]) -> String { + let path = match path.strip_prefix("~/") { + Some(rest) => format!("~/{}", quote(rest)), + None => quote(path), + }; + let mut command = format!("git -C {path}"); + for arg in args { + command.push(' '); + command.push_str("e(arg)); + } + command +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn cwd() -> PathBuf { + std::env::temp_dir() + } + + #[test] + fn reads_remote_locations() { + assert_eq!( + parse("myserver:/srv/app", &cwd()), + Location::Ssh { target: "myserver".into(), path: "/srv/app".into() } + ); + assert_eq!( + parse("ada@myserver:~/x", &cwd()), + Location::Ssh { target: "ada@myserver".into(), path: "~/x".into() } + ); + } + + #[test] + fn keeps_local_paths_local() { + for text in [r"C:\code\x", "C:/code/x", "./a:b", "relative/dir", "/abs/dir"] { + assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)), "{text}"); + } + } + + #[test] + fn keeps_an_existing_local_folder_with_a_colon_local() { + let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id())); + let folder = temp.join("notes:2024"); + std::fs::create_dir_all(&folder).unwrap(); + assert_eq!(parse("notes:2024", &temp), Location::Local(PathBuf::from("notes:2024"))); + std::fs::remove_dir_all(&folder).unwrap(); + assert_eq!(parse("notes:2024", &temp), Location::Ssh { target: "notes".into(), path: "2024".into() }); + std::fs::remove_dir_all(&temp).unwrap(); + } + + #[test] + fn names_locations() { + let remote = Location::Ssh { target: "myserver".into(), path: "/srv/app".into() }; + assert_eq!(remote.name(), "myserver:/srv/app"); + assert_eq!(Location::Local(PathBuf::from("/abs/dir")).name(), "/abs/dir"); + } + + #[test] + fn changes_only_the_path() { + let remote = Location::Ssh { target: "ada@myserver".into(), path: "/srv/app".into() }; + assert_eq!(remote.with_path("/srv/other"), Location::Ssh { target: "ada@myserver".into(), path: "/srv/other".into() }); + assert_eq!(Location::Local(PathBuf::from("/a")).with_path("/b"), Location::Local(PathBuf::from("/b"))); + } + + #[test] + fn quoted_words_survive_a_shell() { + for word in ["plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "line\nbreak"] { + let out = Command::new("sh").arg("-c").arg(format!("printf %s {}", quote(word))).output().unwrap(); + assert_eq!(String::from_utf8(out.stdout).unwrap(), word); + } + } + + #[test] + fn builds_the_remote_command() { + assert_eq!( + remote_command("/srv/my app", &["log", "--format=%H"]), + "git -C '/srv/my app' 'log' '--format=%H'" + ); + assert_eq!(remote_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); + } +}