chore: improved walkthrough for import and new collections 365d0738
Steve Simkins · 2026-08-10 22:17 4 file(s) · +152 −109
Cargo.lock +39 −0
254 254
 "clap",
255 255
 "crossterm",
256 256
 "dirs",
257 +
 "inquire",
257 258
 "ratatui",
258 259
 "reqwest",
259 260
 "serde",
586 587
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
587 588
588 589
[[package]]
590 +
name = "dyn-clone"
591 +
version = "1.0.20"
592 +
source = "registry+https://github.com/rust-lang/crates.io-index"
593 +
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
594 +
595 +
[[package]]
589 596
name = "either"
590 597
version = "1.17.0"
591 598
source = "registry+https://github.com/rust-lang/crates.io-index"
777 784
]
778 785
779 786
[[package]]
787 +
name = "fuzzy-matcher"
788 +
version = "0.3.7"
789 +
source = "registry+https://github.com/rust-lang/crates.io-index"
790 +
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
791 +
dependencies = [
792 +
 "thread_local",
793 +
]
794 +
795 +
[[package]]
780 796
name = "generic-array"
781 797
version = "0.14.7"
782 798
source = "registry+https://github.com/rust-lang/crates.io-index"
1115 1131
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1116 1132
dependencies = [
1117 1133
 "rustversion",
1134 +
]
1135 +
1136 +
[[package]]
1137 +
name = "inquire"
1138 +
version = "0.9.4"
1139 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1140 +
checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
1141 +
dependencies = [
1142 +
 "bitflags 2.13.1",
1143 +
 "crossterm",
1144 +
 "dyn-clone",
1145 +
 "fuzzy-matcher",
1146 +
 "unicode-segmentation",
1147 +
 "unicode-width",
1118 1148
]
1119 1149
1120 1150
[[package]]
2551 2581
 "proc-macro2",
2552 2582
 "quote",
2553 2583
 "syn 3.0.3",
