src/main.rs 22.7 K raw
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
}