| 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 \"{}\" -> {}", |