1use chrono::{DateTime, Local, Utc};
2use colored::Colorize;
3use inquire::{Confirm, MultiSelect, Select};
4use rayon::prelude::*;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashSet};
7use std::fmt;
8use std::fs;
9use std::io::{self, IsTerminal as _};
10use std::path::{Path, PathBuf};
11
12use crate::{
13 CANCELLED_MESSAGE, format_action, format_entry_header, format_error, format_list_hint,
14 format_no_profiles, format_save_before_load, format_unsaved_warning, format_warning,
15 inquire_select_render_config, is_inquire_cancel, is_plain, normalize_error, print_output_block,
16 print_output_block_with_frame, style_text, terminal_width, use_color_stderr, use_color_stdout,
17};
18use crate::{Paths, command_name, copy_atomic, write_atomic};
19use crate::{
20 Tokens, extract_email_and_plan, is_api_key_profile, is_free_plan, is_profile_ready,
21 profile_error, read_tokens, read_tokens_opt, refresh_profile_tokens, require_identity,
22 token_account_id,
23};
24use crate::{
25 UsageLock, fetch_usage_details, format_last_used, format_usage_unavailable, lock_usage,
26 now_seconds, ordered_profiles, read_base_url, usage_unavailable,
27};
28
29const MAX_USAGE_CONCURRENCY: usize = 4;
30
31pub fn save_profile(paths: &Paths, label: Option<String>) -> Result<(), String> {
32 let use_color = use_color_stdout();
33 let mut store = ProfileStore::load(paths)?;
34 let tokens = read_tokens(&paths.auth)?;
35 let id = resolve_save_id(
36 paths,
37 &mut store.usage_map,
38 &mut store.labels,
39 &mut store.profiles_index,
40 &tokens,
41 )?;
42
43 if let Some(label) = label.as_deref() {
44 assign_label(&mut store.labels, label, &id)?;
45 }
46
47 let target = profile_path_for_id(&paths.profiles, &id);
48 copy_profile(&paths.auth, &target, "save profile to")?;
49
50 let now = now_seconds();
51 store.usage_map.insert(id.clone(), now);
52 let label_display = label_for_id(&store.labels, &id);
53 update_profiles_index_entry(
54 &mut store.profiles_index,
55 &id,
56 Some(&tokens),
57 label_display.clone(),
58 now,
59 true,
60 );
61 store.save(paths)?;
62
63 let info = profile_info(Some(&tokens), label_display, true, use_color);
64 let message = if info.email.is_some() {
65 format!("Saved profile {}", info.display)
66 } else {
67 "Saved profile".to_string()
68 };
69 let message = format_action(&message, use_color);
70 print_output_block(&message);
71 Ok(())
72}
73
74pub fn load_profile(paths: &Paths, label: Option<String>) -> Result<(), String> {
75 let use_color_err = use_color_stderr();
76 let use_color_out = use_color_stdout();
77 let no_profiles = format_no_profiles(paths, use_color_err);
78 let (mut snapshot, mut ordered) = load_snapshot_ordered(paths, true, &no_profiles)?;
79
80 if let Some(reason) = unsaved_reason(paths, &snapshot.tokens)? {
81 match prompt_unsaved_load(paths, &reason)? {
82 LoadChoice::SaveAndContinue => {
83 save_profile(paths, None)?;
84 let no_profiles = format_no_profiles(paths, use_color_err);
85 let result = load_snapshot_ordered(paths, true, &no_profiles)?;
86 snapshot = result.0;
87 ordered = result.1;
88 }
89 LoadChoice::ContinueWithoutSaving => {}
90 LoadChoice::Cancel => {
91 return Err(CANCELLED_MESSAGE.to_string());
92 }
93 }
94 }
95
96 let candidates = make_candidates(paths, &snapshot, &ordered);
97 let selected = pick_one("load", label.as_deref(), &snapshot, &candidates)?;
98 let selected_id = selected.id.clone();
99 let selected_display = selected.display.clone();
100
101 match snapshot.tokens.get(&selected_id) {
102 Some(Ok(_)) => {}
103 Some(Err(err)) => {
104 let message = err.strip_prefix("Error: ").unwrap_or(err);
105 return Err(format!("Error: selected profile is invalid. {message}"));
106 }
107 None => {
108 return Err(profile_not_found(use_color_err));
109 }
110 }
111
112 let mut store = ProfileStore::load(paths)?;
113
114 if let Err(err) = sync_current(
115 paths,
116 &mut store.usage_map,
117 &mut store.labels,
118 &mut store.profiles_index,
119 ) {
120 let warning = format_warning(&err, use_color_err);
121 eprintln!("{warning}");
122 }
123
124 let source = profile_path_for_id(&paths.profiles, &selected_id);
125 if !source.is_file() {
126 return Err(profile_not_found(use_color_err));
127 }
128
129 copy_profile(&source, &paths.auth, "load selected profile to")?;
130
131 let now = now_seconds();
132 store.usage_map.insert(selected_id.clone(), now);
133 let label = label_for_id(&store.labels, &selected_id);
134 let tokens = snapshot
135 .tokens
136 .get(&selected_id)
137 .and_then(|result| result.as_ref().ok());
138 update_profiles_index_entry(
139 &mut store.profiles_index,
140 &selected_id,
141 tokens,
142 label,
143 now,
144 true,
145 );
146 store.save(paths)?;
147
148 let message = format_action(&format!("Loaded profile {selected_display}"), use_color_out);
149 print_output_block(&message);
150 Ok(())
151}
152
153pub fn delete_profile(paths: &Paths, yes: bool, label: Option<String>) -> Result<(), String> {
154 let use_color_out = use_color_stdout();
155 let use_color_err = use_color_stderr();
156 let no_profiles = format_no_profiles(paths, use_color_out);
157 let (snapshot, ordered) = match load_snapshot_ordered(paths, true, &no_profiles) {
158 Ok(result) => result,
159 Err(message) => {
160 print_output_block(&message);
161 return Ok(());
162 }
163 };
164
165 let candidates = make_candidates(paths, &snapshot, &ordered);
166 let selections = pick_many("delete", label.as_deref(), &snapshot, &candidates)?;
167 let (selected_ids, displays): (Vec<String>, Vec<String>) = selections
168 .iter()
169 .map(|item| (item.id.clone(), item.display.clone()))
170 .unzip();
171
172 if selected_ids.is_empty() {
173 return Ok(());
174 }
175
176 let mut store = ProfileStore::load(paths)?;
177 if !yes && !confirm_delete_profiles(&displays)? {
178 return Err(CANCELLED_MESSAGE.to_string());
179 }
180
181 for selected in &selected_ids {
182 let target = profile_path_for_id(&paths.profiles, selected);
183 if !target.is_file() {
184 return Err(profile_not_found(use_color_err));
185 }
186 fs::remove_file(&target)
187 .map_err(|err| format!("Error: failed to delete profile: {err}"))?;
188 store.usage_map.remove(selected);
189 remove_labels_for_id(&mut store.labels, selected);
190 store.profiles_index.profiles.remove(selected);
191 if store
192 .profiles_index
193 .active_profile_id
194 .as_deref()
195 .is_some_and(|id| id == selected)
196 {
197 store.profiles_index.active_profile_id = None;
198 }
199 }
200 store.save(paths)?;
201
202 let message = if selected_ids.len() == 1 {
203 format!("Deleted profile {}", displays[0])
204 } else {
205 format!("Deleted {} profiles.", selected_ids.len())
206 };
207 let message = format_action(&message, use_color_out);
208 print_output_block(&message);
209 Ok(())
210}
211
212pub fn list_profiles(
213 paths: &Paths,
214 show_usage: bool,
215 show_last_used: bool,
216 allow_plain_spacing: bool,
217 frame_with_separator: bool,
218) -> Result<(), String> {
219 let snapshot = load_snapshot(paths, false)?;
220 let usage_map = &snapshot.usage_map;
221 let current_saved_id = current_saved_id(paths, usage_map, &snapshot.tokens);
222 let ctx = ListCtx::new(paths, show_usage);
223
224 let ordered = ordered_profiles(usage_map);
225 let separator = separator_line(2);
226 let frame_separator = if frame_with_separator {
227 separator_line(0)
228 } else {
229 None
230 };
231 let has_saved = !ordered.is_empty();
232 if !has_saved {
233 if !render_current(
234 paths,
235 current_saved_id.as_deref(),
236 &snapshot.labels,
237 &snapshot.tokens,
238 &snapshot.usage_map,
239 false,
240 &ctx,
241 )? {
242 let message = format_no_profiles(paths, ctx.use_color);
243 print_output_block(&message);
244 }
245 return Ok(());
246 }
247
248 let filtered: Vec<(String, u64)> = ordered
249 .into_iter()
250 .filter(|(id, _)| current_saved_id.as_deref() != Some(id.as_str()))
251 .collect();
252 let list_entries = make_entries(&filtered, &snapshot, None, &ctx);
253
254 let mut lines = Vec::new();
255 if let Some(entry) = make_current(
256 paths,
257 current_saved_id.as_deref(),
258 &snapshot.labels,
259 &snapshot.tokens,
260 &snapshot.usage_map,
261 &ctx,
262 ) {
263 lines.extend(render_entries(
264 &[entry],
265 show_last_used,
266 &ctx,
267 separator.as_deref(),
268 allow_plain_spacing,
269 ));
270 if !list_entries.is_empty() {
271 push_separator(&mut lines, separator.as_deref(), allow_plain_spacing);
272 }
273 }
274 lines.extend(render_entries(
275 &list_entries,
276 show_last_used,
277 &ctx,
278 separator.as_deref(),
279 allow_plain_spacing,
280 ));
281 let output = lines.join("\n");
282 if frame_with_separator
283 && !is_plain()
284 && let Some(frame_separator) = frame_separator.as_ref()
285 {
286 print_output_block_with_frame(&output, frame_separator);
287 return Ok(());
288 }
289 print_output_block(&output);
290 Ok(())
291}
292
293pub fn status_profiles(paths: &Paths, all: bool) -> Result<(), String> {
294 if all {
295 return list_profiles(paths, true, true, true, true);
296 }
297 let snapshot = load_snapshot(paths, false).ok();
298 let current_saved_id = snapshot
299 .as_ref()
300 .and_then(|snap| current_saved_id(paths, &snap.usage_map, &snap.tokens));
301 let ctx = ListCtx::new(paths, true);
302 let empty_labels = Labels::new();
303 let labels = snapshot
304 .as_ref()
305 .map(|snap| &snap.labels)
306 .unwrap_or(&empty_labels);
307 let empty_tokens = BTreeMap::new();
308 let empty_usage = BTreeMap::new();
309 let tokens_map = snapshot
310 .as_ref()
311 .map(|snap| &snap.tokens)
312 .unwrap_or(&empty_tokens);
313 let usage_map = snapshot
314 .as_ref()
315 .map(|snap| &snap.usage_map)
316 .unwrap_or(&empty_usage);
317 if !render_current(
318 paths,
319 current_saved_id.as_deref(),
320 labels,
321 tokens_map,
322 usage_map,
323 false,
324 &ctx,
325 )? {
326 let message = format_no_profiles(paths, ctx.use_color);
327 print_output_block(&message);
328 }
329 Ok(())
330}
331
332pub fn status_label(paths: &Paths, label: &str) -> Result<(), String> {
333 let snapshot = load_snapshot(paths, false)?;
334 let id = resolve_label_id(&snapshot.labels, label)?;
335 let current_saved_id = current_saved_id(paths, &snapshot.usage_map, &snapshot.tokens);
336 let ctx = ListCtx::new(paths, true);
337 let separator = separator_line(2);
338 let is_current = current_saved_id.as_deref() == Some(id.as_str());
339 let last_used = if is_current {
340 String::new()
341 } else {
342 snapshot
343 .usage_map
344 .get(&id)
345 .copied()
346 .map(format_last_used)
347 .unwrap_or_default()
348 };
349 let label = label_for_id(&snapshot.labels, &id);
350 let profile_path = ctx.profiles_dir.join(format!("{id}.json"));
351 let entry = make_entry(
352 last_used,
353 label,
354 snapshot.tokens.get(&id),
355 snapshot.index.profiles.get(&id),
356 &profile_path,
357 &ctx,
358 is_current,
359 );
360 let lines = render_entries(&[entry], true, &ctx, separator.as_deref(), true);
361 print_output_block(&lines.join("\n"));
362 Ok(())
363}
364
365pub fn sync_current_readonly(paths: &Paths) -> Result<(), String> {
366 if !paths.auth.is_file() {
367 return Ok(());
368 }
369 let snapshot = match load_snapshot(paths, false) {
370 Ok(snapshot) => snapshot,
371 Err(_) => return Ok(()),
372 };
373 let Some(id) = current_saved_id(paths, &snapshot.usage_map, &snapshot.tokens) else {
374 return Ok(());
375 };
376 let target = profile_path_for_id(&paths.profiles, &id);
377 if !target.is_file() {
378 return Ok(());
379 }
380 sync_profile(paths, &target)?;
381 Ok(())
382}
383
384pub type Labels = BTreeMap<String, String>;
385
386const PROFILES_INDEX_VERSION: u8 = 1;
387
388#[derive(Debug, Serialize, Deserialize)]
389pub(crate) struct ProfilesIndex {
390 #[serde(default = "profiles_index_version")]
391 version: u8,
392 #[serde(default)]
393 active_profile_id: Option<String>,
394 #[serde(default)]
395 profiles: BTreeMap<String, ProfileIndexEntry>,
396 #[serde(default)]
397 pub(crate) update_cache: Option<UpdateCache>,
398}
399
400impl Default for ProfilesIndex {
401 fn default() -> Self {
402 Self {
403 version: PROFILES_INDEX_VERSION,
404 active_profile_id: None,
405 profiles: BTreeMap::new(),
406 update_cache: None,
407 }
408 }
409}
410
411#[derive(Debug, Clone, Default, Serialize, Deserialize)]
412struct ProfileIndexEntry {
413 #[serde(default)]
414 account_id: Option<String>,
415 #[serde(default)]
416 email: Option<String>,
417 #[serde(default)]
418 plan: Option<String>,
419 #[serde(default)]
420 label: Option<String>,
421 #[serde(default)]
422 added_at: u64,
423 #[serde(default)]
424 last_used: Option<u64>,
425 #[serde(default)]
426 is_api_key: bool,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub(crate) struct UpdateCache {
431 #[serde(default)]
432 pub(crate) latest_version: String,
433 #[serde(default = "update_cache_checked_default")]
434 pub(crate) last_checked_at: DateTime<Utc>,
435 #[serde(default)]
436 pub(crate) dismissed_version: Option<String>,
437 #[serde(default)]
438 pub(crate) last_prompted_at: Option<DateTime<Utc>>,
439}
440
441fn update_cache_checked_default() -> DateTime<Utc> {
442 DateTime::<Utc>::from_timestamp(0, 0).unwrap_or_else(Utc::now)
443}
444
445fn profiles_index_version() -> u8 {
446 PROFILES_INDEX_VERSION
447}
448
449pub(crate) fn read_profiles_index(paths: &Paths) -> Result<ProfilesIndex, String> {
450 if !paths.profiles_index.exists() {
451 return Ok(ProfilesIndex::default());
452 }
453 let contents = fs::read_to_string(&paths.profiles_index).map_err(|err| {
454 format!(
455 "Error: cannot read profiles index file {}: {err}",
456 paths.profiles_index.display()
457 )
458 })?;
459 let index: ProfilesIndex = serde_json::from_str(&contents).map_err(|_| {
460 format!(
461 "Error: profiles index file {} is invalid JSON",
462 paths.profiles_index.display()
463 )
464 })?;
465 Ok(index)
466}
467
468pub(crate) fn read_profiles_index_relaxed(paths: &Paths) -> ProfilesIndex {
469 match read_profiles_index(paths) {
470 Ok(index) => index,
471 Err(err) => {
472 let normalized = normalize_error(&err);
473 let warning = format_warning(&normalized, use_color_stderr());
474 eprintln!("{warning}");
475 ProfilesIndex::default()
476 }
477 }
478}
479
480pub(crate) fn write_profiles_index(paths: &Paths, index: &ProfilesIndex) -> Result<(), String> {
481 let json = serde_json::to_string_pretty(index)
482 .map_err(|err| format!("Error: failed to serialize profiles index: {err}"))?;
483 write_atomic(&paths.profiles_index, format!("{json}\n").as_bytes())
484 .map_err(|err| format!("Error: failed to write profiles index file: {err}"))
485}
486
487fn prune_profiles_index(index: &mut ProfilesIndex, profiles_dir: &Path) -> Result<(), String> {
488 let ids = collect_profile_ids(profiles_dir)?;
489 index.profiles.retain(|id, _| ids.contains(id));
490 if index
491 .active_profile_id
492 .as_deref()
493 .is_some_and(|id| !ids.contains(id))
494 {
495 index.active_profile_id = None;
496 }
497 Ok(())
498}
499
500fn sync_profiles_index(
501 index: &mut ProfilesIndex,
502 usage_map: &BTreeMap<String, u64>,
503 labels: &Labels,
504) {
505 for (id, entry) in index.profiles.iter_mut() {
506 entry.last_used = usage_map.get(id).copied();
507 entry.label = label_for_id(labels, id);
508 }
509}
510
511fn labels_from_index(index: &ProfilesIndex) -> Labels {
512 let mut labels = Labels::new();
513 for (id, entry) in &index.profiles {
514 let Some(label) = entry.label.as_deref() else {
515 continue;
516 };
517 let trimmed = label.trim();
518 if trimmed.is_empty() || labels.contains_key(trimmed) {
519 continue;
520 }
521 labels.insert(trimmed.to_string(), id.clone());
522 }
523 labels
524}
525
526fn usage_map_from_index(index: &ProfilesIndex, ids: &HashSet<String>) -> BTreeMap<String, u64> {
527 let mut usage_map = BTreeMap::new();
528 for id in ids {
529 usage_map.insert(id.clone(), 0);
530 }
531 for (id, entry) in &index.profiles {
532 if !ids.contains(id) {
533 continue;
534 }
535 let Some(last_used) = entry.last_used else {
536 continue;
537 };
538 let current = usage_map.entry(id.clone()).or_insert(0);
539 if last_used > *current {
540 *current = last_used;
541 }
542 }
543 usage_map
544}
545
546fn update_profiles_index_entry(
547 index: &mut ProfilesIndex,
548 id: &str,
549 tokens: Option<&Tokens>,
550 label: Option<String>,
551 now: u64,
552 set_active: bool,
553) {
554 let entry = index.profiles.entry(id.to_string()).or_default();
555 if entry.added_at == 0 {
556 entry.added_at = now;
557 }
558 if let Some(tokens) = tokens {
559 let (email, plan) = extract_email_and_plan(tokens);
560 entry.email = email;
561 entry.plan = plan;
562 entry.account_id = token_account_id(tokens).map(str::to_string);
563 entry.is_api_key = is_api_key_profile(tokens);
564 }
565 if let Some(label) = label {
566 entry.label = Some(label);
567 }
568 entry.last_used = Some(now);
569 if set_active {
570 index.active_profile_id = Some(id.to_string());
571 }
572}
573
574pub fn read_labels(paths: &Paths) -> Result<Labels, String> {
575 let index = read_profiles_index(paths)?;
576 Ok(labels_from_index(&index))
577}
578
579pub fn write_labels(paths: &Paths, labels: &Labels) -> Result<(), String> {
580 let normalized = normalize_labels(labels);
581 let mut index = read_profiles_index_relaxed(paths);
582 for (id, entry) in index.profiles.iter_mut() {
583 entry.label = label_for_id(&normalized, id);
584 }
585 for (label, id) in &normalized {
586 index.profiles.entry(id.clone()).or_default().label = Some(label.clone());
587 }
588 write_profiles_index(paths, &index)
589}
590
591pub fn prune_labels(labels: &mut Labels, profiles_dir: &Path) {
592 labels.retain(|_, id| profile_path_for_id(profiles_dir, id).is_file());
593}
594
595pub fn assign_label(labels: &mut Labels, label: &str, id: &str) -> Result<(), String> {
596 let trimmed = trim_label(label)?;
597 if let Some(existing) = labels.get(trimmed) {
598 if existing == id {
599 return Ok(());
600 }
601 return Err(format!(
602 "Error: label '{trimmed}' already exists. {}",
603 format_list_hint(use_color_stderr())
604 ));
605 }
606 labels.insert(trimmed.to_string(), id.to_string());
607 Ok(())
608}
609
610pub fn remove_labels_for_id(labels: &mut Labels, id: &str) {
611 labels.retain(|_, value| value != id);
612}
613
614pub fn label_for_id(labels: &Labels, id: &str) -> Option<String> {
615 labels.iter().find_map(|(label, value)| {
616 if value == id {
617 Some(label.clone())
618 } else {
619 None
620 }
621 })
622}
623
624pub fn resolve_label_id(labels: &Labels, label: &str) -> Result<String, String> {
625 let trimmed = trim_label(label)?;
626 labels.get(trimmed).cloned().ok_or_else(|| {
627 format!(
628 "Error: label '{trimmed}' was not found. {}",
629 format_list_hint(use_color_stderr())
630 )
631 })
632}
633
634pub fn profile_files(profiles_dir: &Path) -> Result<Vec<PathBuf>, String> {
635 let mut files = Vec::new();
636 if !profiles_dir.exists() {
637 return Ok(files);
638 }
639 let entries = fs::read_dir(profiles_dir)
640 .map_err(|err| format!("Error: cannot read profiles directory: {err}"))?;
641 for entry in entries {
642 let entry = entry.map_err(|err| format!("Error: cannot read profiles directory: {err}"))?;
643 let path = entry.path();
644 if !is_profile_file(&path) {
645 continue;
646 }
647 files.push(path);
648 }
649 Ok(files)
650}
651
652pub fn profile_id_from_path(path: &Path) -> Option<String> {
653 path.file_stem()
654 .and_then(|value| value.to_str())
655 .filter(|stem| !stem.is_empty())
656 .map(|stem| stem.to_string())
657}
658
659pub fn profile_path_for_id(profiles_dir: &Path, id: &str) -> PathBuf {
660 profiles_dir.join(format!("{id}.json"))
661}
662
663pub fn collect_profile_ids(profiles_dir: &Path) -> Result<HashSet<String>, String> {
664 let mut ids = HashSet::new();
665 for path in profile_files(profiles_dir)? {
666 if let Some(stem) = profile_id_from_path(&path) {
667 ids.insert(stem);
668 }
669 }
670 Ok(ids)
671}
672
673pub fn load_profile_tokens_map(
674 paths: &Paths,
675) -> Result<BTreeMap<String, Result<Tokens, String>>, String> {
676 let mut map = BTreeMap::new();
677 let mut removed_ids: Vec<String> = Vec::new();
678 for path in profile_files(&paths.profiles)? {
679 let Some(stem) = profile_id_from_path(&path) else {
680 continue;
681 };
682 match read_tokens(&path) {
683 Ok(tokens) => {
684 map.insert(stem, Ok(tokens));
685 }
686 Err(err) => {
687 let id = stem.clone();
688 if let Err(remove_err) = fs::remove_file(&path) {
689 let message = format!(
690 "Error: failed to remove invalid profile {}: {remove_err}",
691 path.display()
692 );
693 map.insert(id, Err(message));
694 } else {
695 removed_ids.push(id);
696 let summary = normalize_error(&err);
697 eprintln!(
698 "{}",
699 format_warning(
700 &format!("Removed invalid profile {} ({summary})", path.display()),
701 use_color_stderr()
702 )
703 );
704 }
705 }
706 }
707 }
708 if !removed_ids.is_empty() {
709 let mut index = read_profiles_index_relaxed(paths);
710 for id in &removed_ids {
711 index.profiles.remove(id);
712 if index
713 .active_profile_id
714 .as_deref()
715 .is_some_and(|active| active == id)
716 {
717 index.active_profile_id = None;
718 }
719 }
720 let _ = write_profiles_index(paths, &index);
721 }
722 Ok(map)
723}
724
725pub(crate) fn resolve_save_id(
726 paths: &Paths,
727 map: &mut BTreeMap<String, u64>,
728 labels: &mut Labels,
729 profiles_index: &mut ProfilesIndex,
730 tokens: &Tokens,
731) -> Result<String, String> {
732 let (account_id, email, plan) = require_identity(tokens)?;
733 let (desired_base, desired, candidates) =
734 desired_candidates(paths, &account_id, &email, &plan)?;
735 if has_usage_signal(&candidates, map)
736 && let Some(primary) = pick_primary(&candidates, map).filter(|primary| primary != &desired)
737 {
738 return rename_profile_id(
739 paths,
740 map,
741 labels,
742 profiles_index,
743 &primary,
744 &desired_base,
745 &account_id,
746 );
747 }
748 Ok(desired)
749}
750
751pub(crate) fn resolve_sync_id(
752 paths: &Paths,
753 map: &mut BTreeMap<String, u64>,
754 labels: &mut Labels,
755 profiles_index: &mut ProfilesIndex,
756 tokens: &Tokens,
757) -> Result<Option<String>, String> {
758 let Ok((account_id, email, plan)) = require_identity(tokens) else {
759 return Ok(None);
760 };
761 let (desired_base, desired, candidates) =
762 desired_candidates(paths, &account_id, &email, &plan)?;
763 if !has_usage_signal(&candidates, map) {
764 if candidates.len() == 1 {
765 return Ok(candidates.first().cloned());
766 }
767 if candidates.iter().any(|id| id == &desired) {
768 return Ok(Some(desired));
769 }
770 return Ok(None);
771 }
772 let Some(primary) = pick_primary(&candidates, map) else {
773 return Ok(None);
774 };
775 if primary != desired {
776 let renamed = rename_profile_id(
777 paths,
778 map,
779 labels,
780 profiles_index,
781 &primary,
782 &desired_base,
783 &account_id,
784 )?;
785 return Ok(Some(renamed));
786 }
787 Ok(Some(primary))
788}
789
790pub(crate) fn cached_profile_ids(
791 tokens_map: &BTreeMap<String, Result<Tokens, String>>,
792 account_id: &str,
793 email: Option<&str>,
794) -> Vec<String> {
795 tokens_map
796 .iter()
797 .filter_map(|(id, result)| {
798 result
799 .as_ref()
800 .ok()
801 .filter(|tokens| matches_account(tokens, account_id, email))
802 .map(|_| id.clone())
803 })
804 .collect()
805}
806
807pub(crate) fn pick_primary(
808 candidates: &[String],
809 usage_map: &BTreeMap<String, u64>,
810) -> Option<String> {
811 let mut best: Option<(String, u64)> = None;
812 for candidate in candidates {
813 if let Some(ts) = usage_map.get(candidate).filter(|ts| {
814 best.as_ref()
815 .map(|(_, best_ts)| *ts > best_ts)
816 .unwrap_or(true)
817 }) {
818 best = Some((candidate.clone(), *ts));
819 }
820 }
821 best.map(|(id, _)| id)
822}
823
824fn has_usage_signal(candidates: &[String], usage_map: &BTreeMap<String, u64>) -> bool {
825 candidates
826 .iter()
827 .any(|id| usage_map.get(id).copied().unwrap_or(0) > 0)
828}
829
830fn desired_candidates(
831 paths: &Paths,
832 account_id: &str,
833 email: &str,
834 plan: &str,
835) -> Result<(String, String, Vec<String>), String> {
836 let (desired_base, desired) = desired_id(paths, account_id, email, plan);
837 let candidates = scan_profile_ids(&paths.profiles, account_id, Some(email))?;
838 Ok((desired_base, desired, candidates))
839}
840
841fn desired_id(paths: &Paths, account_id: &str, email: &str, plan: &str) -> (String, String) {
842 let desired_base = profile_base(email, plan);
843 let desired = unique_id(&desired_base, account_id, &paths.profiles);
844 (desired_base, desired)
845}
846
847fn profile_base(email: &str, plan_label: &str) -> String {
848 let email = sanitize_part(email);
849 let plan = sanitize_part(plan_label);
850 let email = if email.is_empty() {
851 "unknown".to_string()
852 } else {
853 email
854 };
855 let plan = if plan.is_empty() {
856 "unknown".to_string()
857 } else {
858 plan
859 };
860 format!("{email}-{plan}")
861}
862
863fn sanitize_part(value: &str) -> String {
864 let mut out = String::with_capacity(value.len());
865 let mut last_dash = false;
866 for ch in value.chars() {
867 let next = if ch.is_ascii_alphanumeric() {
868 Some(ch.to_ascii_lowercase())
869 } else if matches!(ch, '@' | '.' | '-' | '_' | '+') {
870 Some(ch)
871 } else {
872 Some('-')
873 };
874 if let Some(next) = next {
875 if next == '-' {
876 if last_dash {
877 continue;
878 }
879 last_dash = true;
880 } else {
881 last_dash = false;
882 }
883 out.push(next);
884 }
885 }
886 out.trim_matches('-').to_string()
887}
888
889fn unique_id(base: &str, account_id: &str, profiles_dir: &Path) -> String {
890 let mut candidate = base.to_string();
891 let suffix = short_account_suffix(account_id);
892 let mut attempts = 0usize;
893 loop {
894 let path = profile_path_for_id(profiles_dir, &candidate);
895 if !path.is_file() {
896 return candidate;
897 }
898 if read_tokens(&path)
899 .ok()
900 .is_some_and(|tokens| token_account_id(&tokens) == Some(account_id))
901 {
902 return candidate;
903 }
904 attempts += 1;
905 if attempts == 1 {
906 candidate = format!("{base}-{suffix}");
907 } else {
908 candidate = format!("{base}-{suffix}-{attempts}");
909 }
910 }
911}
912
913fn short_account_suffix(account_id: &str) -> String {
914 account_id.chars().take(6).collect()
915}
916
917fn scan_profile_ids(
918 profiles_dir: &Path,
919 account_id: &str,
920 email: Option<&str>,
921) -> Result<Vec<String>, String> {
922 let mut matches = Vec::new();
923 for path in profile_files(profiles_dir)? {
924 let Ok(tokens) = read_tokens(&path) else {
925 continue;
926 };
927 if !matches_account(&tokens, account_id, email) {
928 continue;
929 }
930 if let Some(stem) = profile_id_from_path(&path) {
931 matches.push(stem);
932 }
933 }
934 Ok(matches)
935}
936
937fn matches_account(tokens: &Tokens, account_id: &str, email: Option<&str>) -> bool {
938 if token_account_id(tokens) != Some(account_id) {
939 return false;
940 }
941 if let Some(expected) = email {
942 let token_email = extract_email_and_plan(tokens).0;
943 if token_email.as_deref() != Some(expected) {
944 return false;
945 }
946 }
947 true
948}
949
950fn rename_profile_id(
951 paths: &Paths,
952 map: &mut BTreeMap<String, u64>,
953 labels: &mut Labels,
954 profiles_index: &mut ProfilesIndex,
955 from: &str,
956 target_base: &str,
957 account_id: &str,
958) -> Result<String, String> {
959 let desired = unique_id(target_base, account_id, &paths.profiles);
960 if from == desired {
961 return Ok(desired);
962 }
963 let from_path = profile_path_for_id(&paths.profiles, from);
964 let to_path = profile_path_for_id(&paths.profiles, &desired);
965 if !from_path.is_file() {
966 return Err(format!("Profile {from} not found"));
967 }
968 fs::rename(&from_path, &to_path)
969 .map_err(|err| format!("Error: failed to rename profile {from}: {err}"))?;
970 if let Some(ts) = map.remove(from) {
971 map.insert(desired.clone(), ts);
972 }
973 labels.retain(|_, value| value != from);
974 if let Some(entry) = profiles_index.profiles.remove(from) {
975 profiles_index.profiles.insert(desired.clone(), entry);
976 }
977 if profiles_index
978 .active_profile_id
979 .as_deref()
980 .is_some_and(|id| id == from)
981 {
982 profiles_index.active_profile_id = Some(desired.clone());
983 }
984 Ok(desired)
985}
986
987pub(crate) struct Snapshot {
988 pub(crate) usage_map: BTreeMap<String, u64>,
989 pub(crate) labels: Labels,
990 pub(crate) tokens: BTreeMap<String, Result<Tokens, String>>,
991 pub(crate) index: ProfilesIndex,
992}
993
994pub(crate) fn sync_current(
995 paths: &Paths,
996 map: &mut BTreeMap<String, u64>,
997 labels: &mut Labels,
998 index: &mut ProfilesIndex,
999) -> Result<(), String> {
1000 let Some(tokens) = read_tokens_opt(&paths.auth) else {
1001 return Ok(());
1002 };
1003 let id = match resolve_sync_id(paths, map, labels, index, &tokens)? {
1004 Some(id) => id,
1005 None => return Ok(()),
1006 };
1007 let target = profile_path_for_id(&paths.profiles, &id);
1008 sync_profile(paths, &target)?;
1009 let now = now_seconds();
1010 map.insert(id.clone(), now);
1011 let label = label_for_id(labels, &id);
1012 update_profiles_index_entry(index, &id, Some(&tokens), label, now, true);
1013 Ok(())
1014}
1015
1016fn sync_profile(paths: &Paths, target: &Path) -> Result<(), String> {
1017 copy_atomic(&paths.auth, target)
1018 .map_err(|err| format!("Error: failed to sync current profile: {err}"))?;
1019 Ok(())
1020}
1021
1022pub(crate) fn load_snapshot(paths: &Paths, strict_labels: bool) -> Result<Snapshot, String> {
1023 let _lock = lock_usage(paths)?;
1024 let tokens = load_profile_tokens_map(paths)?;
1025 let ids: HashSet<String> = tokens.keys().cloned().collect();
1026 let mut index = if strict_labels {
1027 read_profiles_index(paths)?
1028 } else {
1029 read_profiles_index_relaxed(paths)
1030 };
1031 let _ = prune_profiles_index(&mut index, &paths.profiles);
1032 for id in &ids {
1033 index.profiles.entry(id.clone()).or_default();
1034 }
1035 let usage_map = usage_map_from_index(&index, &ids);
1036 let labels = labels_from_index(&index);
1037
1038 Ok(Snapshot {
1039 usage_map,
1040 labels,
1041 tokens,
1042 index,
1043 })
1044}
1045
1046pub(crate) fn unsaved_reason(
1047 paths: &Paths,
1048 tokens_map: &BTreeMap<String, Result<Tokens, String>>,
1049) -> Result<Option<String>, String> {
1050 let Some(tokens) = read_tokens_opt(&paths.auth) else {
1051 return Ok(None);
1052 };
1053 let Some(account_id) = token_account_id(&tokens) else {
1054 return Ok(None);
1055 };
1056 let (email, _) = extract_email_and_plan(&tokens);
1057 let Some(email) = email else {
1058 return Ok(None);
1059 };
1060
1061 let candidates = cached_profile_ids(tokens_map, account_id, Some(&email));
1062 if candidates.is_empty() {
1063 return Ok(Some("no saved profile matches auth.json".to_string()));
1064 }
1065 Ok(None)
1066}
1067
1068pub(crate) fn current_saved_id(
1069 paths: &Paths,
1070 usage_map: &BTreeMap<String, u64>,
1071 tokens_map: &BTreeMap<String, Result<Tokens, String>>,
1072) -> Option<String> {
1073 let tokens = read_tokens_opt(&paths.auth)?;
1074 let account_id = token_account_id(&tokens)?;
1075 let (email, _) = extract_email_and_plan(&tokens);
1076 let email = email.as_deref()?;
1077 let candidates = cached_profile_ids(tokens_map, account_id, Some(email));
1078 pick_primary(&candidates, usage_map)
1079}
1080
1081pub(crate) struct ProfileStore {
1082 _lock: UsageLock,
1083 pub(crate) usage_map: BTreeMap<String, u64>,
1084 pub(crate) labels: Labels,
1085 pub(crate) profiles_index: ProfilesIndex,
1086}
1087
1088impl ProfileStore {
1089 pub(crate) fn load(paths: &Paths) -> Result<Self, String> {
1090 let lock = lock_usage(paths)?;
1091 let mut profiles_index = read_profiles_index_relaxed(paths);
1092 let _ = prune_profiles_index(&mut profiles_index, &paths.profiles);
1093 let ids = collect_profile_ids(&paths.profiles)?;
1094 for id in &ids {
1095 profiles_index.profiles.entry(id.clone()).or_default();
1096 }
1097 let usage_map = usage_map_from_index(&profiles_index, &ids);
1098 let labels = labels_from_index(&profiles_index);
1099 Ok(Self {
1100 _lock: lock,
1101 usage_map,
1102 labels,
1103 profiles_index,
1104 })
1105 }
1106
1107 pub(crate) fn save(&mut self, paths: &Paths) -> Result<(), String> {
1108 prune_labels(&mut self.labels, &paths.profiles);
1109 prune_profiles_index(&mut self.profiles_index, &paths.profiles)?;
1110 sync_profiles_index(&mut self.profiles_index, &self.usage_map, &self.labels);
1111 write_profiles_index(paths, &self.profiles_index)?;
1112 Ok(())
1113 }
1114}
1115
1116fn profile_not_found(use_color: bool) -> String {
1117 format!(
1118 "Selected profile not found. {}",
1119 format_list_hint(use_color)
1120 )
1121}
1122
1123fn load_snapshot_ordered(
1124 paths: &Paths,
1125 strict_labels: bool,
1126 no_profiles_message: &str,
1127) -> Result<(Snapshot, Vec<(String, u64)>), String> {
1128 let snapshot = load_snapshot(paths, strict_labels)?;
1129 let ordered = ordered_profiles(&snapshot.usage_map);
1130 if ordered.is_empty() {
1131 return Err(no_profiles_message.to_string());
1132 }
1133 Ok((snapshot, ordered))
1134}
1135
1136fn copy_profile(source: &Path, dest: &Path, context: &str) -> Result<(), String> {
1137 copy_atomic(source, dest)
1138 .map_err(|err| format!("Error: failed to {context} {}: {err}", dest.display()))?;
1139 Ok(())
1140}
1141
1142fn make_candidates(
1143 paths: &Paths,
1144 snapshot: &Snapshot,
1145 ordered: &[(String, u64)],
1146) -> Vec<Candidate> {
1147 let current_saved = current_saved_id(paths, &snapshot.usage_map, &snapshot.tokens);
1148 build_candidates(ordered, snapshot, current_saved.as_deref())
1149}
1150
1151fn pick_one(
1152 action: &str,
1153 label: Option<&str>,
1154 snapshot: &Snapshot,
1155 candidates: &[Candidate],
1156) -> Result<Candidate, String> {
1157 if let Some(label) = label {
1158 select_by_label(label, &snapshot.labels, candidates)
1159 } else {
1160 require_tty(action)?;
1161 select_single_profile("", candidates)
1162 }
1163}
1164
1165fn pick_many(
1166 action: &str,
1167 label: Option<&str>,
1168 snapshot: &Snapshot,
1169 candidates: &[Candidate],
1170) -> Result<Vec<Candidate>, String> {
1171 if let Some(label) = label {
1172 Ok(vec![select_by_label(label, &snapshot.labels, candidates)?])
1173 } else {
1174 require_tty(action)?;
1175 select_multiple_profiles("", candidates)
1176 }
1177}
1178
1179pub(crate) struct ProfileInfo {
1180 pub(crate) display: String,
1181 pub(crate) email: Option<String>,
1182 pub(crate) plan: Option<String>,
1183 pub(crate) is_free: bool,
1184}
1185
1186pub(crate) fn profile_info(
1187 tokens: Option<&Tokens>,
1188 label: Option<String>,
1189 is_current: bool,
1190 use_color: bool,
1191) -> ProfileInfo {
1192 profile_info_with_fallback(tokens, None, label, is_current, use_color)
1193}
1194
1195fn profile_info_with_fallback(
1196 tokens: Option<&Tokens>,
1197 fallback: Option<&ProfileIndexEntry>,
1198 label: Option<String>,
1199 is_current: bool,
1200 use_color: bool,
1201) -> ProfileInfo {
1202 let (email, plan) = if let Some(tokens) = tokens {
1203 extract_email_and_plan(tokens)
1204 } else if let Some(entry) = fallback {
1205 (entry.email.clone(), entry.plan.clone())
1206 } else {
1207 (None, None)
1208 };
1209 let is_free = is_free_plan(plan.as_deref());
1210 let display =
1211 crate::format_profile_display(email.clone(), plan.clone(), label, is_current, use_color);
1212 ProfileInfo {
1213 display,
1214 email,
1215 plan,
1216 is_free,
1217 }
1218}
1219
1220#[derive(Debug)]
1221pub(crate) enum LoadChoice {
1222 SaveAndContinue,
1223 ContinueWithoutSaving,
1224 Cancel,
1225}
1226
1227pub(crate) fn prompt_unsaved_load(paths: &Paths, reason: &str) -> Result<LoadChoice, String> {
1228 let is_tty = io::stdin().is_terminal();
1229 if !is_tty {
1230 let hint = format_save_before_load(paths, use_color_stderr());
1231 return Err(format!("Error: current profile is not saved. {hint}"));
1232 }
1233 let selection = Select::new(
1234 "",
1235 vec![
1236 "Save current profile and continue",
1237 "Continue without saving",
1238 "Cancel",
1239 ],
1240 )
1241 .with_render_config(inquire_select_render_config())
1242 .prompt();
1243 prompt_unsaved_load_with(paths, reason, is_tty, selection)
1244}
1245
1246fn prompt_unsaved_load_with(
1247 paths: &Paths,
1248 reason: &str,
1249 is_tty: bool,
1250 selection: Result<&str, inquire::error::InquireError>,
1251) -> Result<LoadChoice, String> {
1252 if !is_tty {
1253 let hint = format_save_before_load(paths, use_color_stderr());
1254 return Err(format!("Error: current profile is not saved. {hint}"));
1255 }
1256 let warning = format_warning(
1257 &format!("Current profile is not saved ({reason})."),
1258 use_color_stderr(),
1259 );
1260 eprintln!("{warning}");
1261 match selection {
1262 Ok("Save current profile and continue") => Ok(LoadChoice::SaveAndContinue),
1263 Ok("Continue without saving") => Ok(LoadChoice::ContinueWithoutSaving),
1264 Ok(_) => Ok(LoadChoice::Cancel),
1265 Err(err) if is_inquire_cancel(&err) => Ok(LoadChoice::Cancel),
1266 Err(err) => Err(format!("Error: failed to prompt for load: {err}")),
1267 }
1268}
1269
1270pub(crate) fn build_candidates(
1271 ordered: &[(String, u64)],
1272 snapshot: &Snapshot,
1273 current_saved_id: Option<&str>,
1274) -> Vec<Candidate> {
1275 let mut candidates = Vec::with_capacity(ordered.len());
1276 let use_color = use_color_stderr();
1277 for (id, ts) in ordered {
1278 let label = label_for_id(&snapshot.labels, id);
1279 let tokens = snapshot
1280 .tokens
1281 .get(id)
1282 .and_then(|result| result.as_ref().ok());
1283 let index_entry = snapshot.index.profiles.get(id);
1284 let is_current = current_saved_id == Some(id.as_str());
1285 let info = profile_info_with_fallback(tokens, index_entry, label, is_current, use_color);
1286 let last_used = if is_current {
1287 String::new()
1288 } else {
1289 format_last_used(*ts)
1290 };
1291 candidates.push(Candidate {
1292 id: id.clone(),
1293 display: info.display,
1294 last_used,
1295 is_current,
1296 });
1297 }
1298 candidates
1299}
1300
1301pub(crate) fn require_tty(action: &str) -> Result<(), String> {
1302 require_tty_with(io::stdin().is_terminal(), action)
1303}
1304
1305fn require_tty_with(is_tty: bool, action: &str) -> Result<(), String> {
1306 if is_tty {
1307 Ok(())
1308 } else {
1309 Err(format!(
1310 "Error: {action} selection requires a TTY. Run `{} {action}` interactively.",
1311 command_name()
1312 ))
1313 }
1314}
1315
1316pub(crate) fn select_single_profile(
1317 title: &str,
1318 candidates: &[Candidate],
1319) -> Result<Candidate, String> {
1320 let options = candidates.to_vec();
1321 let render_config = inquire_select_render_config();
1322 let prompt = Select::new(title, options)
1323 .with_help_message(LOAD_HELP)
1324 .with_render_config(render_config)
1325 .prompt();
1326 handle_inquire_result(prompt, "selection")
1327}
1328
1329pub(crate) fn select_multiple_profiles(
1330 title: &str,
1331 candidates: &[Candidate],
1332) -> Result<Vec<Candidate>, String> {
1333 let options = candidates.to_vec();
1334 let render_config = inquire_select_render_config();
1335 let prompt = MultiSelect::new(title, options)
1336 .with_help_message(DELETE_HELP)
1337 .with_render_config(render_config)
1338 .prompt();
1339 let selections = handle_inquire_result(prompt, "selection")?;
1340 if selections.is_empty() {
1341 return Err(CANCELLED_MESSAGE.to_string());
1342 }
1343 Ok(selections)
1344}
1345
1346pub(crate) fn select_by_label(
1347 label: &str,
1348 labels: &Labels,
1349 candidates: &[Candidate],
1350) -> Result<Candidate, String> {
1351 let id = resolve_label_id(labels, label)?;
1352 let Some(candidate) = candidates.iter().find(|candidate| candidate.id == id) else {
1353 return Err(format!(
1354 "Error: label '{label}' does not match a saved profile. {}",
1355 format_list_hint(use_color_stderr())
1356 ));
1357 };
1358 Ok(candidate.clone())
1359}
1360
1361pub(crate) fn confirm_delete_profiles(displays: &[String]) -> Result<bool, String> {
1362 let is_tty = io::stdin().is_terminal();
1363 if !is_tty {
1364 return Err(
1365 "Error: deletion requires confirmation. Re-run with `--yes` to skip the prompt."
1366 .to_string(),
1367 );
1368 }
1369 let prompt = if displays.len() == 1 {
1370 format!("Delete profile {}? This cannot be undone.", displays[0])
1371 } else {
1372 let count = displays.len();
1373 eprintln!("Delete {count} profiles? This cannot be undone.");
1374 for display in displays {
1375 eprintln!(" - {display}");
1376 }
1377 "Delete selected profiles? This cannot be undone.".to_string()
1378 };
1379 let selection = Confirm::new(&prompt)
1380 .with_default(false)
1381 .with_render_config(inquire_select_render_config())
1382 .prompt();
1383 confirm_delete_profiles_with(is_tty, selection)
1384}
1385
1386fn confirm_delete_profiles_with(
1387 is_tty: bool,
1388 selection: Result<bool, inquire::error::InquireError>,
1389) -> Result<bool, String> {
1390 if !is_tty {
1391 return Err(
1392 "Error: deletion requires confirmation. Re-run with `--yes` to skip the prompt."
1393 .to_string(),
1394 );
1395 }
1396 match selection {
1397 Ok(value) => Ok(value),
1398 Err(err) if is_inquire_cancel(&err) => Err(CANCELLED_MESSAGE.to_string()),
1399 Err(err) => Err(format!("Error: failed to prompt for delete: {err}")),
1400 }
1401}
1402
1403#[derive(Clone)]
1404pub(crate) struct Candidate {
1405 pub(crate) id: String,
1406 pub(crate) display: String,
1407 pub(crate) last_used: String,
1408 pub(crate) is_current: bool,
1409}
1410
1411impl fmt::Display for Candidate {
1412 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1413 let header = format_entry_header(
1414 &self.display,
1415 &self.last_used,
1416 self.is_current,
1417 use_color_stderr(),
1418 );
1419 write!(f, "{header}")
1420 }
1421}
1422
1423fn render_entries(
1424 entries: &[Entry],
1425 show_last_used: bool,
1426 ctx: &ListCtx,
1427 separator: Option<&str>,
1428 allow_plain_spacing: bool,
1429) -> Vec<String> {
1430 let mut lines = Vec::with_capacity((entries.len().max(1)) * 4);
1431 for (idx, entry) in entries.iter().enumerate() {
1432 let header = format_entry_header(
1433 &entry.display,
1434 if show_last_used { &entry.last_used } else { "" },
1435 entry.is_current,
1436 ctx.use_color,
1437 );
1438 let show_detail_lines = ctx.show_usage || entry.always_show_details;
1439 if !show_detail_lines {
1440 if let Some(err) = entry.error_summary.as_deref() {
1441 let mut header = header;
1442 header.push_str(&format!(" {err}"));
1443 lines.push(header);
1444 } else {
1445 lines.push(header);
1446 }
1447 } else {
1448 lines.push(header);
1449 lines.extend(entry.details.iter().cloned());
1450 }
1451 if idx + 1 < entries.len() {
1452 push_separator(&mut lines, separator, allow_plain_spacing);
1453 }
1454 }
1455 lines
1456}
1457
1458fn push_separator(lines: &mut Vec<String>, separator: Option<&str>, allow_plain_spacing: bool) {
1459 match separator {
1460 Some(value) => lines.push(value.to_string()),
1461 None => {
1462 if !is_plain() || allow_plain_spacing {
1463 lines.push(String::new());
1464 }
1465 }
1466 }
1467}
1468
1469fn separator_line(trim: usize) -> Option<String> {
1470 if is_plain() {
1471 return None;
1472 }
1473 let width = terminal_width()?;
1474 let len = width.saturating_sub(trim);
1475 if len == 0 {
1476 return None;
1477 }
1478 let line = "-".repeat(len);
1479 Some(style_text(&line, use_color_stdout(), |text| text.dimmed()))
1480}
1481
1482fn render_current(
1483 paths: &Paths,
1484 current_saved_id: Option<&str>,
1485 labels: &Labels,
1486 tokens_map: &BTreeMap<String, Result<Tokens, String>>,
1487 usage_map: &BTreeMap<String, u64>,
1488 hide_last_used: bool,
1489 ctx: &ListCtx,
1490) -> Result<bool, String> {
1491 if let Some(entry) = make_current(paths, current_saved_id, labels, tokens_map, usage_map, ctx) {
1492 let lines = render_entries(&[entry], !hide_last_used, ctx, None, false);
1493 print_output_block(&lines.join("\n"));
1494 Ok(true)
1495 } else {
1496 Ok(false)
1497 }
1498}
1499
1500fn make_error(
1501 label: Option<String>,
1502 index_entry: Option<&ProfileIndexEntry>,
1503 use_color: bool,
1504 last_used: String,
1505 message: &str,
1506 summary_label: &str,
1507 is_current: bool,
1508) -> Entry {
1509 let display =
1510 profile_info_with_fallback(None, index_entry, label, is_current, use_color).display;
1511 Entry {
1512 display,
1513 last_used,
1514 details: vec![format_error(message)],
1515 error_summary: Some(error_summary(summary_label, message)),
1516 always_show_details: false,
1517 is_current,
1518 }
1519}
1520
1521fn unavailable_lines(message: &str, use_color: bool) -> Vec<String> {
1522 vec![format_usage_unavailable(message, use_color)]
1523}
1524
1525fn detail_lines(
1526 tokens: &mut Tokens,
1527 email: Option<&str>,
1528 plan: Option<&str>,
1529 is_current: bool,
1530 profile_path: &Path,
1531 ctx: &ListCtx,
1532 allow_401_refresh: bool,
1533) -> (Vec<String>, Option<String>) {
1534 let plan_is_free = is_free_plan(plan);
1535 let use_color = ctx.use_color;
1536 let account_id = token_account_id(tokens).map(str::to_string);
1537 let access_token = tokens.access_token.clone();
1538 if is_api_key_profile(tokens) {
1539 if ctx.show_usage {
1540 return (
1541 unavailable_lines("Usage unavailable for API key login", use_color),
1542 None,
1543 );
1544 }
1545 return (Vec::new(), None);
1546 }
1547 let unavailable_text = usage_unavailable(plan_is_free);
1548 if let Some(message) = profile_error(tokens, email, plan) {
1549 let missing_access = access_token.is_none() || account_id.is_none();
1550 if ctx.show_usage && missing_access && email.is_some() && plan.is_some() {
1551 return (unavailable_lines(unavailable_text, use_color), None);
1552 }
1553 let details = vec![format_error(message)];
1554 let summary = Some(error_summary("Error", message));
1555 return (details, summary);
1556 }
1557 if ctx.show_usage {
1558 let Some(base_url) = ctx.base_url.as_deref() else {
1559 return (Vec::new(), None);
1560 };
1561 let Some(access_token) = access_token.as_deref() else {
1562 return (Vec::new(), None);
1563 };
1564 let Some(account_id) = account_id.as_deref() else {
1565 return (Vec::new(), None);
1566 };
1567 match fetch_usage_details(
1568 base_url,
1569 access_token,
1570 account_id,
1571 unavailable_text,
1572 ctx.now,
1573 is_current,
1574 ) {
1575 Ok(details) => (details, None),
1576 Err(err) if allow_401_refresh && err.status_code() == Some(401) => {
1577 match refresh_profile_tokens(profile_path, tokens) {
1578 Ok(()) => {
1579 let Some(access_token) = tokens.access_token.as_deref() else {
1580 let message = "Error: refreshed access_token is missing.";
1581 return (
1582 vec![format_error(message)],
1583 Some(error_summary("Auth error", message)),
1584 );
1585 };
1586 match fetch_usage_details(
1587 base_url,
1588 access_token,
1589 account_id,
1590 unavailable_text,
1591 ctx.now,
1592 is_current,
1593 ) {
1594 Ok(details) => (details, None),
1595 Err(err) => (
1596 vec![format_error(&err.message())],
1597 Some(error_summary("Usage error", &err.message())),
1598 ),
1599 }
1600 }
1601 Err(err) => (
1602 vec![format_error(&err)],
1603 Some(error_summary("Auth error", &err)),
1604 ),
1605 }
1606 }
1607 Err(err) => (
1608 vec![format_error(&err.message())],
1609 Some(error_summary("Usage error", &err.message())),
1610 ),
1611 }
1612 } else if plan_is_free {
1613 (unavailable_lines(unavailable_text, use_color), None)
1614 } else {
1615 (Vec::new(), None)
1616 }
1617}
1618
1619enum RefreshAttempt {
1620 Skipped,
1621 Succeeded,
1622 Failed(String),
1623}
1624
1625fn refresh_for_status(tokens: &mut Tokens, profile_path: &Path, ctx: &ListCtx) -> RefreshAttempt {
1626 if !ctx.show_usage {
1627 return RefreshAttempt::Skipped;
1628 }
1629 if is_api_key_profile(tokens) {
1630 return RefreshAttempt::Skipped;
1631 }
1632 let has_refresh = tokens
1633 .refresh_token
1634 .as_deref()
1635 .map(|value| !value.is_empty())
1636 .unwrap_or(false);
1637 if !has_refresh {
1638 return RefreshAttempt::Failed(
1639 "Error: profile is missing refresh_token; run `codex login` and save it again."
1640 .to_string(),
1641 );
1642 }
1643 match refresh_profile_tokens(profile_path, tokens) {
1644 Ok(()) => RefreshAttempt::Succeeded,
1645 Err(err) => RefreshAttempt::Failed(err),
1646 }
1647}
1648
1649fn make_entry(
1650 last_used: String,
1651 label: Option<String>,
1652 tokens_result: Option<&Result<Tokens, String>>,
1653 index_entry: Option<&ProfileIndexEntry>,
1654 profile_path: &Path,
1655 ctx: &ListCtx,
1656 is_current: bool,
1657) -> Entry {
1658 let use_color = ctx.use_color;
1659 let label_for_error = label.clone().or_else(|| profile_id_from_path(profile_path));
1660 let mut tokens = match tokens_result {
1661 Some(Ok(tokens)) => tokens.clone(),
1662 Some(Err(err)) => {
1663 return make_error(
1664 label_for_error,
1665 index_entry,
1666 use_color,
1667 last_used,
1668 err,
1669 "Error",
1670 is_current,
1671 );
1672 }
1673 None => {
1674 return make_error(
1675 label_for_error,
1676 index_entry,
1677 use_color,
1678 last_used,
1679 "profile file missing",
1680 "Error",
1681 is_current,
1682 );
1683 }
1684 };
1685 let refresh_attempt = refresh_for_status(&mut tokens, profile_path, ctx);
1686 let info = profile_info(Some(&tokens), label, is_current, use_color);
1687 let allow_401_refresh = matches!(refresh_attempt, RefreshAttempt::Skipped);
1688 let (mut details, mut summary) = detail_lines(
1689 &mut tokens,
1690 info.email.as_deref(),
1691 info.plan.as_deref(),
1692 false,
1693 profile_path,
1694 ctx,
1695 allow_401_refresh,
1696 );
1697 if let RefreshAttempt::Failed(err) = refresh_attempt {
1698 let warning = format_warning(&normalize_error(&err), use_color);
1699 details.insert(0, warning);
1700 if summary.is_none() {
1701 summary = Some(error_summary("Auth refresh", &err));
1702 }
1703 }
1704 Entry {
1705 display: info.display,
1706 last_used,
1707 details,
1708 error_summary: summary,
1709 always_show_details: info.is_free,
1710 is_current,
1711 }
1712}
1713
1714fn make_saved(
1715 id: &str,
1716 ts: u64,
1717 snapshot: &Snapshot,
1718 current_saved_id: Option<&str>,
1719 ctx: &ListCtx,
1720) -> Entry {
1721 let profile_path = ctx.profiles_dir.join(format!("{id}.json"));
1722 let label = label_for_id(&snapshot.labels, id);
1723 let is_current = current_saved_id == Some(id);
1724 let last_used = if is_current {
1725 String::new()
1726 } else {
1727 format_last_used(ts)
1728 };
1729 make_entry(
1730 last_used,
1731 label,
1732 snapshot.tokens.get(id),
1733 snapshot.index.profiles.get(id),
1734 &profile_path,
1735 ctx,
1736 is_current,
1737 )
1738}
1739
1740fn make_entries(
1741 ordered: &[(String, u64)],
1742 snapshot: &Snapshot,
1743 current_saved_id: Option<&str>,
1744 ctx: &ListCtx,
1745) -> Vec<Entry> {
1746 let build = |(id, ts): &(String, u64)| make_saved(id, *ts, snapshot, current_saved_id, ctx);
1747 if ctx.base_url.is_some() && ordered.len() >= 3 {
1748 if ordered.len() > MAX_USAGE_CONCURRENCY {
1749 let mut entries = Vec::with_capacity(ordered.len());
1750 for chunk in ordered.chunks(MAX_USAGE_CONCURRENCY) {
1751 let mut chunk_entries: Vec<Entry> = chunk.par_iter().map(build).collect();
1752 entries.append(&mut chunk_entries);
1753 }
1754 return entries;
1755 }
1756 return ordered.par_iter().map(build).collect();
1757 }
1758
1759 ordered.iter().map(build).collect()
1760}
1761
1762fn make_current(
1763 paths: &Paths,
1764 current_saved_id: Option<&str>,
1765 labels: &Labels,
1766 tokens_map: &BTreeMap<String, Result<Tokens, String>>,
1767 usage_map: &BTreeMap<String, u64>,
1768 ctx: &ListCtx,
1769) -> Option<Entry> {
1770 if !paths.auth.is_file() {
1771 return None;
1772 }
1773 let mut tokens = match read_tokens(&paths.auth) {
1774 Ok(tokens) => tokens,
1775 Err(err) => {
1776 return Some(make_error(
1777 None,
1778 None,
1779 ctx.use_color,
1780 String::new(),
1781 &err,
1782 "Error",
1783 true,
1784 ));
1785 }
1786 };
1787 let refresh_attempt = refresh_for_status(&mut tokens, &ctx.auth_path, ctx);
1788 let (email, _) = extract_email_and_plan(&tokens);
1789 let refreshed_saved_id =
1790 if matches!(refresh_attempt, RefreshAttempt::Succeeded) || current_saved_id.is_none() {
1791 match (token_account_id(&tokens), email.as_deref()) {
1792 (Some(account_id), Some(email)) => {
1793 let candidates = cached_profile_ids(tokens_map, account_id, Some(email));
1794 pick_primary(&candidates, usage_map)
1795 }
1796 _ => None,
1797 }
1798 } else {
1799 None
1800 };
1801 let effective_saved_id = refreshed_saved_id.as_deref().or(current_saved_id);
1802 if matches!(refresh_attempt, RefreshAttempt::Succeeded)
1803 && let Some(id) = effective_saved_id
1804 {
1805 let profile_path = ctx.profiles_dir.join(format!("{id}.json"));
1806 if profile_path.is_file()
1807 && let Err(err) = copy_atomic(&ctx.auth_path, &profile_path)
1808 {
1809 let warning = format_warning(&normalize_error(&err), use_color_stderr());
1810 eprintln!("{warning}");
1811 }
1812 }
1813 let label = effective_saved_id.and_then(|id| label_for_id(labels, id));
1814 let use_color = ctx.use_color;
1815 let info = profile_info(Some(&tokens), label, true, use_color);
1816 let plan_is_free = info.is_free;
1817 let can_save = is_profile_ready(&tokens);
1818 let is_unsaved = effective_saved_id.is_none() && can_save;
1819 let allow_401_refresh = matches!(refresh_attempt, RefreshAttempt::Skipped);
1820 let (mut details, mut summary) = detail_lines(
1821 &mut tokens,
1822 info.email.as_deref(),
1823 info.plan.as_deref(),
1824 true,
1825 &ctx.auth_path,
1826 ctx,
1827 allow_401_refresh,
1828 );
1829 if let RefreshAttempt::Failed(err) = refresh_attempt {
1830 let warning = format_warning(&normalize_error(&err), use_color);
1831 details.insert(0, warning);
1832 if summary.is_none() {
1833 summary = Some(error_summary("Auth refresh", &err));
1834 }
1835 }
1836
1837 if is_unsaved && !plan_is_free {
1838 details.extend(format_unsaved_warning(use_color));
1839 }
1840
1841 Some(Entry {
1842 display: info.display,
1843 last_used: String::new(),
1844 details,
1845 error_summary: summary,
1846 always_show_details: is_unsaved || (plan_is_free && !ctx.show_usage),
1847 is_current: true,
1848 })
1849}
1850
1851fn error_summary(label: &str, message: &str) -> String {
1852 format!("{label}: {}", normalize_error(message))
1853}
1854
1855struct ListCtx {
1856 base_url: Option<String>,
1857 now: DateTime<Local>,
1858 show_usage: bool,
1859 use_color: bool,
1860 profiles_dir: PathBuf,
1861 auth_path: PathBuf,
1862}
1863
1864impl ListCtx {
1865 fn new(paths: &Paths, show_usage: bool) -> Self {
1866 Self {
1867 base_url: show_usage.then(|| read_base_url(paths)),
1868 now: Local::now(),
1869 show_usage,
1870 use_color: use_color_stdout(),
1871 profiles_dir: paths.profiles.clone(),
1872 auth_path: paths.auth.clone(),
1873 }
1874 }
1875}
1876
1877struct Entry {
1878 display: String,
1879 last_used: String,
1880 details: Vec<String>,
1881 error_summary: Option<String>,
1882 always_show_details: bool,
1883 is_current: bool,
1884}
1885
1886const LOAD_HELP: &str = "Type to search • Use ↑/↓ to select • ENTER to load";
1887const DELETE_HELP: &str = "Type to search • Use ↑/↓ to select • SPACE to select • ENTER to delete";
1888
1889fn handle_inquire_result<T>(
1890 result: Result<T, inquire::error::InquireError>,
1891 context: &str,
1892) -> Result<T, String> {
1893 match result {
1894 Ok(value) => Ok(value),
1895 Err(err) if is_inquire_cancel(&err) => Err(CANCELLED_MESSAGE.to_string()),
1896 Err(err) => Err(format!("Error: failed to prompt for {context}: {err}")),
1897 }
1898}
1899
1900fn trim_label(label: &str) -> Result<&str, String> {
1901 let trimmed = label.trim();
1902 if trimmed.is_empty() {
1903 return Err("Error: label cannot be empty".to_string());
1904 }
1905 Ok(trimmed)
1906}
1907
1908fn normalize_labels(labels: &Labels) -> Labels {
1909 let mut normalized = BTreeMap::new();
1910 for (label, id) in labels {
1911 let trimmed = label.trim();
1912 if trimmed.is_empty() {
1913 continue;
1914 }
1915 let id = id.trim();
1916 if id.is_empty() {
1917 continue;
1918 }
1919 normalized.insert(trimmed.to_string(), id.to_string());
1920 }
1921 normalized
1922}
1923
1924fn is_profile_file(path: &Path) -> bool {
1925 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1926 return false;
1927 };
1928 if ext != "json" {
1929 return false;
1930 }
1931 !matches!(
1932 path.file_name().and_then(|name| name.to_str()),
1933 Some("profiles.json")
1934 )
1935}
1936
1937#[cfg(test)]
1938mod tests {
1939 use super::*;
1940 use crate::test_utils::{build_id_token, make_paths};
1941 use std::collections::BTreeMap;
1942 use std::fs;
1943 use std::path::{Path, PathBuf};
1944
1945 fn write_auth(
1946 path: &Path,
1947 account_id: &str,
1948 email: &str,
1949 plan: &str,
1950 access: &str,
1951 refresh: &str,
1952 ) {
1953 let id_token = build_id_token(email, plan);
1954 let value = serde_json::json!({
1955 "tokens": {
1956 "account_id": account_id,
1957 "id_token": id_token,
1958 "access_token": access,
1959 "refresh_token": refresh
1960 }
1961 });
1962 fs::write(path, serde_json::to_string(&value).unwrap()).unwrap();
1963 }
1964
1965 fn write_profile(paths: &Paths, id: &str, account_id: &str, email: &str, plan: &str) {
1966 let id_token = build_id_token(email, plan);
1967 let value = serde_json::json!({
1968 "tokens": {
1969 "account_id": account_id,
1970 "id_token": id_token,
1971 "access_token": "acc",
1972 "refresh_token": "ref"
1973 }
1974 });
1975 let path = profile_path_for_id(&paths.profiles, id);
1976 fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
1977 }
1978
1979 #[test]
1980 fn require_tty_with_variants() {
1981 assert!(require_tty_with(true, "load").is_ok());
1982 let err = require_tty_with(false, "load").unwrap_err();
1983 assert!(err.contains("requires a TTY"));
1984 }
1985
1986 #[test]
1987 fn prompt_unsaved_load_with_variants() {
1988 let dir = tempfile::tempdir().expect("tempdir");
1989 let paths = make_paths(dir.path());
1990 let err = prompt_unsaved_load_with(&paths, "reason", false, Ok("Cancel")).unwrap_err();
1991 assert!(err.contains("not saved"));
1992 assert!(matches!(
1993 prompt_unsaved_load_with(
1994 &paths,
1995 "reason",
1996 true,
1997 Ok("Save current profile and continue")
1998 )
1999 .unwrap(),
2000 LoadChoice::SaveAndContinue
2001 ));
2002 assert!(matches!(
2003 prompt_unsaved_load_with(&paths, "reason", true, Ok("Continue without saving"))
2004 .unwrap(),
2005 LoadChoice::ContinueWithoutSaving
2006 ));
2007 assert!(matches!(
2008 prompt_unsaved_load_with(&paths, "reason", true, Ok("Cancel")).unwrap(),
2009 LoadChoice::Cancel
2010 ));
2011 let err = prompt_unsaved_load_with(
2012 &paths,
2013 "reason",
2014 true,
2015 Err(inquire::error::InquireError::OperationCanceled),
2016 )
2017 .unwrap();
2018 assert!(matches!(err, LoadChoice::Cancel));
2019 }
2020
2021 #[test]
2022 fn confirm_delete_profiles_with_variants() {
2023 let err = confirm_delete_profiles_with(false, Ok(true)).unwrap_err();
2024 assert!(err.contains("requires confirmation"));
2025 assert!(confirm_delete_profiles_with(true, Ok(true)).unwrap());
2026 let err = confirm_delete_profiles_with(
2027 true,
2028 Err(inquire::error::InquireError::OperationCanceled),
2029 )
2030 .unwrap_err();
2031 assert_eq!(err, CANCELLED_MESSAGE);
2032 }
2033
2034 #[test]
2035 fn label_helpers() {
2036 let mut labels = Labels::new();
2037 assign_label(&mut labels, "Team", "id").unwrap();
2038 assert_eq!(label_for_id(&labels, "id").unwrap(), "Team");
2039 assert_eq!(resolve_label_id(&labels, "Team").unwrap(), "id");
2040 remove_labels_for_id(&mut labels, "id");
2041 assert!(labels.is_empty());
2042 assert!(trim_label(" ").is_err());
2043 }
2044
2045 #[test]
2046 fn profiles_index_roundtrip() {
2047 let dir = tempfile::tempdir().expect("tempdir");
2048 let paths = make_paths(dir.path());
2049 let mut index = ProfilesIndex {
2050 active_profile_id: Some("id".to_string()),
2051 ..ProfilesIndex::default()
2052 };
2053 index.profiles.insert(
2054 "id".to_string(),
2055 ProfileIndexEntry {
2056 account_id: Some("acct".to_string()),
2057 email: Some("me@example.com".to_string()),
2058 plan: Some("Team".to_string()),
2059 label: Some("work".to_string()),
2060 added_at: 1,
2061 last_used: Some(2),
2062 is_api_key: false,
2063 },
2064 );
2065 write_profiles_index(&paths, &index).unwrap();
2066 let read_back = read_profiles_index(&paths).unwrap();
2067 let entry = read_back.profiles.get("id").unwrap();
2068 assert_eq!(read_back.active_profile_id.as_deref(), Some("id"));
2069 assert_eq!(entry.account_id.as_deref(), Some("acct"));
2070 assert_eq!(entry.email.as_deref(), Some("me@example.com"));
2071 assert_eq!(entry.plan.as_deref(), Some("Team"));
2072 assert_eq!(entry.label.as_deref(), Some("work"));
2073 assert_eq!(entry.added_at, 1);
2074 assert_eq!(entry.last_used, Some(2));
2075 assert!(!entry.is_api_key);
2076 }
2077
2078 #[test]
2079 fn profiles_index_prunes_missing_profiles() {
2080 let dir = tempfile::tempdir().expect("tempdir");
2081 let paths = make_paths(dir.path());
2082 fs::create_dir_all(&paths.profiles).unwrap();
2083 let mut index = ProfilesIndex {
2084 active_profile_id: Some("missing".to_string()),
2085 ..ProfilesIndex::default()
2086 };
2087 index
2088 .profiles
2089 .insert("missing".to_string(), ProfileIndexEntry::default());
2090 prune_profiles_index(&mut index, &paths.profiles).unwrap();
2091 assert!(index.profiles.is_empty());
2092 assert!(index.active_profile_id.is_none());
2093 }
2094
2095 #[test]
2096 fn sanitize_helpers() {
2097 assert_eq!(sanitize_part("A B"), "a-b");
2098 assert_eq!(profile_base("", ""), "unknown-unknown");
2099 assert_eq!(short_account_suffix("abcdef123"), "abcdef");
2100 }
2101
2102 #[test]
2103 fn unique_id_conflicts() {
2104 let dir = tempfile::tempdir().expect("tempdir");
2105 let paths = make_paths(dir.path());
2106 fs::create_dir_all(&paths.profiles).unwrap();
2107 write_profile(&paths, "base", "acct", "a@b.com", "pro");
2108 let id = unique_id("base", "acct", &paths.profiles);
2109 assert_eq!(id, "base");
2110 let id = unique_id("base", "other", &paths.profiles);
2111 assert!(id.starts_with("base-"));
2112 }
2113
2114 #[test]
2115 fn load_profile_tokens_map_handles_invalid() {
2116 let dir = tempfile::tempdir().expect("tempdir");
2117 let paths = make_paths(dir.path());
2118 fs::create_dir_all(&paths.profiles).unwrap();
2119 write_profile(&paths, "valid", "acct", "a@b.com", "pro");
2120 fs::write(paths.profiles.join("bad.json"), "not-json").unwrap();
2121 let index = serde_json::json!({
2122 "version": 1,
2123 "active_profile_id": null,
2124 "profiles": {
2125 "bad": {
2126 "label": "bad",
2127 "last_used": 1,
2128 "added_at": 1
2129 }
2130 }
2131 });
2132 fs::write(
2133 &paths.profiles_index,
2134 serde_json::to_string(&index).unwrap(),
2135 )
2136 .unwrap();
2137 let map = load_profile_tokens_map(&paths).unwrap();
2138 assert!(map.contains_key("valid"));
2139 }
2140
2141 #[cfg(unix)]
2142 #[test]
2143 fn load_profile_tokens_map_remove_error() {
2144 use std::os::unix::fs::PermissionsExt;
2145 let dir = tempfile::tempdir().expect("tempdir");
2146 let paths = make_paths(dir.path());
2147 fs::create_dir_all(&paths.profiles).unwrap();
2148 let bad_path = paths.profiles.join("bad.json");
2149 fs::write(&bad_path, "not-json").unwrap();
2150 let perms = fs::Permissions::from_mode(0o400);
2151 fs::set_permissions(&paths.profiles, perms).unwrap();
2152 let map = load_profile_tokens_map(&paths).unwrap();
2153 assert!(map.contains_key("bad"));
2154 }
2155
2156 #[test]
2157 fn resolve_save_and_sync_ids() {
2158 let dir = tempfile::tempdir().expect("tempdir");
2159 let paths = make_paths(dir.path());
2160 fs::create_dir_all(&paths.profiles).unwrap();
2161 write_profile(&paths, "one", "acct", "a@b.com", "pro");
2162 let tokens = read_tokens(&paths.profiles.join("one.json")).unwrap();
2163 let mut usage_map = BTreeMap::new();
2164 let mut labels = Labels::new();
2165 let mut index = ProfilesIndex::default();
2166 let id = resolve_save_id(&paths, &mut usage_map, &mut labels, &mut index, &tokens).unwrap();
2167 assert!(!id.is_empty());
2168 let id = resolve_sync_id(&paths, &mut usage_map, &mut labels, &mut index, &tokens).unwrap();
2169 assert!(id.is_some());
2170 }
2171
2172 #[test]
2173 fn rename_profile_id_errors_when_missing() {
2174 let dir = tempfile::tempdir().expect("tempdir");
2175 let paths = make_paths(dir.path());
2176 fs::create_dir_all(&paths.profiles).unwrap();
2177 let mut usage_map = BTreeMap::new();
2178 let mut labels = Labels::new();
2179 let mut index = ProfilesIndex::default();
2180 let err = rename_profile_id(
2181 &paths,
2182 &mut usage_map,
2183 &mut labels,
2184 &mut index,
2185 "missing",
2186 "base",
2187 "acct",
2188 )
2189 .unwrap_err();
2190 assert!(err.contains("not found"));
2191 }
2192
2193 #[test]
2194 fn render_helpers() {
2195 let entry = Entry {
2196 display: "Display".to_string(),
2197 last_used: "".to_string(),
2198 details: vec!["detail".to_string()],
2199 error_summary: None,
2200 always_show_details: true,
2201 is_current: false,
2202 };
2203 let ctx = ListCtx {
2204 base_url: None,
2205 now: chrono::Local::now(),
2206 show_usage: false,
2207 use_color: false,
2208 profiles_dir: PathBuf::new(),
2209 auth_path: PathBuf::new(),
2210 };
2211 let lines = render_entries(&[entry], true, &ctx, None, true);
2212 assert!(!lines.is_empty());
2213 push_separator(&mut vec!["a".to_string()], None, true);
2214 }
2215
2216 #[test]
2217 fn handle_inquire_result_variants() {
2218 let ok: Result<i32, inquire::error::InquireError> = Ok(1);
2219 assert_eq!(handle_inquire_result(ok, "selection").unwrap(), 1);
2220 let err: Result<(), inquire::error::InquireError> =
2221 Err(inquire::error::InquireError::OperationCanceled);
2222 let err = handle_inquire_result(err, "selection").unwrap_err();
2223 assert_eq!(err, CANCELLED_MESSAGE);
2224 }
2225
2226 #[test]
2227 fn sync_and_status_paths() {
2228 let dir = tempfile::tempdir().expect("tempdir");
2229 let paths = make_paths(dir.path());
2230 fs::create_dir_all(&paths.profiles).unwrap();
2231 write_auth(&paths.auth, "acct", "a@b.com", "pro", "acc", "ref");
2232 crate::ensure_paths(&paths).unwrap();
2233 save_profile(&paths, Some("team".to_string())).unwrap();
2234 list_profiles(&paths, false, false, false, false).unwrap();
2235 status_profiles(&paths, false).unwrap();
2236 let label = read_labels(&paths).unwrap().keys().next().cloned().unwrap();
2237 status_label(&paths, &label).unwrap();
2238 sync_current_readonly(&paths).unwrap();
2239 }
2240
2241 #[test]
2242 fn delete_profile_by_label() {
2243 let dir = tempfile::tempdir().expect("tempdir");
2244 let paths = make_paths(dir.path());
2245 fs::create_dir_all(&paths.profiles).unwrap();
2246 write_auth(&paths.auth, "acct", "a@b.com", "pro", "acc", "ref");
2247 crate::ensure_paths(&paths).unwrap();
2248 save_profile(&paths, Some("team".to_string())).unwrap();
2249 delete_profile(&paths, true, Some("team".to_string())).unwrap();
2250 }
2251}