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 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
}