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