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