| 1 | use std::fs; |
| 2 | use std::path::PathBuf; |
| 3 | |
| 4 | use anyhow::{Context, Result, anyhow, bail}; |
| 5 | use serde::{Deserialize, Serialize}; |
| 6 | |
| 7 | use crate::model::Collection; |
| 8 | |
| 9 | /// Root config directory: `~/.config/cielago` on every platform, matching the |
| 10 | /// documented storage layout (rather than e.g. `~/Library/Application Support` |
| 11 | /// on macOS). |
| 12 | pub fn config_dir() -> Result<PathBuf> { |
| 13 | let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?; |
| 14 | let dir = home.join(".config").join("cielago"); |
| 15 | migrate_legacy_dirs(&home.join(".config"), &dir); |
| 16 | Ok(dir) |
| 17 | } |
| 18 | |
| 19 | /// Names this project shipped under before `cielago`, newest first. |
| 20 | const LEGACY_DIR_NAMES: [&str; 3] = ["manpost", "stableman", "getman"]; |
| 21 | |
| 22 | /// Move a leftover directory from an earlier name onto the current one, so |
| 23 | /// existing collections survive the rename. No-op once `cielago` exists. |
| 24 | fn migrate_legacy_dirs(config_root: &std::path::Path, new: &PathBuf) { |
| 25 | if new.exists() { |
| 26 | return; |
| 27 | } |
| 28 | for legacy in LEGACY_DIR_NAMES { |
| 29 | let old = config_root.join(legacy); |
| 30 | if old.exists() && fs::rename(&old, new).is_ok() { |
| 31 | return; |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | pub fn collections_dir() -> Result<PathBuf> { |
| 37 | Ok(config_dir()?.join("collections")) |
| 38 | } |
| 39 | |
| 40 | /// Filesystem-safe slug for a collection name. |
| 41 | pub fn slugify(name: &str) -> String { |
| 42 | let mut slug = String::new(); |
| 43 | let mut last_dash = false; |
| 44 | for c in name.chars() { |
| 45 | if c.is_ascii_alphanumeric() { |
| 46 | slug.push(c.to_ascii_lowercase()); |
| 47 | last_dash = false; |
| 48 | } else if !last_dash && !slug.is_empty() { |
| 49 | slug.push('-'); |
| 50 | last_dash = true; |
| 51 | } |
| 52 | } |
| 53 | let slug = slug.trim_matches('-').to_string(); |
| 54 | if slug.is_empty() { |
| 55 | "collection".into() |
| 56 | } else { |
| 57 | slug |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | pub fn collection_path(name: &str) -> Result<PathBuf> { |
| 62 | Ok(collections_dir()?.join(format!("{}.json", slugify(name)))) |
| 63 | } |
| 64 | |
| 65 | pub fn save_collection(collection: &Collection) -> Result<PathBuf> { |
| 66 | let dir = collections_dir()?; |
| 67 | fs::create_dir_all(&dir).context("creating collections directory")?; |
| 68 | let path = collection_path(&collection.name)?; |
| 69 | let json = serde_json::to_string_pretty(collection)?; |
| 70 | fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?; |
| 71 | Ok(path) |
| 72 | } |
| 73 | |
| 74 | pub fn load_collection(name: &str) -> Result<Collection> { |
| 75 | let path = collection_path(name)?; |
| 76 | load_collection_path(&path) |
| 77 | } |
| 78 | |
| 79 | pub fn load_collection_path(path: &PathBuf) -> Result<Collection> { |
| 80 | let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; |
| 81 | let collection = serde_json::from_str(&text) |
| 82 | .with_context(|| format!("parsing collection at {}", path.display()))?; |
| 83 | Ok(collection) |
| 84 | } |
| 85 | |
| 86 | /// Resolve a user-typed collection name onto a saved one. Exact matches win; |
| 87 | /// otherwise anything that slugifies the same does, so `cielago delete "some |
| 88 | /// api"` finds `Some API`. |
| 89 | pub fn resolve_collection(name: &str) -> Result<String> { |
| 90 | let names = list_collections()?; |
| 91 | if let Some(found) = match_name(&names, name) { |
| 92 | return Ok(found); |
| 93 | } |
| 94 | if names.is_empty() { |
| 95 | bail!( |
| 96 | "No collections yet. Import one:\n\n cielago import <spec.json|yaml|url>\n\nOr create an empty one:\n\n cielago new <name>" |
| 97 | ) |
| 98 | } |
| 99 | bail!( |
| 100 | "No collection named {name:?}.\n\nAvailable: {}", |
| 101 | names.join(", ") |
| 102 | ) |
| 103 | } |
| 104 | |
| 105 | /// Pick the saved name a user-typed one refers to: exact match first, then any |
| 106 | /// name with the same slug (which is what the file is named after anyway). |
| 107 | fn match_name(names: &[String], input: &str) -> Option<String> { |
| 108 | if names.iter().any(|n| n == input) { |
| 109 | return Some(input.to_string()); |
| 110 | } |
| 111 | let slug = slugify(input); |
| 112 | names.iter().find(|n| slugify(n) == slug).cloned() |
| 113 | } |
| 114 | |
| 115 | /// Delete a saved collection. Returns the file that was removed. |
| 116 | pub fn delete_collection(name: &str) -> Result<PathBuf> { |
| 117 | let path = collection_path(name)?; |
| 118 | fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; |
| 119 | Ok(path) |
| 120 | } |
| 121 | |
| 122 | /// Names of all saved collections (derived from file names). |
| 123 | pub fn list_collections() -> Result<Vec<String>> { |
| 124 | let dir = collections_dir()?; |
| 125 | let mut names = Vec::new(); |
| 126 | if dir.exists() { |
| 127 | for entry in fs::read_dir(&dir)? { |
| 128 | let entry = entry?; |
| 129 | let path = entry.path(); |
| 130 | if path.extension().and_then(|e| e.to_str()) == Some("json") |
| 131 | && let Ok(c) = load_collection_path(&path) |
| 132 | { |
| 133 | names.push(c.name); |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | names.sort(); |
| 138 | Ok(names) |
| 139 | } |
| 140 | |
| 141 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 142 | pub struct AppConfig { |
| 143 | #[serde(default)] |
| 144 | pub last_collection: Option<String>, |
| 145 | #[serde(default)] |
| 146 | pub editor: Option<String>, |
| 147 | } |
| 148 | |
| 149 | impl AppConfig { |
| 150 | fn path() -> Result<PathBuf> { |
| 151 | Ok(config_dir()?.join("config.json")) |
| 152 | } |
| 153 | |
| 154 | pub fn load() -> AppConfig { |
| 155 | Self::path() |
| 156 | .and_then(|p| Ok(fs::read_to_string(p)?)) |
| 157 | .and_then(|t| Ok(serde_json::from_str(&t)?)) |
| 158 | .unwrap_or_default() |
| 159 | } |
| 160 | |
| 161 | pub fn save(&self) -> Result<()> { |
| 162 | let dir = config_dir()?; |
| 163 | fs::create_dir_all(&dir)?; |
| 164 | fs::write(Self::path()?, serde_json::to_string_pretty(self)?)?; |
| 165 | Ok(()) |
| 166 | } |
| 167 | |
| 168 | /// Editor to use for external body editing: config override, `$EDITOR`, else `vi`. |
| 169 | pub fn editor_cmd(&self) -> String { |
| 170 | self.editor |
| 171 | .clone() |
| 172 | .or_else(|| std::env::var("EDITOR").ok()) |
| 173 | .filter(|e| !e.is_empty()) |
| 174 | .unwrap_or_else(|| "vi".into()) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | #[cfg(test)] |
| 179 | mod tests { |
| 180 | use super::*; |
| 181 | use crate::model::Method; |
| 182 | |
| 183 | #[test] |
| 184 | fn slugify_basic() { |
| 185 | assert_eq!(slugify("My Pet API"), "my-pet-api"); |
| 186 | assert_eq!(slugify("pets_v2 (internal)"), "pets-v2-internal"); |
| 187 | assert_eq!(slugify("!!!"), "collection"); |
| 188 | assert_eq!(slugify("a"), "a"); |
| 189 | } |
| 190 | |
| 191 | #[test] |
| 192 | fn match_name_exact_then_slug() { |
| 193 | let names = vec!["Some API".to_string(), "Other API".to_string()]; |
| 194 | assert_eq!(match_name(&names, "Some API").as_deref(), Some("Some API")); |
| 195 | assert_eq!(match_name(&names, "some api").as_deref(), Some("Some API")); |
| 196 | assert_eq!(match_name(&names, "some-api").as_deref(), Some("Some API")); |
| 197 | assert_eq!(match_name(&names, "nope"), None); |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn collection_json_roundtrip() { |
| 202 | let mut c = Collection::new("Test API"); |
| 203 | c.servers = vec![ |
| 204 | "https://a.example.com".into(), |
| 205 | "https://b.example.com".into(), |
| 206 | ]; |
| 207 | c.active_server = 1; |
| 208 | c.variables |
| 209 | .push(crate::model::KeyValueRow::new("tenant", "acme", true)); |
| 210 | c.auth = Some(crate::model::OAuthConfig { |
| 211 | token_url: "https://auth.example.com/token".into(), |
| 212 | client_id: "id".into(), |
| 213 | client_secret: "secret".into(), |
| 214 | scopes: vec!["read".into()], |
| 215 | auth_style: crate::model::AuthStyle::Basic, |
| 216 | }); |
| 217 | let mut r = crate::model::SavedRequest::blank("list pets"); |
| 218 | r.method = Method::Post; |
| 219 | r.query |
| 220 | .push(crate::model::KeyValueRow::new("limit", "10", false)); |
| 221 | r.body = Some("{\"a\":1}".into()); |
| 222 | c.requests.push(r); |
| 223 | |
| 224 | let json = serde_json::to_string_pretty(&c).unwrap(); |
| 225 | let back: Collection = serde_json::from_str(&json).unwrap(); |
| 226 | |
| 227 | assert_eq!(back.name, "Test API"); |
| 228 | assert_eq!(back.base_url(), Some("https://b.example.com")); |
| 229 | assert_eq!(back.variables[0].value, "acme"); |
| 230 | assert_eq!(back.auth.as_ref().unwrap().client_secret, "secret"); |
| 231 | assert_eq!(back.requests.len(), 1); |
| 232 | assert_eq!(back.requests[0].method, Method::Post); |
| 233 | assert!(!back.requests[0].query[0].enabled); |
| 234 | } |
| 235 | } |