use std::{ collections::{BTreeMap, BTreeSet}, env, fs, io::Write, path::PathBuf, process::{self, Command}, time::{SystemTime, UNIX_EPOCH}, }; #[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use serde::{Deserialize, Serialize}; use crate::{Client, Provider, RepositoryId, Url}; type Result = std::result::Result; #[derive(Clone, Default, Deserialize, Serialize)] pub struct ServerProfile { pub url: String, pub token: String, #[serde(default)] pub provider: Provider, } #[derive(Clone, Deserialize, Serialize)] pub struct TuiPreferences { #[serde(default = "default_refresh_seconds")] pub refresh_seconds: u64, #[serde(default)] pub favorites: BTreeSet, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_server: Option, } impl Default for TuiPreferences { fn default() -> Self { Self { refresh_seconds: default_refresh_seconds(), favorites: BTreeSet::new(), last_server: None, } } } const fn default_refresh_seconds() -> u64 { 5 } #[derive(Clone, Default, Deserialize, Serialize)] pub struct Config { #[serde(skip)] path: PathBuf, #[serde(default)] pub servers: BTreeMap, #[serde(default)] pub tui: TuiPreferences, } pub struct Selection { pub name: Option, pub url: String, pub token: Option, pub provider: Provider, pub repository: Option, } impl Config { pub fn load() -> Result { let home = env::var_os("HOME").ok_or("HOME is not set")?; Self::load_from(PathBuf::from(home).join(".config/gotcha/config")) } fn load_from(path: PathBuf) -> Result { if !path.exists() { return Ok(Self { path, ..Self::default() }); } let text = fs::read_to_string(&path) .map_err(|error| format!("cannot read {}: {error}", path.display()))?; let mut config: Self = serde_yaml::from_str(&text) .map_err(|error| format!("invalid {}: {error}", path.display()))?; config.path = path; for (name, server) in &config.servers { validate_name(name)?; Client::with_provider(&server.url, Some(&server.token), server.provider) .map_err(|error| format!("invalid server {name}: {error}"))?; } Ok(config) } pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> { let url = server_url(name)?; self.save_server(name, &url, token, provider) } pub fn save_server( &mut self, name: &str, url: &str, token: &str, provider: Provider, ) -> Result<()> { validate_name(name)?; Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?; let mut updated = self.clone(); updated.servers.insert( name.into(), ServerProfile { url: url.trim_end_matches('/').into(), token: token.into(), provider, }, ); updated.save()?; *self = updated; Ok(()) } pub fn replace_server( &mut self, original_name: &str, name: &str, url: &str, token: &str, provider: Provider, ) -> Result<()> { if !self.servers.contains_key(original_name) { return Err(format!("server profile {original_name:?} does not exist")); } validate_name(name)?; Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?; let mut updated = self.clone(); let old_url = updated .servers .get(original_name) .map(|server| server.url.clone()) .expect("profile existence checked above"); let new_url = url.trim_end_matches('/'); updated.servers.remove(original_name); updated.servers.insert( name.into(), ServerProfile { url: new_url.into(), token: token.into(), provider, }, ); if old_url != new_url { migrate_favorites(&mut updated.tui.favorites, &old_url, new_url); } if updated.tui.last_server.as_deref() == Some(original_name) { updated.tui.last_server = Some(name.into()); } updated.save()?; *self = updated; Ok(()) } pub fn logout(&mut self, name: &str) -> Result<()> { if !self.servers.contains_key(name) { return Err(format!("server profile {name:?} does not exist")); } let mut updated = self.clone(); let removed_url = updated.servers[name].url.clone(); updated.servers.remove(name); if !updated .servers .values() .any(|server| server.url == removed_url) { remove_favorites(&mut updated.tui.favorites, &removed_url); } if updated.tui.last_server.as_deref() == Some(name) { updated.tui.last_server = updated.servers.keys().next().cloned(); } updated.save()?; *self = updated; Ok(()) } pub fn set_tui_refresh_seconds(&mut self, seconds: u64) -> Result<()> { let mut updated = self.clone(); updated.tui.refresh_seconds = seconds; updated.save()?; *self = updated; Ok(()) } pub fn set_tui_last_server(&mut self, name: Option<&str>) -> Result<()> { if let Some(name) = name && !self.servers.contains_key(name) { return Err(format!("server profile {name:?} does not exist")); } if self.tui.last_server.as_deref() == name { return Ok(()); } let mut updated = self.clone(); updated.tui.last_server = name.map(str::to_owned); updated.save()?; *self = updated; Ok(()) } pub fn is_tui_favorite(&self, pane: &str, server_url: &str, repository: &RepositoryId) -> bool { self.tui .favorites .contains(&favorite_key(pane, server_url, repository)) } pub fn toggle_tui_favorite( &mut self, pane: &str, server_url: &str, repository: &RepositoryId, ) -> Result<()> { let key = favorite_key(pane, server_url, repository); let mut updated = self.clone(); if !updated.tui.favorites.remove(&key) { updated.tui.favorites.insert(key); } updated.save()?; *self = updated; Ok(()) } pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result { if name.is_some() && url.is_some() { return Err("use either --server or --url, not both".into()); } let remotes = git_remotes().unwrap_or_default(); if let Some(name) = name { let server = self .servers .get(name) .ok_or_else(|| format!("server profile {name:?} does not exist"))?; return Ok(selection(Some(name), server, &remotes)); } if let Some(url) = url { let matches: Vec<_> = self .servers .iter() .filter(|(_, server)| same_instance(&server.url, url)) .collect(); return match matches.as_slice() { [] => Ok(selection( None, &ServerProfile { url: url.into(), token: String::new(), provider: Provider::Gitea, }, &remotes, )), [(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)), _ => Err("multiple profiles use that URL; select one with --server".into()), }; } let mut matches = BTreeMap::new(); for (name, server) in &self.servers { if let Some(scope) = remotes .iter() .find_map(|remote| repository_scope(&server.url, remote)) { matches.insert(name, (server, scope)); } } match matches.into_iter().collect::>().as_slice() { [] if self.servers.is_empty() => { Err("no servers configured; run `gotcha auth login SERVER`".into()) } [] => Err("no configured server matches this Git repository; use --server NAME".into()), [(name, (server, scope))] => Ok(Selection { name: Some((*name).clone()), url: server.url.clone(), token: Some(server.token.clone()), provider: server.provider, repository: Some(scope.clone()), }), _ => Err("multiple server profiles match this Git repository; use --server".into()), } } fn save(&self) -> Result<()> { let parent = self .path .parent() .ok_or_else(|| format!("invalid config path: {}", self.path.display()))?; fs::create_dir_all(parent) .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|error| error.to_string())? .as_nanos(); let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id())); let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?; let result = (|| -> std::io::Result<()> { let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(0o600); let mut file = options.open(&temporary)?; file.write_all(text.as_bytes())?; file.sync_all()?; fs::rename(&temporary, &self.path)?; #[cfg(unix)] fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?; Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result.map_err(|error| format!("cannot write {}: {error}", self.path.display())) } } fn favorite_key(pane: &str, server_url: &str, repository: &RepositoryId) -> String { format!( "{pane}|{}|{}/{}", server_url.trim_end_matches('/'), repository.owner, repository.repository ) } fn migrate_favorites(favorites: &mut BTreeSet, old_url: &str, new_url: &str) { let old = format!("|{}|", old_url.trim_end_matches('/')); let new = format!("|{}|", new_url.trim_end_matches('/')); let migrations: Vec<_> = favorites .iter() .filter(|key| key.contains(&old)) .map(|key| (key.clone(), key.replacen(&old, &new, 1))) .collect(); for (old, new) in migrations { favorites.remove(&old); favorites.insert(new); } } fn remove_favorites(favorites: &mut BTreeSet, server_url: &str) { let server = format!("|{}|", server_url.trim_end_matches('/')); favorites.retain(|key| !key.contains(&server)); } fn selection(name: Option<&str>, server: &ServerProfile, remotes: &[String]) -> Selection { Selection { name: name.map(str::to_owned), url: server.url.clone(), token: (!server.token.is_empty()).then(|| server.token.clone()), provider: server.provider, repository: remotes .iter() .find_map(|remote| repository_scope(&server.url, remote)), } } fn validate_name(name: &str) -> Result<()> { server_url(name)?; Ok(()) } pub fn server_url(name: &str) -> Result { if name.is_empty() || name.contains('/') || name.contains('@') || name.chars().any(char::is_whitespace) { return Err("server name must be a hostname, optionally followed by a port".into()); } let url = format!("https://{name}"); let parsed = Url::parse(&url).map_err(|_| "invalid server name")?; if parsed.host_str().is_none() || parsed.path() != "/" { return Err("server name must be a hostname, optionally followed by a port".into()); } if parsed.query().is_some() || parsed.fragment().is_some() { return Err("server name must be a hostname, optionally followed by a port".into()); } Ok(url) } fn same_instance(left: &str, right: &str) -> bool { let api_url = |url| { Client::new(url, None) .ok() .map(|client| client.api_url().clone()) }; api_url(left) == api_url(right) } fn git_remotes() -> Result> { let output = Command::new("git") .args(["config", "--get-regexp", r"^remote\..*\.url$"]) .output() .map_err(|error| format!("cannot inspect Git remotes: {error}"))?; if !output.status.success() { return Ok(Vec::new()); } Ok(String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|line| line.split_once(char::is_whitespace)) .map(|(_, url)| url.trim().to_owned()) .collect()) } fn repository_scope(server_url: &str, remote: &str) -> Option { let server = Url::parse(server_url).ok()?; let server_host = server.host_str()?; let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) { ( url.host_str()?.to_owned(), url.path().trim_matches('/').to_owned(), matches!(url.scheme(), "http" | "https"), ) } else { let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest); let (host, path) = remote.split_once(':')?; (host.into(), path.trim_matches('/').into(), false) }; if !server_host.eq_ignore_ascii_case(&remote_host) { return None; } let prefix = server.path().trim_matches('/'); if is_http && !prefix.is_empty() { remote_path = remote_path .strip_prefix(prefix)? .strip_prefix('/')? .to_owned(); } let parts: Vec<_> = remote_path .split('/') .filter(|part| !part.is_empty()) .collect(); let [.., owner, repository] = parts.as_slice() else { return None; }; let repository = repository.strip_suffix(".git").unwrap_or(repository); (!owner.is_empty() && !repository.is_empty()).then(|| RepositoryId { owner: (*owner).into(), repository: repository.into(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn config_round_trip_and_remote_scope() { let directory = env::temp_dir().join(format!( "gotcha-config-test-{}-{}", process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let path = directory.join("config"); let mut config = Config { path: path.clone(), ..Config::default() }; assert_eq!(config.tui.refresh_seconds, 5); assert_eq!( config.select(None, None).err().unwrap(), "no servers configured; run `gotcha auth login SERVER`" ); config .login("code.example", "secret", Provider::Forgejo) .unwrap(); config.set_tui_refresh_seconds(9).unwrap(); config.set_tui_last_server(Some("code.example")).unwrap(); let mut loaded = Config::load_from(path).unwrap(); assert_eq!(loaded.tui.refresh_seconds, 9); assert_eq!(loaded.tui.last_server.as_deref(), Some("code.example")); assert_eq!(loaded.servers["code.example"].token, "secret"); assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo); assert_eq!( loaded.select(Some("code.example"), None).unwrap().provider, Provider::Forgejo ); let repository = RepositoryId { owner: "alice".into(), repository: "project".into(), }; loaded .toggle_tui_favorite("issues", "https://code.example", &repository) .unwrap(); assert!(loaded.is_tui_favorite("issues", "https://code.example", &repository)); loaded .replace_server( "code.example", "new.example", "https://new.example", "new-secret", Provider::Gitea, ) .unwrap(); assert!(loaded.is_tui_favorite("issues", "https://new.example", &repository)); assert!(!loaded.servers.contains_key("code.example")); assert_eq!(loaded.servers["new.example"].token, "new-secret"); assert_eq!(loaded.tui.last_server.as_deref(), Some("new.example")); assert!( fs::read_to_string(&loaded.path) .unwrap() .contains("provider: gitea") ); let legacy: ServerProfile = serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap(); assert_eq!(legacy.provider, Provider::Gitea); let scope = repository_scope( "https://code.example/gitea", "https://code.example/gitea/alice/project.git", ) .unwrap(); assert_eq!( (scope.owner.as_str(), scope.repository.as_str()), ("alice", "project") ); #[cfg(unix)] assert_eq!( fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777, 0o600 ); loaded.logout("new.example").unwrap(); assert_eq!(loaded.tui.last_server, None); fs::remove_dir_all(directory).unwrap(); } }