2584 +
]
2585 +
2586 +
[[package]]
2587 +
name = "thread_local"
2588 +
version = "1.1.10"
2589 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2590 +
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
2591 +
dependencies = [
2592 +
 "cfg-if",
2554 2593
]
2555 2594
2556 2595
[[package]]
Cargo.toml +1 −0
17 17
clap = { version = "4.6.6", features = ["derive"] }
18 18
crossterm = "0.29.0"
19 19
dirs = "6.0.0"
20 +
inquire = "0.9.4"
20 21
ratatui = "0.30.2"
21 22
reqwest = { version = "0.13.4", default-features = false, features = [
22 23
    "rustls",
README.md +1 −1
61 61
cielago                          # open the last-used collection in the TUI
62 62
cielago open [name]              # open a specific collection
63 63
cielago import <spec|url> [-y]   # import an OpenAPI 3.x spec (-y skips setup)
64 -
cielago new <name> [--server u]  # create an empty collection and open it
64 +
cielago new <name> [-s u] [-y]   # create a collection (walkthrough; -y skips)
65 65
cielago list [-l]                # list collections (-l adds counts + paths)
66 66
cielago info <name>              # servers, counts, auth, groups
67 67
cielago edit <name>              # edit the collection JSON in $EDITOR
src/main.rs +111 −108
5 5
6 6
use anyhow::{Context, Result, bail};
7 7
use clap::{Parser, Subcommand};
8 +
use inquire::{Confirm, Select, Text};
8 9
9 10
use cielago::app;
10 11
use cielago::model::{
56 57
        /// Base URL to start with (becomes the active server)
57 58
        #[arg(long, short)]
58 59
        server: Option<String>,
60 +
        /// Skip interactive setup; open straight into the TUI
61 +
        #[arg(short = 'y', long)]
62 +
        yes: bool,
59 63
    },
60 64
    /// List saved collections
61 65
    List {
94 98
    let cli = Cli::parse();
95 99
    match cli.command {
96 100
        Some(Command::Import { source, name, yes }) => cmd_import(&source, name, yes).await,
97 -
        Some(Command::New { name, server }) => cmd_new(&name, server).await,
101 +
        Some(Command::New { name, server, yes }) => cmd_new(&name, server, yes).await,
98 102
        Some(Command::List { long }) => cmd_list(long),
99 103
        Some(Command::Open { name }) => cmd_open(name).await,
100 104
        Some(Command::Delete { name, force }) => cmd_delete(&name, force),
122 126
    // script or pipe), in which case the spec-derived defaults stand.
123 127
    let interactive = !yes && io::stdin().is_terminal();
124 128
    let name = if interactive {
125 -
        ask_default("\nCollection name", &name)?
129 +
        println!("{BANNER}");
130 +
        Text::new("Collection name").with_default(&name).prompt()?
126 131
    } else {
127 132
        name
128 133
    };
130 135
    let mut collection = openapi::import_spec(&doc, &name, Some(source.to_string()));
131 136
132 137
    if interactive {
133 -
        prompt_auth(&mut collection)?;
134 -
        prompt_server(&mut collection)?;
135 -
        prompt_preferences(&mut collection)?;
138 +
        run_walkthrough(&mut collection)?;
136 139
    }
137 140
138 141
    let path = store::save_collection(&collection)?;
191 194
    }
192 195
}
193 196
194 -
/// Print `prompt`, then read one trimmed line from stdin. An empty reply (or
195 -
/// EOF) comes back as an empty string, which every caller treats as "leave the
196 -
/// current value" or "skip".
197 -
fn ask(prompt: &str) -> Result<String> {
198 -
    print!("{prompt}");
199 -
    io::stdout().flush()?;
200 -
    let mut line = String::new();
201 -
    io::stdin().read_line(&mut line)?;
202 -
    Ok(line.trim().to_string())
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())
203 204
}
204 205
205 -
/// Like [`ask`], but shows `current` as the value kept when the reply is blank.
206 -
fn ask_default(label: &str, current: &str) -> Result<String> {
207 -
    let suffix = if current.is_empty() {
208 -
        String::new()
209 -
    } else {
210 -
        format!(" [{current}]")
211 -
    };
212 -
    let answer = ask(&format!("{label}{suffix}: "))?;
213 -
    Ok(if answer.is_empty() {
214 -
        current.to_string()
215 -
    } else {
216 -
        answer
217 -
    })
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(())
218 223
}
219 224
220 225
/// Choose the collection's auth scheme, then collect that scheme's values. Any
221 226
/// value may be left blank and filled in later from the TUI. A scheme the spec
222 -
/// implied (e.g. oauth2 from a `clientCredentials` flow) is offered as default
223 -
/// and seeds the oauth2 field prompts.
227 +
/// implied (e.g. oauth2 from a `clientCredentials` flow) is pre-selected and
228 +
/// seeds the oauth2 field prompts.
224 229
fn prompt_auth(collection: &mut Collection) -> Result<()> {
225 -
    let detected = collection.auth.as_ref().map(|a| a.kind);
226 -
    println!("\nAuthentication:");
227 -
    println!("  1) none");
228 -
    println!("  2) bearer token");
229 -
    println!("  3) api key");
230 -
    println!("  4) oauth2 client-credentials");
231 -
    let default_choice = match detected {
232 -
        Some(AuthKind::Bearer) => 2,
233 -
        Some(AuthKind::ApiKey) => 3,
234 -
        Some(AuthKind::Oauth2) => 4,
235 -
        None => 1,
236 -
    };
237 -
    let raw = ask(&format!("  choose [1-4] (default {default_choice}): "))?;
238 -
    let choice = if raw.is_empty() {
239 -
        default_choice
240 -
    } else {
241 -
        raw.parse().unwrap_or(default_choice)
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,
242 240
    };
241 +
    let choice = Select::new("Authentication", vec![NONE, BEARER, API_KEY, OAUTH2])
242 +
        .with_starting_cursor(cursor)
243 +
        .prompt()?;
243 244
244 245
    collection.auth = match choice {
245 -
        2 => {
246 -
            let token = ask("  bearer token (blank to set later): ")?;
247 -
            Some(OAuthConfig {
248 -
                kind: AuthKind::Bearer,
249 -
                token,
250 -
                ..Default::default()
251 -
            })
252 -
        }
253 -
        3 => {
254 -
            let header = ask(&format!(
255 -
                "  header name (blank for {DEFAULT_API_KEY_HEADER}): "
256 -
            ))?;
257 -
            let token = ask("  api key value (blank to set later): ")?;
258 -
            Some(OAuthConfig {
259 -
                kind: AuthKind::ApiKey,
260 -
                header,
261 -
                token,
262 -
                ..Default::default()
263 -
            })
264 -
        }
265 -
        4 => {
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 => {
266 260
            // Reuse spec-derived token url/scopes as defaults when present.
267 261
            let mut cfg = collection.auth.clone().unwrap_or_default();
268 262
            cfg.kind = AuthKind::Oauth2;
269 -
            cfg.token_url = ask_default("  token url", &cfg.token_url)?;
270 -
            cfg.client_id = ask("  client id (blank to set later): ")?;
271 -
            cfg.client_secret = ask("  client secret (blank to set later): ")?;
272 -
            let scopes = ask_default("  scopes (space-separated)", &cfg.scopes.join(" "))?;
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(" "))?;
273 267
            cfg.scopes = scopes.split_whitespace().map(String::from).collect();
274 -
            let style = ask("  send credentials via [1] basic header or [2] form body (default 1): ")?;
275 -
            cfg.auth_style = if style == "2" {
268 +
            let style =
269 +
                Select::new("Send credentials via", vec!["basic header", "form body"]).prompt()?;
270 +
            cfg.auth_style = if style == "form body" {
276 271
                AuthStyle::Post
277 272
            } else {
278 273
                AuthStyle::Basic
284 279
    Ok(())
285 280
}
286 281
287 -
/// Pick the active base URL. Spec-derived servers are offered by number; a
288 -
/// typed URL is added and made active; a blank reply keeps the first (or none).
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.
289 284
fn prompt_server(collection: &mut Collection) -> Result<()> {
290 -
    println!("\nServer:");
291 285
    if collection.servers.is_empty() {
292 -
        let url = normalize_server(&ask("  base url (blank for none): ")?);
286 +
        let url = normalize_server(&optional_text("Base URL", "blank for none")?);
293 287
        if !url.is_empty() {
294 288
            collection.servers.push(url);
295 289
            collection.active_server = 0;
297 291
        return Ok(());
298 292
    }
299 293
300 -
    println!("  detected:");
301 -
    for (i, s) in collection.servers.iter().enumerate() {
302 -
        println!("    {}) {s}", i + 1);
303 -
    }
304 -
    let answer = ask("  choose a number, type a new url, or blank for #1: ")?;
305 -
    if answer.is_empty() {
306 -
        collection.active_server = 0;
307 -
    } else if let Ok(n) = answer.parse::<usize>() {
308 -
        if (1..=collection.servers.len()).contains(&n) {
309 -
            collection.active_server = n - 1;
310 -
        }
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 +
        };
311 313
    } else {
312 -
        let url = normalize_server(&answer);
313 -
        let idx = collection
314 +
        collection.active_server = collection
314 315
            .servers
315 316
            .iter()
316 -
            .position(|s| *s == url)
317 -
            .unwrap_or_else(|| {
318 -
                collection.servers.push(url);
319 -
                collection.servers.len() - 1
320 -
            });
321 -
        collection.active_server = idx;
317 +
            .position(|s| *s == choice)
318 +
            .unwrap_or(0);
322 319
    }
323 320
    Ok(())
324 321
}
332 329
/// Collection display preferences: how the sidebar labels requests, and whether
333 330
/// tag groups start collapsed.
334 331
fn prompt_preferences(collection: &mut Collection) -> Result<()> {
335 -
    println!("\nPreferences:");
336 -
    println!("  label requests by:");
337 -
    println!("    1) name");
338 -
    println!("    2) summary");
339 -
    println!("    3) path");
340 -
    let answer = ask("  choose [1-3] (default 1): ")?;
341 -
    collection.label_mode = match answer.as_str() {
342 -
        "2" => LabelMode::Summary,
343 -
        "3" => LabelMode::Path,
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,
344 336
        _ => LabelMode::Name,
345 337
    };
346 338
347 -
    let answer = ask("  collapse tag groups on open? [y/N]: ")?;
348 -
    collection.groups_collapsed = matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes");
339 +
    collection.groups_collapsed = Confirm::new("Collapse tag groups on open?")
340 +
        .with_default(true)
341 +
        .prompt()?;
349 342
    Ok(())
350 343
}
351 344
354 347
/// `store::resolve_collection`, which bails by contract on a name that doesn't
355 348
/// exist yet — and the path check also catches names that collide after
356 349
/// slugify, same as `cielago rename`.
357 -
async fn cmd_new(name: &str, server: Option<String>) -> Result<()> {
350 +
async fn cmd_new(name: &str, server: Option<String>, yes: bool) -> Result<()> {
358 351
    let path = store::collection_path(name)?;
359 352
    if path.exists() {
360 353
        bail!(
372 365
            collection.servers.push(url);
373 366
        }
374 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 +
375 378
    let path = store::save_collection(&collection)?;
376 379
    println!(
377 380
        "Created collection \"{}\" -> {}",