Skip to main content

codex_profiles/
profiles.rs

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}