| 1 | //! The CLI's settings: the hosts it is signed in to, which account is active |
| 2 | //! on each, and aliases. One JSON file, `config.json`, in |
| 3 | //! |
| 4 | //! - `G1T_CONFIG_DIR`, when set; |
| 5 | //! - `%APPDATA%\g1t` on Windows; |
| 6 | //! - `$XDG_CONFIG_HOME/g1t`, or `~/.config/g1t`, elsewhere. |
| 7 | //! |
| 8 | //! Tokens are not kept here unless the system keychain cannot hold them |
| 9 | //! (see [`crate::auth`]); then the file is written readable by its owner only. |
| 10 | |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | use std::collections::BTreeMap; |
| 13 | use std::path::PathBuf; |
| 14 | |
| 15 | use crate::error::{Error, Result}; |
| 16 | |
| 17 | pub const DEFAULT_HOST: &str = "g1t.sh"; |
| 18 | |
| 19 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] |
| 20 | pub struct Config { |
| 21 | /// The host commands talk to when nothing else names one. |
| 22 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 23 | pub default_host: Option<String>, |
| 24 | #[serde(default)] |
| 25 | pub hosts: BTreeMap<String, HostConfig>, |
| 26 | #[serde(default)] |
| 27 | pub aliases: BTreeMap<String, String>, |
| 28 | } |
| 29 | |
| 30 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] |
| 31 | pub struct HostConfig { |
| 32 | /// The site, like https://g1t.sh: git remotes and pages. |
| 33 | pub site: String, |
| 34 | /// The REST API, like https://api.g1t.sh. |
| 35 | pub api: String, |
| 36 | /// The active account's username. |
| 37 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 38 | pub user: Option<String>, |
| 39 | #[serde(default)] |
| 40 | pub users: BTreeMap<String, UserConfig>, |
| 41 | } |
| 42 | |
| 43 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] |
| 44 | pub struct UserConfig { |
| 45 | /// Set only when the token could not go in the system keychain. |
| 46 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 47 | pub token: Option<String>, |
| 48 | } |
| 49 | |
| 50 | pub fn dir() -> Result<PathBuf> { |
| 51 | if let Some(dir) = std::env::var_os("G1T_CONFIG_DIR").filter(|d| !d.is_empty()) { |
| 52 | return Ok(PathBuf::from(dir)); |
| 53 | } |
| 54 | if cfg!(windows) |
| 55 | && let Some(appdata) = std::env::var_os("APPDATA") |
| 56 | { |
| 57 | return Ok(PathBuf::from(appdata).join("g1t")); |
| 58 | } |
| 59 | if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) { |
| 60 | return Ok(PathBuf::from(xdg).join("g1t")); |
| 61 | } |
| 62 | let home = std::env::var_os("HOME") |
| 63 | .or_else(|| std::env::var_os("USERPROFILE")) |
| 64 | .ok_or_else(|| { |
| 65 | Error::failed("No home directory to keep settings in. Set G1T_CONFIG_DIR.") |
| 66 | })?; |
| 67 | Ok(PathBuf::from(home).join(".config").join("g1t")) |
| 68 | } |
| 69 | |
| 70 | pub fn path() -> Result<PathBuf> { |
| 71 | Ok(dir()?.join("config.json")) |
| 72 | } |
| 73 | |
| 74 | impl Config { |
| 75 | pub fn load() -> Result<Config> { |
| 76 | let path = path()?; |
| 77 | match std::fs::read_to_string(&path) { |
| 78 | Ok(text) if text.trim().is_empty() => Ok(Config::default()), |
| 79 | Ok(text) => serde_json::from_str(&text) |
| 80 | .map_err(|e| Error::failed(format!("{} is not valid: {e}", path.display()))), |
| 81 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()), |
| 82 | Err(e) => Err(Error::failed(format!( |
| 83 | "Could not read {}: {e}", |
| 84 | path.display() |
| 85 | ))), |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | pub fn save(&self) -> Result<()> { |
| 90 | let path = path()?; |
| 91 | if let Some(parent) = path.parent() { |
| 92 | std::fs::create_dir_all(parent) |
| 93 | .map_err(|e| Error::failed(format!("Could not make {}: {e}", parent.display())))?; |
| 94 | } |
| 95 | let text = serde_json::to_string_pretty(self).expect("config serializes") + "\n"; |
| 96 | let tmp = path.with_extension("json.tmp"); |
| 97 | write_private(&tmp, text.as_bytes()) |
| 98 | .map_err(|e| Error::failed(format!("Could not write {}: {e}", tmp.display())))?; |
| 99 | std::fs::rename(&tmp, &path) |
| 100 | .map_err(|e| Error::failed(format!("Could not write {}: {e}", path.display())))?; |
| 101 | Ok(()) |
| 102 | } |
| 103 | |
| 104 | pub fn host(&self, host: &str) -> Option<&HostConfig> { |
| 105 | self.hosts.get(host) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// Writes a file only its owner can read: it may hold a token. |
| 110 | fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { |
| 111 | #[cfg(unix)] |
| 112 | { |
| 113 | use std::io::Write; |
| 114 | use std::os::unix::fs::OpenOptionsExt; |
| 115 | let mut file = std::fs::OpenOptions::new() |
| 116 | .write(true) |
| 117 | .create(true) |
| 118 | .truncate(true) |
| 119 | .mode(0o600) |
| 120 | .open(path)?; |
| 121 | file.write_all(bytes) |
| 122 | } |
| 123 | #[cfg(not(unix))] |
| 124 | { |
| 125 | std::fs::write(path, bytes) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | #[cfg(test)] |
| 130 | mod tests { |
| 131 | use super::*; |
| 132 | |
| 133 | #[test] |
| 134 | fn an_empty_config_round_trips() { |
| 135 | let config: Config = serde_json::from_str("{}").unwrap(); |
| 136 | assert_eq!(config, Config::default()); |
| 137 | let mut config = Config::default(); |
| 138 | config.hosts.insert( |
| 139 | "g1t.sh".into(), |
| 140 | HostConfig { |
| 141 | site: "https://g1t.sh".into(), |
| 142 | api: "https://api.g1t.sh".into(), |
| 143 | user: Some("ana".into()), |
| 144 | users: [("ana".to_owned(), UserConfig::default())].into(), |
| 145 | }, |
| 146 | ); |
| 147 | config.aliases.insert("co".into(), "pr checkout".into()); |
| 148 | let text = serde_json::to_string(&config).unwrap(); |
| 149 | assert!( |
| 150 | !text.contains("token"), |
| 151 | "no token field when none is kept: {text}" |
| 152 | ); |
| 153 | assert_eq!(serde_json::from_str::<Config>(&text).unwrap(), config); |
| 154 | } |
| 155 | } |