| 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 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!("oauth2 client-credentials, token url {}{state}", auth.token_url) |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | /// A free-text prompt whose empty submission — or an Esc — comes back as an |
| 198 | /// empty string, the "leave it blank, fill in from the TUI later" case. |
| 199 | fn optional_text(message: &str, help: &str) -> Result<String> { |
| 200 | Ok(Text::new(message) |
| 201 | .with_help_message(help) |
| 202 | .prompt_skippable()? |
| 203 | .unwrap_or_default()) |
| 204 | } |
| 205 | |
| 206 | /// A free-text prompt that offers `current` as its default (kept on an empty |
| 207 | /// reply). With no current value it behaves like a plain optional prompt. |
| 208 | fn text_default(message: &str, current: &str) -> Result<String> { |
| 209 | if current.is_empty() { |
| 210 | return optional_text(message, "blank to set later"); |
| 211 | } |
| 212 | Ok(Text::new(message).with_default(current).prompt()?) |
| 213 | } |
| 214 | |
| 215 | /// The shared auth → server → preferences walkthrough, run by both `import` |
| 216 | /// and `new` once the caller has confirmed a terminal and printed the banner. |
| 217 | /// Kept separate from the banner and name prompts, which differ per command. |
| 218 | fn run_walkthrough(collection: &mut Collection) -> Result<()> { |
| 219 | prompt_auth(collection)?; |
| 220 | prompt_server(collection)?; |
| 221 | prompt_preferences(collection)?; |
| 222 | Ok(()) |
| 223 | } |
| 224 | |
| 225 | /// Choose the collection's auth scheme, then collect that scheme's values. Any |
| 226 | /// value may be left blank and filled in later from the TUI. A scheme the spec |
| 227 | /// implied (e.g. oauth2 from a `clientCredentials` flow) is pre-selected and |
| 228 | /// seeds the oauth2 field prompts. |
| 229 | fn prompt_auth(collection: &mut Collection) -> Result<()> { |
| 230 | const NONE: &str = "none"; |
| 231 | const BEARER: &str = "bearer token"; |
| 232 | const API_KEY: &str = "api key"; |
| 233 | const OAUTH2: &str = "oauth2 client-credentials"; |
| 234 | |
| 235 | let cursor = match collection.auth.as_ref().map(|a| a.kind) { |
| 236 | Some(AuthKind::Bearer) => 1, |
| 237 | Some(AuthKind::ApiKey) => 2, |
| 238 | Some(AuthKind::Oauth2) => 3, |
| 239 | None => 0, |
| 240 | }; |
| 241 | let choice = Select::new("Authentication", vec![NONE, BEARER, API_KEY, OAUTH2]) |
| 242 | .with_starting_cursor(cursor) |
| 243 | .prompt()?; |
| 244 | |
| 245 | collection.auth = match choice { |
| 246 | BEARER => Some(OAuthConfig { |
| 247 | kind: AuthKind::Bearer, |
| 248 | token: optional_text("Bearer token", "blank to set later")?, |
| 249 | ..Default::default() |
| 250 | }), |
| 251 | API_KEY => Some(OAuthConfig { |
| 252 | kind: AuthKind::ApiKey, |
| 253 | header: Text::new("Header name") |
| 254 | .with_default(DEFAULT_API_KEY_HEADER) |
| 255 | .prompt()?, |
| 256 | token: optional_text("API key value", "blank to set later")?, |
| 257 | ..Default::default() |
| 258 | }), |
| 259 | OAUTH2 => { |
| 260 | // Reuse spec-derived token url/scopes as defaults when present. |
| 261 | let mut cfg = collection.auth.clone().unwrap_or_default(); |
| 262 | cfg.kind = AuthKind::Oauth2; |
| 263 | cfg.token_url = text_default("Token URL", &cfg.token_url)?; |
| 264 | cfg.client_id = optional_text("Client id", "blank to set later")?; |
| 265 | cfg.client_secret = optional_text("Client secret", "blank to set later")?; |
| 266 | let scopes = text_default("Scopes (space-separated)", &cfg.scopes.join(" "))?; |
| 267 | cfg.scopes = scopes.split_whitespace().map(String::from).collect(); |
| 268 | let style = |
| 269 | Select::new("Send credentials via", vec!["basic header", "form body"]).prompt()?; |
| 270 | cfg.auth_style = if style == "form body" { |
| 271 | AuthStyle::Post |
| 272 | } else { |
| 273 | AuthStyle::Basic |
| 274 | }; |
| 275 | Some(cfg) |
| 276 | } |
| 277 | _ => None, |
| 278 | }; |
| 279 | Ok(()) |
| 280 | } |
| 281 | |
| 282 | /// Pick the active base URL. Spec-derived servers are offered in a list with a |
| 283 | /// trailing "enter a new URL" escape; a typed URL is added and made active. |
| 284 | fn prompt_server(collection: &mut Collection) -> Result<()> { |
| 285 | if collection.servers.is_empty() { |
| 286 | let url = normalize_server(&optional_text("Base URL", "blank for none")?); |
| 287 | if !url.is_empty() { |
| 288 | collection.servers.push(url); |
| 289 | collection.active_server = 0; |
| 290 | } |
| 291 | return Ok(()); |
| 292 | } |
| 293 | |
| 294 | const NEW: &str = "+ enter a new URL…"; |
| 295 | let mut options: Vec<String> = collection.servers.clone(); |
| 296 | options.push(NEW.to_string()); |
| 297 | let choice = Select::new("Active server", options).prompt()?; |
| 298 | |
| 299 | if choice == NEW { |
| 300 | let url = normalize_server(&optional_text("Base URL", "blank to keep the first")?); |
| 301 | collection.active_server = if url.is_empty() { |
| 302 | 0 |
| 303 | } else { |
| 304 | collection |
| 305 | .servers |
| 306 | .iter() |
| 307 | .position(|s| *s == url) |
| 308 | .unwrap_or_else(|| { |
| 309 | collection.servers.push(url); |
| 310 | collection.servers.len() - 1 |
| 311 | }) |
| 312 | }; |
| 313 | } else { |
| 314 | collection.active_server = collection |
| 315 | .servers |
| 316 | .iter() |
| 317 | .position(|s| *s == choice) |
| 318 | .unwrap_or(0); |
| 319 | } |
| 320 | Ok(()) |
| 321 | } |
| 322 | |
| 323 | /// Trim surrounding whitespace and a trailing slash so a typed URL matches the |
| 324 | /// form imported servers are stored in (and doesn't duplicate one). |
| 325 | fn normalize_server(url: &str) -> String { |
| 326 | url.trim().trim_end_matches('/').to_string() |
| 327 | } |
| 328 | |
| 329 | /// Collection display preferences: how the sidebar labels requests, and whether |
| 330 | /// tag groups start collapsed. |
| 331 | fn prompt_preferences(collection: &mut Collection) -> Result<()> { |
| 332 | let label = Select::new("Label requests by", vec!["name", "summary", "path"]).prompt()?; |
| 333 | collection.label_mode = match label { |
| 334 | "summary" => LabelMode::Summary, |
| 335 | "path" => LabelMode::Path, |
| 336 | _ => LabelMode::Name, |
| 337 | }; |
| 338 | |
| 339 | collection.groups_collapsed = Confirm::new("Collapse tag groups on open?") |
| 340 | .with_default(true) |
| 341 | .prompt()?; |
| 342 | Ok(()) |
| 343 | } |
| 344 | |
| 345 | /// Create an empty collection and drop straight into the TUI to fill it in. |
| 346 | /// The existence check is on the slug path rather than via |
| 347 | /// `store::resolve_collection`, which bails by contract on a name that doesn't |
| 348 | /// exist yet — and the path check also catches names that collide after |
| 349 | /// slugify, same as `cielago rename`. |
| 350 | async fn cmd_new(name: &str, server: Option<String>, yes: bool) -> Result<()> { |
| 351 | let path = store::collection_path(name)?; |
| 352 | if path.exists() { |
| 353 | bail!( |
| 354 | "a collection already exists at {} — open it with `cielago open {name:?}` or pick another name", |
| 355 | path.display() |
| 356 | ); |
| 357 | } |
| 358 | |
| 359 | let mut collection = Collection::new(name); |
| 360 | if let Some(url) = server { |
| 361 | // Trailing slash trimmed to match imported servers, so pasting a URL in |
| 362 | // the TUI later recognises this one instead of adding a duplicate. |
| 363 | let url = url.trim().trim_end_matches('/').to_string(); |
| 364 | if !url.is_empty() { |
| 365 | collection.servers.push(url); |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | // Same auth/server/preferences walkthrough as import, so a hand-made |
| 370 | // collection starts configured rather than blank. Skipped with `--yes` or |
| 371 | // when stdin isn't a terminal; either way the TUI opens next to fill in the |
| 372 | // rest. A `--server` given on the command line seeds the server prompt. |
| 373 | if !yes && io::stdin().is_terminal() { |
| 374 | println!("{BANNER}"); |
| 375 | run_walkthrough(&mut collection)?; |
| 376 | } |
| 377 | |
| 378 | let path = store::save_collection(&collection)?; |
| 379 | println!( |
| 380 | "Created collection \"{}\" -> {}", |
| 381 | collection.name, |
| 382 | path.display() |
| 383 | ); |
| 384 | |
| 385 | let mut config = AppConfig::load(); |
| 386 | config.last_collection = Some(collection.name.clone()); |
| 387 | let _ = config.save(); |
| 388 | app::run(collection, path, config).await |
| 389 | } |
| 390 | |
| 391 | fn cmd_list(long: bool) -> Result<()> { |
| 392 | let names = store::list_collections()?; |
| 393 | if names.is_empty() { |
| 394 | println!( |
| 395 | "No collections yet. Import one: cielago import <spec>\n\ |
| 396 | …or start from scratch: cielago new <name>" |
| 397 | ); |
| 398 | return Ok(()); |
| 399 | } |
| 400 | let last = AppConfig::load().last_collection; |
| 401 | for n in names { |
| 402 | if !long { |
| 403 | println!("{n}"); |
| 404 | continue; |
| 405 | } |
| 406 | let marker = if last.as_deref() == Some(n.as_str()) { |
| 407 | "*" |
| 408 | } else { |
| 409 | " " |
| 410 | }; |
| 411 | let path = store::collection_path(&n)?; |
| 412 | match store::load_collection(&n) { |
| 413 | Ok(c) => println!( |
| 414 | "{marker} {n}\n {} requests, {} server(s){}\n {}", |
| 415 | c.requests.len(), |
| 416 | c.servers.len(), |
| 417 | if c.auth.is_some() { ", oauth2" } else { "" }, |
| 418 | path.display() |
| 419 | ), |
| 420 | Err(e) => println!("{marker} {n}\n unreadable: {e}\n {}", path.display()), |
| 421 | } |
| 422 | } |
| 423 | Ok(()) |
| 424 | } |
| 425 | |
| 426 | fn cmd_delete(name: &str, force: bool) -> Result<()> { |
| 427 | let name = store::resolve_collection(name)?; |
| 428 | let collection = store::load_collection(&name).ok(); |
| 429 | let path = store::collection_path(&name)?; |
| 430 | |
| 431 | if !force { |
| 432 | let count = collection |
| 433 | .as_ref() |
| 434 | .map(|c| format!(" ({} requests)", c.requests.len())) |
| 435 | .unwrap_or_default(); |
| 436 | print!("Delete collection \"{name}\"{count}? [y/N] "); |
| 437 | io::stdout().flush()?; |
| 438 | let mut answer = String::new(); |
| 439 | io::stdin().read_line(&mut answer)?; |
| 440 | if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { |
| 441 | println!("Aborted."); |
| 442 | return Ok(()); |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | store::delete_collection(&name)?; |
| 447 | let mut config = AppConfig::load(); |
| 448 | if config.last_collection.as_deref() == Some(name.as_str()) { |
| 449 | config.last_collection = None; |
| 450 | let _ = config.save(); |
| 451 | } |
| 452 | println!("Deleted \"{name}\" ({})", path.display()); |
| 453 | Ok(()) |
| 454 | } |
| 455 | |
| 456 | /// Edit the collection JSON in `$EDITOR`. The edit happens on a temp copy so a |
| 457 | /// file that no longer parses never replaces the saved one; a `name` changed in |
| 458 | /// the editor moves the file, same as `cielago rename`. |
| 459 | fn cmd_edit(name: &str) -> Result<()> { |
| 460 | let name = store::resolve_collection(name)?; |
| 461 | let path = store::collection_path(&name)?; |
| 462 | let original = |
| 463 | fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; |
| 464 | |
| 465 | let mut tmp = std::env::temp_dir(); |
| 466 | tmp.push(format!( |
| 467 | "cielago-{}-{}.json", |
| 468 | store::slugify(&name), |
| 469 | std::process::id() |
| 470 | )); |
| 471 | fs::write(&tmp, &original)?; |
| 472 | |
| 473 | let editor = AppConfig::load().editor_cmd(); |
| 474 | let mut parts = editor.split_whitespace(); |
| 475 | let program = parts.next().unwrap_or("vi"); |
| 476 | let status = ProcessCommand::new(program) |
| 477 | .args(parts) |
| 478 | .arg(&tmp) |
| 479 | .status() |
| 480 | .with_context(|| format!("launching editor {editor:?}"))?; |
| 481 | if !status.success() { |
| 482 | let _ = fs::remove_file(&tmp); |
| 483 | bail!("editor exited with {status}; collection left unchanged"); |
| 484 | } |
| 485 | |
| 486 | let edited = fs::read_to_string(&tmp)?; |
| 487 | if edited == original { |
| 488 | let _ = fs::remove_file(&tmp); |
| 489 | println!("No changes."); |
| 490 | return Ok(()); |
| 491 | } |
| 492 | |
| 493 | let collection: Collection = match serde_json::from_str(&edited) { |
| 494 | Ok(c) => c, |
| 495 | Err(e) => bail!( |
| 496 | "edited JSON is not a valid collection: {e}\n\nYour edits are kept at {}; the saved collection is unchanged.", |
| 497 | tmp.display() |
| 498 | ), |
| 499 | }; |
| 500 | let new_path = store::collection_path(&collection.name)?; |
| 501 | if new_path != path && new_path.exists() { |
| 502 | bail!( |
| 503 | "renaming to {:?} would overwrite the collection at {}.\n\nYour edits are kept at {}; the saved collection is unchanged.", |
| 504 | collection.name, |
| 505 | new_path.display(), |
| 506 | tmp.display() |
| 507 | ); |
| 508 | } |
| 509 | let _ = fs::remove_file(&tmp); |
| 510 | |
| 511 | store::save_collection(&collection)?; |
| 512 | if new_path != path { |
| 513 | fs::remove_file(&path).ok(); |
| 514 | update_last_collection(&name, &collection.name); |
| 515 | println!( |
| 516 | "Saved \"{}\" -> {} (was \"{name}\")", |
| 517 | collection.name, |
| 518 | new_path.display() |
| 519 | ); |
| 520 | } else { |
| 521 | println!("Saved \"{}\" -> {}", collection.name, new_path.display()); |
| 522 | } |
| 523 | Ok(()) |
| 524 | } |
| 525 | |
| 526 | fn cmd_rename(name: &str, new_name: &str) -> Result<()> { |
| 527 | let name = store::resolve_collection(name)?; |
| 528 | let mut collection = store::load_collection(&name)?; |
| 529 | let old_path = store::collection_path(&name)?; |
| 530 | let new_path = store::collection_path(new_name)?; |
| 531 | |
| 532 | if new_path != old_path && new_path.exists() { |
| 533 | bail!( |
| 534 | "a collection already exists at {} — pick another name", |
| 535 | new_path.display() |
| 536 | ); |
| 537 | } |
| 538 | |
| 539 | collection.name = new_name.to_string(); |
| 540 | store::save_collection(&collection)?; |
| 541 | if new_path != old_path { |
| 542 | fs::remove_file(&old_path).ok(); |
| 543 | } |
| 544 | update_last_collection(&name, new_name); |
| 545 | println!( |
| 546 | "Renamed \"{name}\" -> \"{new_name}\" ({})", |
| 547 | new_path.display() |
| 548 | ); |
| 549 | Ok(()) |
| 550 | } |
| 551 | |
| 552 | /// Refresh a collection's routes from a spec without touching the rest of it. |
| 553 | /// Only `requests` is replaced (existing routes are overwritten); auth, |
| 554 | /// variables, servers, active server and view state stay as the user left |
| 555 | /// them. `last_request` is cleared because re-import mints new request ids, so |
| 556 | /// the old pointer would dangle. |
| 557 | async fn cmd_update(name: &str, source: &str) -> Result<()> { |
| 558 | let name = store::resolve_collection(name)?; |
| 559 | let mut collection = store::load_collection(&name)?; |
| 560 | |
| 561 | let doc = openapi::load_spec(source).await?; |
| 562 | // Import under the collection's own name so the throwaway result matches; |
| 563 | // only its `requests` are pulled across. |
| 564 | let imported = openapi::import_spec(&doc, &collection.name, Some(source.to_string())); |
| 565 | |
| 566 | let before = collection.requests.len(); |
| 567 | let after = imported.requests.len(); |
| 568 | collection.replace_requests_from(imported); |
| 569 | collection.spec_source = Some(source.to_string()); |
| 570 | |
| 571 | let path = store::save_collection(&collection)?; |
| 572 | println!( |
| 573 | "Updated collection \"{}\" -> {}", |
| 574 | collection.name, |
| 575 | path.display() |
| 576 | ); |
| 577 | println!(" {before} -> {after} requests"); |
| 578 | Ok(()) |
| 579 | } |
| 580 | |
| 581 | fn cmd_info(name: &str) -> Result<()> { |
| 582 | let name = store::resolve_collection(name)?; |
| 583 | let collection = store::load_collection(&name)?; |
| 584 | let path = store::collection_path(&name)?; |
| 585 | |
| 586 | println!("{}", collection.name); |
| 587 | println!(" file: {}", path.display()); |
| 588 | if let Some(src) = &collection.spec_source { |
| 589 | println!(" spec: {src}"); |
| 590 | } |
| 591 | if collection.servers.is_empty() { |
| 592 | println!(" servers: (none)"); |
| 593 | } else { |
| 594 | for (i, s) in collection.servers.iter().enumerate() { |
| 595 | let marker = if i == collection.active_server { |
| 596 | "*" |
| 597 | } else { |
| 598 | " " |
| 599 | }; |
| 600 | println!(" server{marker} {s}"); |
| 601 | } |
| 602 | } |
| 603 | println!(" requests: {}", collection.requests.len()); |
| 604 | println!(" variables: {}", collection.variables.len()); |
| 605 | match &collection.auth { |
| 606 | Some(auth) => println!( |
| 607 | " auth: oauth2 client-credentials, token url {} ({} client id)", |
| 608 | auth.token_url, |
| 609 | if auth.client_id.is_empty() { |
| 610 | "no" |
| 611 | } else { |
| 612 | "has" |
| 613 | } |
| 614 | ), |
| 615 | None => println!(" auth: none"), |
| 616 | } |
| 617 | |
| 618 | let mut groups: BTreeMap<&str, usize> = BTreeMap::new(); |
| 619 | for r in &collection.requests { |
| 620 | *groups |
| 621 | .entry(r.tags.first().map(String::as_str).unwrap_or("default")) |
| 622 | .or_default() += 1; |
| 623 | } |
| 624 | if !groups.is_empty() { |
| 625 | println!(" groups:"); |
| 626 | for (group, count) in groups { |
| 627 | println!(" {group} ({count})"); |
| 628 | } |
| 629 | } |
| 630 | Ok(()) |
| 631 | } |
| 632 | |
| 633 | fn cmd_path(name: &str) -> Result<()> { |
| 634 | let name = store::resolve_collection(name)?; |
| 635 | println!("{}", store::collection_path(&name)?.display()); |
| 636 | Ok(()) |
| 637 | } |
| 638 | |
| 639 | /// Keep `config.last_collection` pointing at a collection that was renamed. |
| 640 | fn update_last_collection(old: &str, new: &str) { |
| 641 | let mut config = AppConfig::load(); |
| 642 | if config.last_collection.as_deref() == Some(old) { |
| 643 | config.last_collection = Some(new.to_string()); |
| 644 | let _ = config.save(); |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | async fn cmd_open(name: Option<String>) -> Result<()> { |
| 649 | let mut config = AppConfig::load(); |
| 650 | let name = match name.or_else(|| config.last_collection.clone()) { |
| 651 | Some(n) => n, |
| 652 | None => { |
| 653 | let names = store::list_collections()?; |
| 654 | match names.as_slice() { |
| 655 | [] => bail!( |
| 656 | "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>" |
| 657 | ), |
| 658 | [only] => only.clone(), |
| 659 | many => bail!( |
| 660 | "Multiple collections exist; choose one:\n\n cielago open <name>\n\nAvailable: {}", |
| 661 | many.join(", ") |
| 662 | ), |
| 663 | } |
| 664 | } |
| 665 | }; |
| 666 | |
| 667 | let collection = |
| 668 | store::load_collection(&name).with_context(|| format!("loading collection {name:?}"))?; |
| 669 | config.last_collection = Some(collection.name.clone()); |
| 670 | let _ = config.save(); |
| 671 | let path = store::collection_path(&collection.name)?; |
| 672 | app::run(collection, path, config).await |
| 673 | } |