| 1 | use std::collections::BTreeMap; |
| 2 | use std::fs; |
| 3 | use std::io::{self, IsTerminal, Write}; |
| 4 | use std::process::Command as ProcessCommand; |
| 5 | |
| 6 | use anyhow::{Context, Result, bail}; |
| 7 | use clap::{Parser, Subcommand}; |
| 8 | use inquire::{Confirm, Select, Text}; |
| 9 | |
| 10 | use cielago::app; |
| 11 | use cielago::model::{ |
| 12 | AuthKind, AuthStyle, Collection, DEFAULT_API_KEY_HEADER, LabelMode, OAuthConfig, |
| 13 | }; |
| 14 | use cielago::openapi; |
| 15 | use cielago::store::{self, AppConfig}; |
| 16 | |
| 17 | const BANNER: &str = r#" |
| 18 | |
| 19 | c i e l a g o |
| 20 | |
| 21 | .-'"/'. |
| 22 | _-" ( '-_ |
| 23 | _.-' ) "-._ |
| 24 | .-' ` |
| 25 | ____________ _____ __ |
| 26 | "#; |
| 27 | |
| 28 | #[derive(Parser)] |
| 29 | #[command( |
| 30 | name = "cielago", |
| 31 | version, |
| 32 | about = "Like Postman but it actually works", |
| 33 | before_help = BANNER |
| 34 | )] |
| 35 | struct Cli { |
| 36 | #[command(subcommand)] |
| 37 | command: Option<Command>, |
| 38 | } |
| 39 | |
| 40 | #[derive(Subcommand)] |
| 41 | enum Command { |
| 42 | /// Import an OpenAPI 2.0/3.x spec (JSON/YAML, file path or URL) as a collection |
| 43 | Import { |
| 44 | /// File path or http(s) URL of the spec |
| 45 | source: String, |
| 46 | /// Collection name (defaults to the spec's info.title) |
| 47 | #[arg(long)] |
| 48 | name: Option<String>, |
| 49 | /// Skip interactive setup; keep spec-derived auth/servers and defaults |
| 50 | #[arg(short = 'y', long)] |
| 51 | yes: bool, |
| 52 | }, |
| 53 | /// Create an empty collection and open it in the TUI |
| 54 | New { |
| 55 | /// Collection name |
| 56 | name: String, |
| 57 | /// Base URL to start with (becomes the active server) |
| 58 | #[arg(long, short)] |
| 59 | server: Option<String>, |
| 60 | /// Skip interactive setup; open straight into the TUI |
| 61 | #[arg(short = 'y', long)] |
| 62 | yes: bool, |
| 63 | }, |
| 64 | /// List saved collections |
| 65 | List { |
| 66 | /// Show servers, request counts and file paths |
| 67 | #[arg(short, long)] |
| 68 | long: bool, |
| 69 | }, |
| 70 | /// Open a collection in the TUI (defaults to the last opened one) |
| 71 | Open { name: Option<String> }, |
| 72 | /// Delete a saved collection |
| 73 | Delete { |
| 74 | name: String, |
| 75 | /// Skip the confirmation prompt |
| 76 | #[arg(short, long)] |
| 77 | force: bool, |
| 78 | }, |
| 79 | /// Edit a collection's JSON in $EDITOR |
| 80 | Edit { name: String }, |
| 81 | /// Rename a collection (renames its file too) |
| 82 | Rename { name: String, new_name: String }, |
| 83 | /// Replace a collection's requests from a spec, keeping auth/vars/servers |
| 84 | Update { |
| 85 | /// Collection to update |
| 86 | name: String, |
| 87 | /// File path or http(s) URL of the spec to pull routes from |
| 88 | source: String, |
| 89 | }, |
| 90 | /// Show details about a collection |
| 91 | Info { name: String }, |
| 92 | /// Print the path of a collection's JSON file |
| 93 | Path { name: String }, |
| 94 | } |
| 95 | |
| 96 | #[tokio::main] |
| 97 | async fn main() -> Result<()> { |
| 98 | let cli = Cli::parse(); |
| 99 | match cli.command { |
| 100 | Some(Command::Import { source, name, yes }) => cmd_import(&source, name, yes).await, |
| 101 | Some(Command::New { name, server, yes }) => cmd_new(&name, server, yes).await, |
| 102 | Some(Command::List { long }) => cmd_list(long), |
| 103 | Some(Command::Open { name }) => cmd_open(name).await, |
| 104 | Some(Command::Delete { name, force }) => cmd_delete(&name, force), |
| 105 | Some(Command::Edit { name }) => cmd_edit(&name), |
| 106 | Some(Command::Rename { name, new_name }) => cmd_rename(&name, &new_name), |
| 107 | Some(Command::Update { name, source }) => cmd_update(&name, &source).await, |
| 108 | Some(Command::Info { name }) => cmd_info(&name), |
| 109 | Some(Command::Path { name }) => cmd_path(&name), |
| 110 | None => cmd_open(None).await, |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | async fn cmd_import(source: &str, name: Option<String>, yes: bool) -> Result<()> { |
| 115 | let doc = openapi::load_spec(source).await?; |
| 116 | let name = name |
| 117 | .or_else(|| { |
| 118 | doc.pointer("/info/title") |
| 119 | .and_then(|t| t.as_str()) |
| 120 | .map(String::from) |
| 121 | }) |
| 122 | .unwrap_or_else(|| "imported".to_string()); |
| 123 | |
| 124 | // Walk the user through the name, auth, server and display preferences |
| 125 | // unless they opted out with `--yes` or stdin isn't a terminal (e.g. a |
| 126 | // script or pipe), in which case the spec-derived defaults stand. |
| 127 | let interactive = !yes && io::stdin().is_terminal(); |
| 128 | let name = if interactive { |
| 129 | println!("{BANNER}"); |
| 130 | Text::new("Collection name").with_default(&name).prompt()? |
| 131 | } else { |
| 132 | name |
| 133 | }; |
| 134 | |
| 135 | let mut collection = openapi::import_spec(&doc, &name, Some(source.to_string())); |
| 136 | |
| 137 | if interactive { |
| 138 | run_walkthrough(&mut collection)?; |
| 139 | } |
| 140 | |
| 141 | let path = store::save_collection(&collection)?; |
| 142 | |
| 143 | println!( |
| 144 | "Imported collection \"{}\" -> {}", |
| 145 | collection.name, |
| 146 | path.display() |
| 147 | ); |
| 148 | println!(" {} requests", collection.requests.len()); |
| 149 | if collection.servers.is_empty() { |
| 150 | println!(" servers: (none)"); |
| 151 | } else { |
| 152 | println!( |
| 153 | " servers: {} (active: {})", |
| 154 | collection.servers.join(", "), |
| 155 | collection |
| 156 | .base_url() |
| 157 | .unwrap_or(collection.servers[0].as_str()) |
| 158 | ); |
| 159 | } |
| 160 | match &collection.auth { |
| 161 | Some(auth) => println!(" auth: {}", auth_summary(auth)), |
| 162 | None => println!(" auth: none"), |
| 163 | } |
| 164 | Ok(()) |
| 165 | } |
| 166 | |
| 167 | /// One-line description of a configured auth scheme for the import summary. |
| 168 | fn auth_summary(auth: &OAuthConfig) -> String { |
| 169 | match auth.kind { |
| 170 | AuthKind::Bearer => { |
| 171 | let state = if auth.token.is_empty() { |
| 172 | " (no token set — set it with A in the TUI)" |
| 173 | } else { |
| 174 | "" |
| 175 | }; |
| 176 | format!("bearer{state}") |
| 177 | } |
| 178 | AuthKind::ApiKey => { |
| 179 | let state = if auth.token.is_empty() { |
| 180 | " (no value set — set it with A in the TUI)" |
| 181 | } else { |
| 182 | "" |
| 183 | }; |
| 184 | format!("api key in {}{state}", auth.api_key_header()) |
| 185 | } |
| 186 | AuthKind::Oauth2 => { |
| 187 | let state = if auth.client_id.is_empty() { |
| 188 | " (set client id/secret with A in the TUI)" |
| 189 | } else { |
| 190 | "" |
| 191 | }; |
| 192 | format!( |
| 193 | "oauth2 client-credentials, token url {}{state}", |
| 194 | auth.token_url |
| 195 | ) |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | /// A free-text prompt whose empty submission — or an Esc — comes back as an |
| 201 | /// empty string, the "leave it blank, fill in from the TUI later" case. |
| 202 | fn optional_text(message: &str, help: &str) -> Result<String> { |
| 203 | Ok(Text::new(message) |
| 204 | .with_help_message(help) |
| 205 | .prompt_skippable()? |
| 206 | .unwrap_or_default()) |
| 207 | } |
| 208 | |
| 209 | /// A free-text prompt that offers `current` as its default (kept on an empty |
| 210 | /// reply). With no current value it behaves like a plain optional prompt. |
| 211 | fn text_default(message: &str, current: &str) -> Result<String> { |
| 212 | if current.is_empty() { |
| 213 | return optional_text(message, "blank to set later"); |
| 214 | } |
| 215 | Ok(Text::new(message).with_default(current).prompt()?) |
| 216 | } |
| 217 | |
| 218 | /// The shared auth → server → preferences walkthrough, run by both `import` |
| 219 | /// and `new` once the caller has confirmed a terminal and printed the banner. |
| 220 | /// Kept separate from the banner and name prompts, which differ per command. |
| 221 | fn run_walkthrough(collection: &mut Collection) -> Result<()> { |
| 222 | prompt_auth(collection)?; |
| 223 | prompt_server(collection)?; |
| 224 | prompt_preferences(collection)?; |
| 225 | Ok(()) |
| 226 | } |
| 227 | |
| 228 | /// Choose the collection's auth scheme, then collect that scheme's values. Any |
| 229 | /// value may be left blank and filled in later from the TUI. A scheme the spec |
| 230 | /// implied (e.g. oauth2 from a `clientCredentials` flow) is pre-selected and |
| 231 | /// seeds the oauth2 field prompts. |
| 232 | fn prompt_auth(collection: &mut Collection) -> Result<()> { |
| 233 | const NONE: &str = "none"; |
| 234 | const BEARER: &str = "bearer token"; |
| 235 | const API_KEY: &str = "api key"; |
| 236 | const OAUTH2: &str = "oauth2 client-credentials"; |
| 237 | |
| 238 | let cursor = match collection.auth.as_ref().map(|a| a.kind) { |
| 239 | Some(AuthKind::Bearer) => 1, |
| 240 | Some(AuthKind::ApiKey) => 2, |
| 241 | Some(AuthKind::Oauth2) => 3, |
| 242 | None => 0, |
| 243 | }; |
| 244 | let choice = Select::new("Authentication", vec![NONE, BEARER, API_KEY, OAUTH2]) |
| 245 | .with_starting_cursor(cursor) |
| 246 | .prompt()?; |
| 247 | |
| 248 | collection.auth = match choice { |
| 249 | BEARER => Some(OAuthConfig { |
| 250 | kind: AuthKind::Bearer, |
| 251 | token: optional_text("Bearer token", "blank to set later")?, |
| 252 | ..Default::default() |
| 253 | }), |
| 254 | API_KEY => Some(OAuthConfig { |
| 255 | kind: AuthKind::ApiKey, |
| 256 | header: Text::new("Header name") |
| 257 | .with_default(DEFAULT_API_KEY_HEADER) |
| 258 | .prompt()?, |
| 259 | token: optional_text("API key value", "blank to set later")?, |
| 260 | ..Default::default() |
| 261 | }), |
| 262 | OAUTH2 => { |
| 263 | // Reuse spec-derived token url/scopes as defaults when present. |
| 264 | let mut cfg = collection.auth.clone().unwrap_or_default(); |
| 265 | cfg.kind = AuthKind::Oauth2; |
| 266 | cfg.token_url = text_default("Token URL", &cfg.token_url)?; |
| 267 | cfg.client_id = optional_text("Client id", "blank to set later")?; |
| 268 | cfg.client_secret = optional_text("Client secret", "blank to set later")?; |
| 269 | let scopes = text_default("Scopes (space-separated)", &cfg.scopes.join(" "))?; |
| 270 | cfg.scopes = scopes.split_whitespace().map(String::from).collect(); |
| 271 | let style = |
| 272 | Select::new("Send credentials via", vec!["basic header", "form body"]).prompt()?; |
| 273 | cfg.auth_style = if style == "form body" { |
| 274 | AuthStyle::Post |
| 275 | } else { |
| 276 | AuthStyle::Basic |
| 277 | }; |
| 278 | Some(cfg) |
| 279 | } |
| 280 | _ => None, |
| 281 | }; |
| 282 | Ok(()) |
| 283 | } |
| 284 | |
| 285 | /// Pick the active base URL. Spec-derived servers are offered in a list with a |
| 286 | /// trailing "enter a new URL" escape; a typed URL is added and made active. |
| 287 | fn prompt_server(collection: &mut Collection) -> Result<()> { |
| 288 | if collection.servers.is_empty() { |
| 289 | let url = normalize_server(&optional_text("Base URL", "blank for none")?); |
| 290 | if !url.is_empty() { |
| 291 | collection.servers.push(url); |
| 292 | collection.active_server = 0; |
| 293 | } |
| 294 | return Ok(()); |
| 295 | } |
| 296 | |
| 297 | const NEW: &str = "+ enter a new URL…"; |
| 298 | let mut options: Vec<String> = collection.servers.clone(); |
| 299 | options.push(NEW.to_string()); |
| 300 | let choice = Select::new("Active server", options).prompt()?; |
| 301 | |
| 302 | if choice == NEW { |
| 303 | let url = normalize_server(&optional_text("Base URL", "blank to keep the first")?); |
| 304 | collection.active_server = if url.is_empty() { |
| 305 | 0 |
| 306 | } else { |
| 307 | collection |
| 308 | .servers |
| 309 | .iter() |
| 310 | .position(|s| *s == url) |
| 311 | .unwrap_or_else(|| { |
| 312 | collection.servers.push(url); |
| 313 | collection.servers.len() - 1 |
| 314 | }) |
| 315 | }; |
| 316 | } else { |
| 317 | collection.active_server = collection |
| 318 | .servers |
| 319 | .iter() |
| 320 | .position(|s| *s == choice) |
| 321 | .unwrap_or(0); |
| 322 | } |
| 323 | Ok(()) |
| 324 | } |
| 325 | |
| 326 | /// Trim surrounding whitespace and a trailing slash so a typed URL matches the |
| 327 | /// form imported servers are stored in (and doesn't duplicate one). |
| 328 | fn normalize_server(url: &str) -> String { |
| 329 | url.trim().trim_end_matches('/').to_string() |
| 330 | } |
| 331 | |
| 332 | /// Collection display preferences: how the sidebar labels requests, and whether |
| 333 | /// tag groups start collapsed. |
| 334 | fn prompt_preferences(collection: &mut Collection) -> Result<()> { |
| 335 | let label = Select::new("Label requests by", vec!["name", "summary", "path"]).prompt()?; |
| 336 | collection.label_mode = match label { |
| 337 | "summary" => LabelMode::Summary, |
| 338 | "path" => LabelMode::Path, |
| 339 | _ => LabelMode::Name, |
| 340 | }; |
| 341 | |
| 342 | collection.groups_collapsed = Confirm::new("Collapse tag groups on open?") |
| 343 | .with_default(true) |
| 344 | .prompt()?; |
| 345 | Ok(()) |
| 346 | } |
| 347 | |
| 348 | /// Create an empty collection and drop straight into the TUI to fill it in. |
| 349 | /// The existence check is on the slug path rather than via |
| 350 | /// `store::resolve_collection`, which bails by contract on a name that doesn't |
| 351 | /// exist yet — and the path check also catches names that collide after |
| 352 | /// slugify, same as `cielago rename`. |
| 353 | async fn cmd_new(name: &str, server: Option<String>, yes: bool) -> Result<()> { |
| 354 | let path = store::collection_path(name)?; |
| 355 | if path.exists() { |
| 356 | bail!( |
| 357 | "a collection already exists at {} — open it with `cielago open {name:?}` or pick another name", |
| 358 | path.display() |
| 359 | ); |
| 360 | } |
| 361 | |
| 362 | let mut collection = Collection::new(name); |
| 363 | if let Some(url) = server { |
| 364 | // Trailing slash trimmed to match imported servers, so pasting a URL in |
| 365 | // the TUI later recognises this one instead of adding a duplicate. |
| 366 | let url = url.trim().trim_end_matches('/').to_string(); |
| 367 | if !url.is_empty() { |
| 368 | collection.servers.push(url); |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | // Same auth/server/preferences walkthrough as import, so a hand-made |
| 373 | // collection starts configured rather than blank. Skipped with `--yes` or |
| 374 | // when stdin isn't a terminal; either way the TUI opens next to fill in the |
| 375 | // rest. A `--server` given on the command line seeds the server prompt. |
| 376 | if !yes && io::stdin().is_terminal() { |
| 377 | println!("{BANNER}"); |
| 378 | run_walkthrough(&mut collection)?; |
| 379 | } |
| 380 | |
| 381 | let path = store::save_collection(&collection)?; |
| 382 | println!( |
| 383 | "Created collection \"{}\" -> {}", |
| 384 | collection.name, |
| 385 | path.display() |
| 386 | ); |
| 387 | |
| 388 | let mut config = AppConfig::load(); |
| 389 | config.last_collection = Some(collection.name.clone()); |
| 390 | let _ = config.save(); |
| 391 | app::run(collection, path, config).await |
| 392 | } |
| 393 | |
| 394 | fn cmd_list(long: bool) -> Result<()> { |
| 395 | let names = store::list_collections()?; |
| 396 | if names.is_empty() { |
| 397 | println!( |
| 398 | "No collections yet. Import one: cielago import <spec>\n\ |
| 399 | …or start from scratch: cielago new <name>" |
| 400 | ); |
| 401 | return Ok(()); |
| 402 | } |
| 403 | let last = AppConfig::load().last_collection; |
| 404 | for n in names { |
| 405 | if !long { |
| 406 | println!("{n}"); |
| 407 | continue; |
| 408 | } |
| 409 | let marker = if last.as_deref() == Some(n.as_str()) { |
| 410 | "*" |
| 411 | } else { |
| 412 | " " |
| 413 | }; |
| 414 | let path = store::collection_path(&n)?; |
| 415 | match store::load_collection(&n) { |
| 416 | Ok(c) => println!( |
| 417 | "{marker} {n}\n {} requests, {} server(s){}\n {}", |
| 418 | c.requests.len(), |
| 419 | c.servers.len(), |
| 420 | if c.auth.is_some() { ", oauth2" } else { "" }, |
| 421 | path.display() |
| 422 | ), |
| 423 | Err(e) => println!("{marker} {n}\n unreadable: {e}\n {}", path.display()), |
| 424 | } |
| 425 | } |
| 426 | Ok(()) |
| 427 | } |
| 428 | |
| 429 | fn cmd_delete(name: &str, force: bool) -> Result<()> { |
| 430 | let name = store::resolve_collection(name)?; |
| 431 | let collection = store::load_collection(&name).ok(); |
| 432 | let path = store::collection_path(&name)?; |
| 433 | |
| 434 | if !force { |
| 435 | let count = collection |
| 436 | .as_ref() |
| 437 | .map(|c| format!(" ({} requests)", c.requests.len())) |
| 438 | .unwrap_or_default(); |
| 439 | print!("Delete collection \"{name}\"{count}? [y/N] "); |
| 440 | io::stdout().flush()?; |
| 441 | let mut answer = String::new(); |
| 442 | io::stdin().read_line(&mut answer)?; |
| 443 | if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { |
| 444 | println!("Aborted."); |
| 445 | return Ok(()); |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | store::delete_collection(&name)?; |
| 450 | let mut config = AppConfig::load(); |
| 451 | if config.last_collection.as_deref() == Some(name.as_str()) { |
| 452 | config.last_collection = None; |
| 453 | let _ = config.save(); |
| 454 | } |
| 455 | println!("Deleted \"{name}\" ({})", path.display()); |
| 456 | Ok(()) |
| 457 | } |
| 458 | |
| 459 | /// Edit the collection JSON in `$EDITOR`. The edit happens on a temp copy so a |
| 460 | /// file that no longer parses never replaces the saved one; a `name` changed in |
| 461 | /// the editor moves the file, same as `cielago rename`. |
| 462 | fn cmd_edit(name: &str) -> Result<()> { |
| 463 | let name = store::resolve_collection(name)?; |
| 464 | let path = store::collection_path(&name)?; |
| 465 | let original = |
| 466 | fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; |
| 467 | |
| 468 | let mut tmp = std::env::temp_dir(); |
| 469 | tmp.push(format!( |
| 470 | "cielago-{}-{}.json", |
| 471 | store::slugify(&name), |
| 472 | std::process::id() |
| 473 | )); |
| 474 | fs::write(&tmp, &original)?; |
| 475 | |
| 476 | let editor = AppConfig::load().editor_cmd(); |
| 477 | let mut parts = editor.split_whitespace(); |
| 478 | let program = parts.next().unwrap_or("vi"); |
| 479 | let status = ProcessCommand::new(program) |
| 480 | .args(parts) |
| 481 | .arg(&tmp) |
| 482 | .status() |
| 483 | .with_context(|| format!("launching editor {editor:?}"))?; |
| 484 | if !status.success() { |
| 485 | let _ = fs::remove_file(&tmp); |
| 486 | bail!("editor exited with {status}; collection left unchanged"); |
| 487 | } |
| 488 | |
| 489 | let edited = fs::read_to_string(&tmp)?; |
| 490 | if edited == original { |
| 491 | let _ = fs::remove_file(&tmp); |
| 492 | println!("No changes."); |
| 493 | return Ok(()); |
| 494 | } |
| 495 | |
| 496 | let collection: Collection = match serde_json::from_str(&edited) { |
| 497 | Ok(c) => c, |
| 498 | Err(e) => bail!( |
| 499 | "edited JSON is not a valid collection: {e}\n\nYour edits are kept at {}; the saved collection is unchanged.", |
| 500 | tmp.display() |
| 501 | ), |
| 502 | }; |
| 503 | let new_path = store::collection_path(&collection.name)?; |
| 504 | if new_path != path && new_path.exists() { |
| 505 | bail!( |
| 506 | "renaming to {:?} would overwrite the collection at {}.\n\nYour edits are kept at {}; the saved collection is unchanged.", |
| 507 | collection.name, |
| 508 | new_path.display(), |
| 509 | tmp.display() |
| 510 | ); |
| 511 | } |
| 512 | let _ = fs::remove_file(&tmp); |
| 513 | |
| 514 | store::save_collection(&collection)?; |
| 515 | if new_path != path { |
| 516 | fs::remove_file(&path).ok(); |
| 517 | update_last_collection(&name, &collection.name); |
| 518 | println!( |
| 519 | "Saved \"{}\" -> {} (was \"{name}\")", |
| 520 | collection.name, |
| 521 | new_path.display() |
| 522 | ); |
| 523 | } else { |
| 524 | println!("Saved \"{}\" -> {}", collection.name, new_path.display()); |
| 525 | } |
| 526 | Ok(()) |
| 527 | } |
| 528 | |
| 529 | fn cmd_rename(name: &str, new_name: &str) -> Result<()> { |
| 530 | let name = store::resolve_collection(name)?; |
| 531 | let mut collection = store::load_collection(&name)?; |
| 532 | let old_path = store::collection_path(&name)?; |
| 533 | let new_path = store::collection_path(new_name)?; |
| 534 | |
| 535 | if new_path != old_path && new_path.exists() { |
| 536 | bail!( |
| 537 | "a collection already exists at {} — pick another name", |
| 538 | new_path.display() |
| 539 | ); |
| 540 | } |
| 541 | |
| 542 | collection.name = new_name.to_string(); |
| 543 | store::save_collection(&collection)?; |
| 544 | if new_path != old_path { |
| 545 | fs::remove_file(&old_path).ok(); |
| 546 | } |
| 547 | update_last_collection(&name, new_name); |
| 548 | println!( |
| 549 | "Renamed \"{name}\" -> \"{new_name}\" ({})", |
| 550 | new_path.display() |
| 551 | ); |
| 552 | Ok(()) |
| 553 | } |
| 554 | |
| 555 | /// Refresh a collection's routes from a spec without touching the rest of it. |
| 556 | /// Only `requests` is replaced (existing routes are overwritten); auth, |
| 557 | /// variables, servers, active server and view state stay as the user left |
| 558 | /// them. `last_request` is cleared because re-import mints new request ids, so |
| 559 | /// the old pointer would dangle. |
| 560 | async fn cmd_update(name: &str, source: &str) -> Result<()> { |
| 561 | let name = store::resolve_collection(name)?; |
| 562 | let mut collection = store::load_collection(&name)?; |
| 563 | |
| 564 | let doc = openapi::load_spec(source).await?; |
| 565 | // Import under the collection's own name so the throwaway result matches; |
| 566 | // only its `requests` are pulled across. |
| 567 | let imported = openapi::import_spec(&doc, &collection.name, Some(source.to_string())); |
| 568 | |
| 569 | let before = collection.requests.len(); |
| 570 | let after = imported.requests.len(); |
| 571 | collection.replace_requests_from(imported); |
| 572 | collection.spec_source = Some(source.to_string()); |
| 573 | |
| 574 | let path = store::save_collection(&collection)?; |
| 575 | println!( |
| 576 | "Updated collection \"{}\" -> {}", |
| 577 | collection.name, |
| 578 | path.display() |
| 579 | ); |
| 580 | println!(" {before} -> {after} requests"); |
| 581 | Ok(()) |
| 582 | } |
| 583 | |
| 584 | fn cmd_info(name: &str) -> Result<()> { |
| 585 | let name = store::resolve_collection(name)?; |
| 586 | let collection = store::load_collection(&name)?; |
| 587 | let path = store::collection_path(&name)?; |
| 588 | |
| 589 | println!("{}", collection.name); |
| 590 | println!(" file: {}", path.display()); |
| 591 | if let Some(src) = &collection.spec_source { |
| 592 | println!(" spec: {src}"); |
| 593 | } |
| 594 | if collection.servers.is_empty() { |
| 595 | println!(" servers: (none)"); |
| 596 | } else { |
| 597 | for (i, s) in collection.servers.iter().enumerate() { |
| 598 | let marker = if i == collection.active_server { |
| 599 | "*" |
| 600 | } else { |
| 601 | " " |
| 602 | }; |
| 603 | println!(" server{marker} {s}"); |
| 604 | } |
| 605 | } |
| 606 | println!(" requests: {}", collection.requests.len()); |
| 607 | println!(" variables: {}", collection.variables.len()); |
| 608 | match &collection.auth { |
| 609 | Some(auth) => println!( |
| 610 | " auth: oauth2 client-credentials, token url {} ({} client id)", |
| 611 | auth.token_url, |
| 612 | if auth.client_id.is_empty() { |
| 613 | "no" |
| 614 | } else { |
| 615 | "has" |
| 616 | } |
| 617 | ), |
| 618 | None => println!(" auth: none"), |
| 619 | } |
| 620 | |
| 621 | let mut groups: BTreeMap<&str, usize> = BTreeMap::new(); |
| 622 | for r in &collection.requests { |
| 623 | *groups |
| 624 | .entry(r.tags.first().map(String::as_str).unwrap_or("default")) |
| 625 | .or_default() += 1; |
| 626 | } |
| 627 | if !groups.is_empty() { |
| 628 | println!(" groups:"); |
| 629 | for (group, count) in groups { |
| 630 | println!(" {group} ({count})"); |
| 631 | } |
| 632 | } |
| 633 | Ok(()) |
| 634 | } |
| 635 | |
| 636 | fn cmd_path(name: &str) -> Result<()> { |
| 637 | let name = store::resolve_collection(name)?; |
| 638 | println!("{}", store::collection_path(&name)?.display()); |
| 639 | Ok(()) |
| 640 | } |
| 641 | |
| 642 | /// Keep `config.last_collection` pointing at a collection that was renamed. |
| 643 | fn update_last_collection(old: &str, new: &str) { |
| 644 | let mut config = AppConfig::load(); |
| 645 | if config.last_collection.as_deref() == Some(old) { |
| 646 | config.last_collection = Some(new.to_string()); |
| 647 | let _ = config.save(); |
| 648 | } |
| 649 | } |
| 650 | |
| 651 | async fn cmd_open(name: Option<String>) -> Result<()> { |
| 652 | let mut config = AppConfig::load(); |
| 653 | let name = match name.or_else(|| config.last_collection.clone()) { |
| 654 | Some(n) => n, |
| 655 | None => { |
| 656 | let names = store::list_collections()?; |
| 657 | match names.as_slice() { |
| 658 | [] => bail!( |
| 659 | "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>" |
| 660 | ), |
| 661 | [only] => only.clone(), |
| 662 | many => bail!( |
| 663 | "Multiple collections exist; choose one:\n\n cielago open <name>\n\nAvailable: {}", |
| 664 | many.join(", ") |
| 665 | ), |
| 666 | } |
| 667 | } |
| 668 | }; |
| 669 | |
| 670 | let collection = |
| 671 | store::load_collection(&name).with_context(|| format!("loading collection {name:?}"))?; |
| 672 | config.last_collection = Some(collection.name.clone()); |
| 673 | let _ = config.save(); |
| 674 | let path = store::collection_path(&collection.name)?; |
| 675 | app::run(collection, path, config).await |
| 676 | } |