| 1 | use std::collections::BTreeMap; |
| 2 | use std::fs; |
| 3 | use std::io::{self, Write}; |
| 4 | use std::process::Command as ProcessCommand; |
| 5 | |
| 6 | use anyhow::{Context, Result, bail}; |
| 7 | use clap::{Parser, Subcommand}; |
| 8 | |
| 9 | use cielago::app; |
| 10 | use cielago::model::Collection; |
| 11 | use cielago::openapi; |
| 12 | use cielago::store::{self, AppConfig}; |
| 13 | |
| 14 | const BANNER: &str = r#" |
| 15 | |
| 16 | c i e l a g o |
| 17 | |
| 18 | .-'"/'. |
| 19 | _-" ( '-_ |
| 20 | _.-' ) "-._ |
| 21 | .-' ` |
| 22 | ____________ _____ __ |
| 23 | "#; |
| 24 | |
| 25 | #[derive(Parser)] |
| 26 | #[command( |
| 27 | name = "cielago", |
| 28 | version, |
| 29 | about = "Like Postman but it actually works", |
| 30 | before_help = BANNER |
| 31 | )] |
| 32 | struct Cli { |
| 33 | #[command(subcommand)] |
| 34 | command: Option<Command>, |
| 35 | } |
| 36 | |
| 37 | #[derive(Subcommand)] |
| 38 | enum Command { |
| 39 | /// Import an OpenAPI 3.x spec (JSON/YAML, file path or URL) as a collection |
| 40 | Import { |
| 41 | /// File path or http(s) URL of the spec |
| 42 | source: String, |
| 43 | /// Collection name (defaults to the spec's info.title) |
| 44 | #[arg(long)] |
| 45 | name: Option<String>, |
| 46 | }, |
| 47 | /// Create an empty collection and open it in the TUI |
| 48 | New { |
| 49 | /// Collection name |
| 50 | name: String, |
| 51 | /// Base URL to start with (becomes the active server) |
| 52 | #[arg(long, short)] |
| 53 | server: Option<String>, |
| 54 | }, |
| 55 | /// List saved collections |
| 56 | List { |
| 57 | /// Show servers, request counts and file paths |
| 58 | #[arg(short, long)] |
| 59 | long: bool, |
| 60 | }, |
| 61 | /// Open a collection in the TUI (defaults to the last opened one) |
| 62 | Open { name: Option<String> }, |
| 63 | /// Delete a saved collection |
| 64 | Delete { |
| 65 | name: String, |
| 66 | /// Skip the confirmation prompt |
| 67 | #[arg(short, long)] |
| 68 | force: bool, |
| 69 | }, |
| 70 | /// Edit a collection's JSON in $EDITOR |
| 71 | Edit { name: String }, |
| 72 | /// Rename a collection (renames its file too) |
| 73 | Rename { name: String, new_name: String }, |
| 74 | /// Replace a collection's requests from a spec, keeping auth/vars/servers |
| 75 | Update { |
| 76 | /// Collection to update |
| 77 | name: String, |
| 78 | /// File path or http(s) URL of the spec to pull routes from |
| 79 | source: String, |
| 80 | }, |
| 81 | /// Show details about a collection |
| 82 | Info { name: String }, |
| 83 | /// Print the path of a collection's JSON file |
| 84 | Path { name: String }, |
| 85 | } |
| 86 | |
| 87 | #[tokio::main] |
| 88 | async fn main() -> Result<()> { |
| 89 | let cli = Cli::parse(); |
| 90 | match cli.command { |
| 91 | Some(Command::Import { source, name }) => cmd_import(&source, name).await, |
| 92 | Some(Command::New { name, server }) => cmd_new(&name, server).await, |
| 93 | Some(Command::List { long }) => cmd_list(long), |
| 94 | Some(Command::Open { name }) => cmd_open(name).await, |
| 95 | Some(Command::Delete { name, force }) => cmd_delete(&name, force), |
| 96 | Some(Command::Edit { name }) => cmd_edit(&name), |
| 97 | Some(Command::Rename { name, new_name }) => cmd_rename(&name, &new_name), |
| 98 | Some(Command::Update { name, source }) => cmd_update(&name, &source).await, |
| 99 | Some(Command::Info { name }) => cmd_info(&name), |
| 100 | Some(Command::Path { name }) => cmd_path(&name), |
| 101 | None => cmd_open(None).await, |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | async fn cmd_import(source: &str, name: Option<String>) -> Result<()> { |
| 106 | let doc = openapi::load_spec(source).await?; |
| 107 | let name = name |
| 108 | .or_else(|| { |
| 109 | doc.pointer("/info/title") |
| 110 | .and_then(|t| t.as_str()) |
| 111 | .map(String::from) |
| 112 | }) |
| 113 | .unwrap_or_else(|| "imported".to_string()); |
| 114 | |
| 115 | let collection = openapi::import_spec(&doc, &name, Some(source.to_string())); |
| 116 | let path = store::save_collection(&collection)?; |
| 117 | |
| 118 | println!( |
| 119 | "Imported collection \"{}\" -> {}", |
| 120 | collection.name, |
| 121 | path.display() |
| 122 | ); |
| 123 | println!(" {} requests", collection.requests.len()); |
| 124 | if !collection.servers.is_empty() { |
| 125 | println!(" servers: {}", collection.servers.join(", ")); |
| 126 | } |
| 127 | if let Some(auth) = &collection.auth { |
| 128 | println!( |
| 129 | " oauth2 client-credentials: {} (set client id/secret with A in the TUI)", |
| 130 | auth.token_url |
| 131 | ); |
| 132 | } |
| 133 | Ok(()) |
| 134 | } |
| 135 | |
| 136 | /// Create an empty collection and drop straight into the TUI to fill it in. |
| 137 | /// The existence check is on the slug path rather than via |
| 138 | /// `store::resolve_collection`, which bails by contract on a name that doesn't |
| 139 | /// exist yet — and the path check also catches names that collide after |
| 140 | /// slugify, same as `cielago rename`. |
| 141 | async fn cmd_new(name: &str, server: Option<String>) -> Result<()> { |
| 142 | let path = store::collection_path(name)?; |
| 143 | if path.exists() { |
| 144 | bail!( |
| 145 | "a collection already exists at {} — open it with `cielago open {name:?}` or pick another name", |
| 146 | path.display() |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | let mut collection = Collection::new(name); |
| 151 | if let Some(url) = server { |
| 152 | // Trailing slash trimmed to match imported servers, so pasting a URL in |
| 153 | // the TUI later recognises this one instead of adding a duplicate. |
| 154 | let url = url.trim().trim_end_matches('/').to_string(); |
| 155 | if !url.is_empty() { |
| 156 | collection.servers.push(url); |
| 157 | } |
| 158 | } |
| 159 | let path = store::save_collection(&collection)?; |
| 160 | println!( |
| 161 | "Created collection \"{}\" -> {}", |
| 162 | collection.name, |
| 163 | path.display() |
| 164 | ); |
| 165 | |
| 166 | let mut config = AppConfig::load(); |
| 167 | config.last_collection = Some(collection.name.clone()); |
| 168 | let _ = config.save(); |
| 169 | app::run(collection, path, config).await |
| 170 | } |
| 171 | |
| 172 | fn cmd_list(long: bool) -> Result<()> { |
| 173 | let names = store::list_collections()?; |
| 174 | if names.is_empty() { |
| 175 | println!( |
| 176 | "No collections yet. Import one: cielago import <spec>\n\ |
| 177 | …or start from scratch: cielago new <name>" |
| 178 | ); |
| 179 | return Ok(()); |
| 180 | } |
| 181 | let last = AppConfig::load().last_collection; |
| 182 | for n in names { |
| 183 | if !long { |
| 184 | println!("{n}"); |
| 185 | continue; |
| 186 | } |
| 187 | let marker = if last.as_deref() == Some(n.as_str()) { |
| 188 | "*" |
| 189 | } else { |
| 190 | " " |
| 191 | }; |
| 192 | let path = store::collection_path(&n)?; |
| 193 | match store::load_collection(&n) { |
| 194 | Ok(c) => println!( |
| 195 | "{marker} {n}\n {} requests, {} server(s){}\n {}", |
| 196 | c.requests.len(), |
| 197 | c.servers.len(), |
| 198 | if c.auth.is_some() { ", oauth2" } else { "" }, |
| 199 | path.display() |
| 200 | ), |
| 201 | Err(e) => println!("{marker} {n}\n unreadable: {e}\n {}", path.display()), |
| 202 | } |
| 203 | } |
| 204 | Ok(()) |
| 205 | } |
| 206 | |
| 207 | fn cmd_delete(name: &str, force: bool) -> Result<()> { |
| 208 | let name = store::resolve_collection(name)?; |
| 209 | let collection = store::load_collection(&name).ok(); |
| 210 | let path = store::collection_path(&name)?; |
| 211 | |
| 212 | if !force { |
| 213 | let count = collection |
| 214 | .as_ref() |
| 215 | .map(|c| format!(" ({} requests)", c.requests.len())) |
| 216 | .unwrap_or_default(); |
| 217 | print!("Delete collection \"{name}\"{count}? [y/N] "); |
| 218 | io::stdout().flush()?; |
| 219 | let mut answer = String::new(); |
| 220 | io::stdin().read_line(&mut answer)?; |
| 221 | if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { |
| 222 | println!("Aborted."); |
| 223 | return Ok(()); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | store::delete_collection(&name)?; |
| 228 | let mut config = AppConfig::load(); |
| 229 | if config.last_collection.as_deref() == Some(name.as_str()) { |
| 230 | config.last_collection = None; |
| 231 | let _ = config.save(); |
| 232 | } |
| 233 | println!("Deleted \"{name}\" ({})", path.display()); |
| 234 | Ok(()) |
| 235 | } |
| 236 | |
| 237 | /// Edit the collection JSON in `$EDITOR`. The edit happens on a temp copy so a |
| 238 | /// file that no longer parses never replaces the saved one; a `name` changed in |
| 239 | /// the editor moves the file, same as `cielago rename`. |
| 240 | fn cmd_edit(name: &str) -> Result<()> { |
| 241 | let name = store::resolve_collection(name)?; |
| 242 | let path = store::collection_path(&name)?; |
| 243 | let original = |
| 244 | fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; |
| 245 | |
| 246 | let mut tmp = std::env::temp_dir(); |
| 247 | tmp.push(format!( |
| 248 | "cielago-{}-{}.json", |
| 249 | store::slugify(&name), |
| 250 | std::process::id() |
| 251 | )); |
| 252 | fs::write(&tmp, &original)?; |
| 253 | |
| 254 | let editor = AppConfig::load().editor_cmd(); |
| 255 | let mut parts = editor.split_whitespace(); |
| 256 | let program = parts.next().unwrap_or("vi"); |
| 257 | let status = ProcessCommand::new(program) |
| 258 | .args(parts) |
| 259 | .arg(&tmp) |
| 260 | .status() |
| 261 | .with_context(|| format!("launching editor {editor:?}"))?; |
| 262 | if !status.success() { |
| 263 | let _ = fs::remove_file(&tmp); |
| 264 | bail!("editor exited with {status}; collection left unchanged"); |
| 265 | } |
| 266 | |
| 267 | let edited = fs::read_to_string(&tmp)?; |
| 268 | if edited == original { |
| 269 | let _ = fs::remove_file(&tmp); |
| 270 | println!("No changes."); |
| 271 | return Ok(()); |
| 272 | } |
| 273 | |
| 274 | let collection: Collection = match serde_json::from_str(&edited) { |
| 275 | Ok(c) => c, |
| 276 | Err(e) => bail!( |
| 277 | "edited JSON is not a valid collection: {e}\n\nYour edits are kept at {}; the saved collection is unchanged.", |
| 278 | tmp.display() |
| 279 | ), |
| 280 | }; |
| 281 | let new_path = store::collection_path(&collection.name)?; |
| 282 | if new_path != path && new_path.exists() { |
| 283 | bail!( |
| 284 | "renaming to {:?} would overwrite the collection at {}.\n\nYour edits are kept at {}; the saved collection is unchanged.", |
| 285 | collection.name, |
| 286 | new_path.display(), |
| 287 | tmp.display() |
| 288 | ); |
| 289 | } |
| 290 | let _ = fs::remove_file(&tmp); |
| 291 | |
| 292 | store::save_collection(&collection)?; |
| 293 | if new_path != path { |
| 294 | fs::remove_file(&path).ok(); |
| 295 | update_last_collection(&name, &collection.name); |
| 296 | println!( |
| 297 | "Saved \"{}\" -> {} (was \"{name}\")", |
| 298 | collection.name, |
| 299 | new_path.display() |
| 300 | ); |
| 301 | } else { |
| 302 | println!("Saved \"{}\" -> {}", collection.name, new_path.display()); |
| 303 | } |
| 304 | Ok(()) |
| 305 | } |
| 306 | |
| 307 | fn cmd_rename(name: &str, new_name: &str) -> Result<()> { |
| 308 | let name = store::resolve_collection(name)?; |
| 309 | let mut collection = store::load_collection(&name)?; |
| 310 | let old_path = store::collection_path(&name)?; |
| 311 | let new_path = store::collection_path(new_name)?; |
| 312 | |
| 313 | if new_path != old_path && new_path.exists() { |
| 314 | bail!( |
| 315 | "a collection already exists at {} — pick another name", |
| 316 | new_path.display() |
| 317 | ); |
| 318 | } |
| 319 | |
| 320 | collection.name = new_name.to_string(); |
| 321 | store::save_collection(&collection)?; |
| 322 | if new_path != old_path { |
| 323 | fs::remove_file(&old_path).ok(); |
| 324 | } |
| 325 | update_last_collection(&name, new_name); |
| 326 | println!( |
| 327 | "Renamed \"{name}\" -> \"{new_name}\" ({})", |
| 328 | new_path.display() |
| 329 | ); |
| 330 | Ok(()) |
| 331 | } |
| 332 | |
| 333 | /// Refresh a collection's routes from a spec without touching the rest of it. |
| 334 | /// Only `requests` is replaced (existing routes are overwritten); auth, |
| 335 | /// variables, servers, active server and view state stay as the user left |
| 336 | /// them. `last_request` is cleared because re-import mints new request ids, so |
| 337 | /// the old pointer would dangle. |
| 338 | async fn cmd_update(name: &str, source: &str) -> Result<()> { |
| 339 | let name = store::resolve_collection(name)?; |
| 340 | let mut collection = store::load_collection(&name)?; |
| 341 | |
| 342 | let doc = openapi::load_spec(source).await?; |
| 343 | // Import under the collection's own name so the throwaway result matches; |
| 344 | // only its `requests` are pulled across. |
| 345 | let imported = openapi::import_spec(&doc, &collection.name, Some(source.to_string())); |
| 346 | |
| 347 | let before = collection.requests.len(); |
| 348 | let after = imported.requests.len(); |
| 349 | collection.replace_requests_from(imported); |
| 350 | collection.spec_source = Some(source.to_string()); |
| 351 | |
| 352 | let path = store::save_collection(&collection)?; |
| 353 | println!( |
| 354 | "Updated collection \"{}\" -> {}", |
| 355 | collection.name, |
| 356 | path.display() |
| 357 | ); |
| 358 | println!(" {before} -> {after} requests"); |
| 359 | Ok(()) |
| 360 | } |
| 361 | |
| 362 | fn cmd_info(name: &str) -> Result<()> { |
| 363 | let name = store::resolve_collection(name)?; |
| 364 | let collection = store::load_collection(&name)?; |
| 365 | let path = store::collection_path(&name)?; |
| 366 | |
| 367 | println!("{}", collection.name); |
| 368 | println!(" file: {}", path.display()); |
| 369 | if let Some(src) = &collection.spec_source { |
| 370 | println!(" spec: {src}"); |
| 371 | } |
| 372 | if collection.servers.is_empty() { |
| 373 | println!(" servers: (none)"); |
| 374 | } else { |
| 375 | for (i, s) in collection.servers.iter().enumerate() { |
| 376 | let marker = if i == collection.active_server { |
| 377 | "*" |
| 378 | } else { |
| 379 | " " |
| 380 | }; |
| 381 | println!(" server{marker} {s}"); |
| 382 | } |
| 383 | } |
| 384 | println!(" requests: {}", collection.requests.len()); |
| 385 | println!(" variables: {}", collection.variables.len()); |
| 386 | match &collection.auth { |
| 387 | Some(auth) => println!( |
| 388 | " auth: oauth2 client-credentials, token url {} ({} client id)", |
| 389 | auth.token_url, |
| 390 | if auth.client_id.is_empty() { |
| 391 | "no" |
| 392 | } else { |
| 393 | "has" |
| 394 | } |
| 395 | ), |
| 396 | None => println!(" auth: none"), |
| 397 | } |
| 398 | |
| 399 | let mut groups: BTreeMap<&str, usize> = BTreeMap::new(); |
| 400 | for r in &collection.requests { |
| 401 | *groups |
| 402 | .entry(r.tags.first().map(String::as_str).unwrap_or("default")) |
| 403 | .or_default() += 1; |
| 404 | } |
| 405 | if !groups.is_empty() { |
| 406 | println!(" groups:"); |
| 407 | for (group, count) in groups { |
| 408 | println!(" {group} ({count})"); |
| 409 | } |
| 410 | } |
| 411 | Ok(()) |
| 412 | } |
| 413 | |
| 414 | fn cmd_path(name: &str) -> Result<()> { |
| 415 | let name = store::resolve_collection(name)?; |
| 416 | println!("{}", store::collection_path(&name)?.display()); |
| 417 | Ok(()) |
| 418 | } |
| 419 | |
| 420 | /// Keep `config.last_collection` pointing at a collection that was renamed. |
| 421 | fn update_last_collection(old: &str, new: &str) { |
| 422 | let mut config = AppConfig::load(); |
| 423 | if config.last_collection.as_deref() == Some(old) { |
| 424 | config.last_collection = Some(new.to_string()); |
| 425 | let _ = config.save(); |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | async fn cmd_open(name: Option<String>) -> Result<()> { |
| 430 | let mut config = AppConfig::load(); |
| 431 | let name = match name.or_else(|| config.last_collection.clone()) { |
| 432 | Some(n) => n, |
| 433 | None => { |
| 434 | let names = store::list_collections()?; |
| 435 | match names.as_slice() { |
| 436 | [] => bail!( |
| 437 | "No collections yet. Import one first:\n\n cielago import <spec.json|yaml|url>\n\nOr create an empty one:\n\n cielago new <name>" |
| 438 | ), |
| 439 | [only] => only.clone(), |
| 440 | many => bail!( |
| 441 | "Multiple collections exist; choose one:\n\n cielago open <name>\n\nAvailable: {}", |
| 442 | many.join(", ") |
| 443 | ), |
| 444 | } |
| 445 | } |
| 446 | }; |
| 447 | |
| 448 | let collection = |
| 449 | store::load_collection(&name).with_context(|| format!("loading collection {name:?}"))?; |
| 450 | config.last_collection = Some(collection.name.clone()); |
| 451 | let _ = config.save(); |
| 452 | let path = store::collection_path(&collection.name)?; |
| 453 | app::run(collection, path, config).await |
| 454 | } |