Skip to main content

codex_profiles/
profiles.rs

1use chrono::{DateTime, Local};
2use colored::Colorize;
3use inquire::{Confirm, MultiSelect, Select};
4use rayon::prelude::*;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashSet};
7use std::env;
8use std::fmt;
9use std::fs;
10use std::io::{self, IsTerminal as _};
11use std::path::Component;
12use std::path::{Path, PathBuf};
13
14use crate::json_response::CommandResultJson;
15use crate::{
16    AUTH_ERR_INCOMPLETE_ACCOUNT, AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN, PROFILE_COPY_CONTEXT_LOAD,
17    PROFILE_COPY_CONTEXT_SAVE, PROFILE_DELETE_HELP, PROFILE_ERR_COPY_CONTEXT,
18    PROFILE_ERR_CURRENT_NOT_SAVED, PROFILE_ERR_DELETE_CONFIRM_REQUIRED, PROFILE_ERR_FAILED_DELETE,
19    PROFILE_ERR_ID_NO_MATCH, PROFILE_ERR_ID_NOT_FOUND, PROFILE_ERR_INDEX_INVALID_JSON,
20    PROFILE_ERR_LABEL_EMPTY, PROFILE_ERR_LABEL_EXISTS, PROFILE_ERR_LABEL_NO_MATCH,
21    PROFILE_ERR_LABEL_NOT_FOUND, PROFILE_ERR_PROMPT_CONTEXT, PROFILE_ERR_PROMPT_DELETE,
22    PROFILE_ERR_PROMPT_LOAD, PROFILE_ERR_READ_INDEX, PROFILE_ERR_READ_PROFILES_DIR,
23    PROFILE_ERR_RENAME_PROFILE, PROFILE_ERR_SELECTED_INVALID, PROFILE_ERR_SERIALIZE_INDEX,
24    PROFILE_ERR_SYNC_CURRENT, PROFILE_ERR_TTY_REQUIRED, PROFILE_ERR_WRITE_INDEX, PROFILE_LOAD_HELP,
25    PROFILE_MSG_DELETED_COUNT, PROFILE_MSG_DELETED_WITH, PROFILE_MSG_LABEL_CLEARED,
26    PROFILE_MSG_LABEL_SET, PROFILE_MSG_LOADED_WITH, PROFILE_MSG_NOT_FOUND, PROFILE_MSG_SAVED,
27    PROFILE_MSG_SAVED_WITH, PROFILE_PROMPT_CANCEL, PROFILE_PROMPT_CONTINUE_WITHOUT_SAVING,
28    PROFILE_PROMPT_DELETE_MANY, PROFILE_PROMPT_DELETE_ONE, PROFILE_PROMPT_DELETE_SELECTED,
29    PROFILE_PROMPT_SAVE_AND_CONTINUE, PROFILE_SUMMARY_AUTH_ERROR, PROFILE_SUMMARY_ERROR,
30    PROFILE_SUMMARY_FILE_MISSING, PROFILE_SUMMARY_USAGE_ERROR, PROFILE_UNSAVED_NO_MATCH,
31    PROFILE_WARN_CURRENT_NOT_SAVED_REASON, UI_ERROR_PREFIX, UI_ERROR_TWO_LINE,
32};
33use crate::{
34    AuthFile, ProfileIdentityKey, Tokens, extract_email_and_plan, extract_profile_identity,
35    is_api_key_profile, is_free_plan, is_profile_ready, profile_error, read_tokens,
36    read_tokens_opt, require_identity, token_account_id, tokens_from_api_key,
37};
38use crate::{
39    CANCELLED_MESSAGE, format_action, format_entry_header, format_error, format_label_later_hint,
40    format_list_hint, format_no_profiles, format_save_before_load_or_force, format_unsaved_warning,
41    format_warning, inquire_select_render_config, is_inquire_cancel, is_plain, normalize_error,
42    print_output_block, style_text, use_color_stderr, use_color_stdout,
43};
44use crate::{
45    Paths, USAGE_UNAVAILABLE_API_KEY_DETAIL, USAGE_UNAVAILABLE_API_KEY_TITLE, command_name,
46    copy_atomic, write_atomic,
47};
48use crate::{UsageLock, format_usage_unavailable, lock_usage, read_base_url, usage_unavailable};
49
50const DEFAULT_USAGE_CONCURRENCY: usize = 32;
51const MAX_USAGE_CONCURRENCY: usize = 128;
52const USAGE_CONCURRENCY_ENV: &str = "CODEX_PROFILES_USAGE_CONCURRENCY";
53
54#[derive(Serialize, Deserialize)]
55struct ExportBundle {
56    version: u8,
57    profiles: Vec<ExportedProfile>,
58}
59
60#[derive(Serialize, Deserialize)]
61struct ExportedProfile {
62    id: String,
63    label: Option<String>,
64    contents: serde_json::Value,
65}
66
67struct PreparedImportProfile {
68    id: String,
69    label: Option<String>,
70    contents: Vec<u8>,
71    tokens: Tokens,
72}
73
74pub fn save_profile(paths: &Paths, label: Option<String>, json: bool) -> Result<(), String> {
75    let use_color = use_color_stdout();
76    let mut store = ProfileStore::load(paths)?;
77    let tokens = read_tokens(&paths.auth)?;
78    let id = resolve_save_id(paths, &mut store.profiles_index, &tokens)?;
79
80    if let Some(label) = label.as_deref() {
81        assign_label(&mut store.labels, label, &id)?;
82    }
83
84    let target = profile_path_for_id(&paths.profiles, &id);
85    copy_profile(&paths.auth, &target, PROFILE_COPY_CONTEXT_SAVE)?;
86
87    let label_display = label_for_id(&store.labels, &id);
88    update_profiles_index_entry(
89        &mut store.profiles_index,
90        &id,
91        Some(&tokens),
92        label_display.clone(),
93    );
94    store.save(paths)?;
95
96    if json {
97        let result = CommandResultJson::success(
98            "save",
99            serde_json::json!({
100                "id": id,
101                "label": label_display,
102            }),
103        );
104        result.print()?;
105        return Ok(());
106    }
107
108    let info = profile_info(Some(&tokens), label_display.clone(), true, use_color);
109    let message = if info.email.is_some() {
110        crate::msg1(PROFILE_MSG_SAVED_WITH, info.display)
111    } else {
112        PROFILE_MSG_SAVED.to_string()
113    };
114    let mut message = format_action(&message, use_color);
115    if label_display.is_none() {
116        message.push('\n');
117        message.push_str(&format_label_later_hint(&id, use_color));
118    }
119    print_output_block(&message);
120    Ok(())
121}
122
123pub fn set_profile_label(
124    paths: &Paths,
125    label: Option<String>,
126    id: Option<String>,
127    to: String,
128    json: bool,
129) -> Result<(), String> {
130    let use_color = use_color_stdout();
131    let mut store = ProfileStore::load(paths)?;
132    let target_id = resolve_label_target_id(&store, label.as_deref(), id.as_deref())?;
133    let target_label = trim_label(&to)?.to_string();
134
135    assign_label(&mut store.labels, &target_label, &target_id)?;
136    store.save(paths)?;
137
138    if json {
139        let result = CommandResultJson::success(
140            "label set",
141            serde_json::json!({
142                "id": target_id,
143                "label": target_label,
144            }),
145        );
146        result.print()?;
147        return Ok(());
148    }
149
150    let message = format_action(
151        &crate::msg2(PROFILE_MSG_LABEL_SET, target_label, target_id),
152        use_color,
153    );
154    print_output_block(&message);
155    Ok(())
156}
157
158pub fn clear_profile_label(
159    paths: &Paths,
160    label: Option<String>,
161    id: Option<String>,
162    json: bool,
163) -> Result<(), String> {
164    let use_color = use_color_stdout();
165    let mut store = ProfileStore::load(paths)?;
166    let target_id = resolve_label_target_id(&store, label.as_deref(), id.as_deref())?;
167
168    remove_labels_for_id(&mut store.labels, &target_id);
169    store.save(paths)?;
170
171    if json {
172        let result = CommandResultJson::success(
173            "label clear",
174            serde_json::json!({
175                "id": target_id,
176                "label": null,
177            }),
178        );
179        result.print()?;
180        return Ok(());
181    }
182
183    let message = format_action(
184        &crate::msg1(PROFILE_MSG_LABEL_CLEARED, target_id),
185        use_color,
186    );
187    print_output_block(&message);
188    Ok(())
189}
190
191pub fn rename_profile_label(
192    paths: &Paths,
193    label: String,
194    to: String,
195    json: bool,
196) -> Result<(), String> {
197    let use_color = use_color_stdout();
198    let mut store = ProfileStore::load(paths)?;
199    let old_label = trim_label(&label)?.to_string();
200    let target_id = resolve_label_id(&store.labels, &old_label)?;
201    let new_label = trim_label(&to)?.to_string();
202
203    assign_label(&mut store.labels, &new_label, &target_id)?;
204    store.save(paths)?;
205
206    if json {
207        let result = CommandResultJson::success(
208            "label rename",
209            serde_json::json!({
210                "id": target_id,
211                "label": new_label,
212            }),
213        );
214        result.print()?;
215        return Ok(());
216    }
217
218    let message = format_action(
219        &format!("Renamed label '{}' to '{}'", old_label, new_label),
220        use_color,
221    );
222    print_output_block(&message);
223    Ok(())
224}
225
226pub fn export_profiles(
227    paths: &Paths,
228    label: Option<String>,
229    ids: Vec<String>,
230    output: PathBuf,
231    json: bool,
232) -> Result<(), String> {
233    if output.exists() {
234        return Err(format!(
235            "Error: Export file already exists: {}",
236            output.display()
237        ));
238    }
239
240    let use_color = use_color_stdout();
241    let store = ProfileStore::load(paths)?;
242    let selected_ids = resolve_export_ids(paths, &store, label.as_deref(), &ids)?;
243    let mut profiles = Vec::with_capacity(selected_ids.len());
244
245    for id in selected_ids {
246        let path = profile_path_for_id(&paths.profiles, &id);
247        let raw = fs::read_to_string(&path)
248            .map_err(|err| crate::msg2(PROFILE_ERR_READ_PROFILES_DIR, path.display(), err))?;
249        let contents: serde_json::Value = serde_json::from_str(&raw)
250            .map_err(|err| format!("Error: Saved profile '{}' is invalid JSON: {err}", id))?;
251        profiles.push(ExportedProfile {
252            label: label_for_id(&store.labels, &id),
253            id,
254            contents,
255        });
256    }
257
258    let bundle = ExportBundle {
259        version: 1,
260        profiles,
261    };
262    let mut bytes = serde_json::to_vec_pretty(&bundle).map_err(|err| err.to_string())?;
263    bytes.push(b'\n');
264    crate::common::write_atomic_private(&output, &bytes)?;
265    tighten_export_permissions(&output)?;
266
267    let count = bundle.profiles.len();
268    let noun = if count == 1 { "profile" } else { "profiles" };
269
270    if json {
271        let result = CommandResultJson::success(
272            "export",
273            serde_json::json!({
274                "path": output.display().to_string(),
275                "count": count,
276            }),
277        );
278        result.print()?;
279        return Ok(());
280    }
281
282    let message = format_action(
283        &format!("Exported {count} {noun} to {}", output.display()),
284        use_color,
285    );
286    print_output_block(&message);
287    Ok(())
288}
289
290pub fn import_profiles(paths: &Paths, input: PathBuf, json: bool) -> Result<(), String> {
291    let use_color = use_color_stdout();
292    let raw = fs::read_to_string(&input).map_err(|err| {
293        format!(
294            "Error: Could not read import file {}: {err}",
295            input.display()
296        )
297    })?;
298    let bundle: ExportBundle = serde_json::from_str(&raw)
299        .map_err(|err| format!("Error: Import file is invalid JSON: {err}"))?;
300    if bundle.version != 1 {
301        return Err(format!(
302            "Error: Import file version {} is not supported.",
303            bundle.version
304        ));
305    }
306
307    let mut store = ProfileStore::load(paths)?;
308    let existing_ids = collect_profile_ids(&paths.profiles)?;
309    let mut staged_labels = store.labels.clone();
310    let mut seen_ids = HashSet::new();
311    let mut prepared = Vec::with_capacity(bundle.profiles.len());
312    for profile in bundle.profiles {
313        validate_import_profile_id(&profile.id)?;
314        if !seen_ids.insert(profile.id.clone()) {
315            return Err(format!(
316                "Error: Import bundle contains duplicate profile id '{}'.",
317                profile.id
318            ));
319        }
320        if existing_ids.contains(&profile.id) {
321            return Err(format!("Error: Profile '{}' already exists.", profile.id));
322        }
323        if let Some(label) = profile.label.as_deref() {
324            assign_label(&mut staged_labels, label, &profile.id)?;
325        }
326        prepared.push(prepare_import_profile(profile)?);
327    }
328
329    let mut written_ids = Vec::with_capacity(prepared.len());
330    for profile in &prepared {
331        let path = profile_path_for_id(&paths.profiles, &profile.id);
332        if let Err(err) = crate::common::write_atomic_private(&path, &profile.contents) {
333            cleanup_imported_profiles(paths, &written_ids);
334            return Err(err);
335        }
336        written_ids.push(profile.id.clone());
337    }
338
339    for profile in &prepared {
340        if let Some(label) = profile.label.as_deref() {
341            assign_label(&mut store.labels, label, &profile.id)?;
342        }
343        update_profiles_index_entry(
344            &mut store.profiles_index,
345            &profile.id,
346            Some(&profile.tokens),
347            profile.label.clone(),
348        );
349    }
350    if let Err(err) = store.save(paths) {
351        cleanup_imported_profiles(paths, &written_ids);
352        return Err(err);
353    }
354
355    let count = prepared.len();
356    let noun = if count == 1 { "profile" } else { "profiles" };
357
358    if json {
359        let imported: Vec<serde_json::Value> = prepared
360            .iter()
361            .map(|p| {
362                serde_json::json!({
363                    "id": p.id,
364                    "label": p.label,
365                })
366            })
367            .collect();
368        let result = CommandResultJson::success(
369            "import",
370            serde_json::json!({
371                "count": count,
372                "profiles": imported,
373            }),
374        );
375        result.print()?;
376        return Ok(());
377    }
378
379    let message = format_action(
380        &format!("Imported {count} {noun} from {}", input.display()),
381        use_color,
382    );
383    print_output_block(&message);
384    Ok(())
385}
386
387pub fn load_profile(
388    paths: &Paths,
389    label: Option<String>,
390    id: Option<String>,
391    force: bool,
392    json: bool,
393) -> Result<(), String> {
394    let use_color_err = use_color_stderr();
395    let use_color_out = use_color_stdout();
396    let no_profiles = format_no_profiles(paths, use_color_err);
397    let (mut snapshot, mut ordered) = load_snapshot_ordered(paths, true, &no_profiles)?;
398
399    if let Some(reason) = unsaved_reason(paths, &snapshot.tokens)
400        && !force
401    {
402        match prompt_unsaved_load(paths, &reason)? {
403            LoadChoice::SaveAndContinue => {
404                save_profile(paths, None, false)?;
405                let no_profiles = format_no_profiles(paths, use_color_err);
406                let result = load_snapshot_ordered(paths, true, &no_profiles)?;
407                snapshot = result.0;
408                ordered = result.1;
409            }
410            LoadChoice::ContinueWithoutSaving => {}
411            LoadChoice::Cancel => {
412                return Err(CANCELLED_MESSAGE.to_string());
413            }
414        }
415    }
416
417    let candidates = make_candidates(paths, &snapshot, &ordered);
418    let selected = pick_one(
419        "load",
420        label.as_deref(),
421        id.as_deref(),
422        &snapshot,
423        &candidates,
424    )?;
425    let selected_id = selected.id.clone();
426    let selected_display = selected.display.clone();
427
428    match snapshot.tokens.get(&selected_id) {
429        Some(Ok(_)) => {}
430        Some(Err(err)) => {
431            let message = err
432                .strip_prefix(&format!("{} ", UI_ERROR_PREFIX))
433                .unwrap_or(err);
434            return Err(crate::msg1(PROFILE_ERR_SELECTED_INVALID, message));
435        }
436        None => {
437            return Err(profile_not_found(use_color_err));
438        }
439    }
440
441    let mut store = ProfileStore::load(paths)?;
442
443    if let Err(err) = sync_current(paths, &mut store.profiles_index) {
444        let warning = format_warning(&err, use_color_err);
445        eprintln!("{warning}");
446    }
447
448    let source = profile_path_for_id(&paths.profiles, &selected_id);
449    if !source.is_file() {
450        return Err(profile_not_found(use_color_err));
451    }
452
453    copy_profile(&source, &paths.auth, PROFILE_COPY_CONTEXT_LOAD)?;
454
455    let label = label_for_id(&store.labels, &selected_id);
456    let tokens = snapshot
457        .tokens
458        .get(&selected_id)
459        .and_then(|result| result.as_ref().ok());
460    update_profiles_index_entry(
461        &mut store.profiles_index,
462        &selected_id,
463        tokens,
464        label.clone(),
465    );
466    store.save(paths)?;
467
468    if json {
469        let result = CommandResultJson::success(
470            "load",
471            serde_json::json!({
472                "id": selected_id,
473                "label": label,
474            }),
475        );
476        result.print()?;
477        return Ok(());
478    }
479
480    let message = format_action(
481        &crate::msg1(PROFILE_MSG_LOADED_WITH, selected_display),
482        use_color_out,
483    );
484    print_output_block(&message);
485    Ok(())
486}
487
488pub fn delete_profile(
489    paths: &Paths,
490    yes: bool,
491    label: Option<String>,
492    ids: Vec<String>,
493    json: bool,
494) -> Result<(), String> {
495    let use_color_out = use_color_stdout();
496    let use_color_err = use_color_stderr();
497    let no_profiles = format_no_profiles(paths, use_color_out);
498    let (snapshot, ordered) = match load_snapshot_ordered(paths, true, &no_profiles) {
499        Ok(result) => result,
500        Err(message) => {
501            if message == no_profiles {
502                print_output_block(&message);
503                return Ok(());
504            }
505            return Err(message);
506        }
507    };
508
509    let candidates = make_candidates(paths, &snapshot, &ordered);
510    let selections = pick_many("delete", label.as_deref(), &ids, &snapshot, &candidates)?;
511    let (selected_ids, displays): (Vec<String>, Vec<String>) = selections
512        .iter()
513        .map(|item| (item.id.clone(), item.display.clone()))
514        .unzip();
515
516    if selected_ids.is_empty() {
517        return Ok(());
518    }
519
520    let mut store = ProfileStore::load(paths)?;
521    if !yes && !confirm_delete_profiles(&displays)? {
522        return Err(CANCELLED_MESSAGE.to_string());
523    }
524
525    for selected in &selected_ids {
526        let target = profile_path_for_id(&paths.profiles, selected);
527        if !target.is_file() {
528            return Err(profile_not_found(use_color_err));
529        }
530        fs::remove_file(&target).map_err(|err| crate::msg1(PROFILE_ERR_FAILED_DELETE, err))?;
531        remove_labels_for_id(&mut store.labels, selected);
532        store.profiles_index.profiles.remove(selected);
533    }
534    store.save(paths)?;
535
536    if json {
537        let deleted: Vec<serde_json::Value> = selected_ids
538            .iter()
539            .zip(displays.iter())
540            .map(|(id, display)| serde_json::json!({ "id": id, "display": display }))
541            .collect();
542        let result = CommandResultJson::success(
543            "delete",
544            serde_json::json!({
545                "count": selected_ids.len(),
546                "deleted": deleted,
547            }),
548        );
549        result.print()?;
550        return Ok(());
551    }
552
553    let message = if selected_ids.len() == 1 {
554        crate::msg1(PROFILE_MSG_DELETED_WITH, &displays[0])
555    } else {
556        crate::msg1(PROFILE_MSG_DELETED_COUNT, selected_ids.len())
557    };
558    let message = format_action(&message, use_color_out);
559    print_output_block(&message);
560    Ok(())
561}
562
563pub fn list_profiles(paths: &Paths, json: bool, show_id: bool) -> Result<(), String> {
564    let snapshot = load_snapshot(paths, false)?;
565    let current_saved_id = current_saved_id(paths, &snapshot.tokens);
566    let ctx = ListCtx::new(paths, false, true, show_id);
567
568    let ordered = ordered_profile_ids(&snapshot, current_saved_id.as_deref());
569    let current_entry = make_current(
570        paths,
571        current_saved_id.as_deref(),
572        &snapshot.labels,
573        &snapshot.tokens,
574        &ctx,
575    );
576    let has_saved = !ordered.is_empty();
577    if !has_saved {
578        if json {
579            if let Some(entry) = current_entry {
580                return print_list_json(&[entry]);
581            }
582            return print_list_json(&[]);
583        }
584        if let Some(entry) = current_entry {
585            let lines = render_entries(&[entry], &ctx, false);
586            print_output_block(&lines.join("\n"));
587        } else {
588            let message = format_no_profiles(paths, ctx.use_color);
589            print_output_block(&message);
590        }
591        return Ok(());
592    }
593
594    let filtered: Vec<String> = ordered
595        .into_iter()
596        .filter(|id| current_saved_id.as_deref() != Some(id.as_str()))
597        .collect();
598    let list_entries = make_entries(&filtered, &snapshot, None, &ctx);
599
600    if json {
601        let mut entries = Vec::new();
602        if let Some(entry) = current_entry {
603            entries.push(entry);
604        }
605        entries.extend(list_entries);
606        return print_list_json(&entries);
607    }
608
609    let mut lines = Vec::new();
610    if let Some(entry) = current_entry.as_ref() {
611        lines.extend(render_entries(std::slice::from_ref(entry), &ctx, false));
612        if !list_entries.is_empty() {
613            push_separator(&mut lines, false);
614        }
615    }
616    lines.extend(render_entries(&list_entries, &ctx, false));
617    let output = lines.join("\n");
618    print_output_block(&output);
619    Ok(())
620}
621
622pub fn status_profiles(
623    paths: &Paths,
624    all: bool,
625    label: Option<String>,
626    id: Option<String>,
627    json: bool,
628) -> Result<(), String> {
629    if all {
630        return status_all_profiles(paths, json);
631    }
632
633    if label.is_some() || id.is_some() {
634        return status_selected_profile(paths, label.as_deref(), id.as_deref(), json);
635    }
636
637    let snapshot = load_snapshot(paths, false)?;
638    let current_saved_id = current_saved_id(paths, &snapshot.tokens);
639    let mut ctx = ListCtx::new(paths, true, false, false);
640    if json {
641        ctx.use_color = false;
642    }
643    let labels = &snapshot.labels;
644    let tokens_map = &snapshot.tokens;
645    let current_entry = make_current(paths, current_saved_id.as_deref(), labels, tokens_map, &ctx);
646    if json {
647        return print_current_status_json(current_entry);
648    }
649    if let Some(entry) = current_entry {
650        let lines = render_entries(&[entry], &ctx, false);
651        print_output_block(&lines.join("\n"));
652    } else {
653        let message = format_no_profiles(paths, ctx.use_color);
654        print_output_block(&message);
655    }
656    Ok(())
657}
658
659fn status_selected_profile(
660    paths: &Paths,
661    label: Option<&str>,
662    id: Option<&str>,
663    json: bool,
664) -> Result<(), String> {
665    let use_color = use_color_stdout();
666    let no_profiles = format_no_profiles(paths, use_color);
667    let (snapshot, ordered) = match load_snapshot_ordered(paths, false, &no_profiles) {
668        Ok(result) => result,
669        Err(message) => {
670            if message == no_profiles {
671                if json {
672                    return print_current_status_json(None);
673                }
674                print_output_block(&message);
675                return Ok(());
676            }
677            return Err(message);
678        }
679    };
680    let current_saved_id = current_saved_id(paths, &snapshot.tokens);
681    let mut ctx = ListCtx::new(paths, true, false, false);
682    if json {
683        ctx.use_color = false;
684    }
685
686    let candidates = build_candidates(&ordered, &snapshot, current_saved_id.as_deref());
687    let selected = if let Some(label) = label {
688        select_by_label(label, &snapshot.labels, &candidates)?
689    } else if let Some(id) = id {
690        select_by_id(id, &candidates)?
691    } else {
692        unreachable!("status selector requires label or id")
693    };
694
695    let mut entries = make_entries(
696        std::slice::from_ref(&selected.id),
697        &snapshot,
698        current_saved_id.as_deref(),
699        &ctx,
700    );
701    let Some(entry) = entries.pop() else {
702        return Err(profile_not_found(use_color_stderr()));
703    };
704
705    if json {
706        return print_current_status_json(Some(entry));
707    }
708
709    let lines = render_entries(&[entry], &ctx, false);
710    print_output_block(&lines.join("\n"));
711    Ok(())
712}
713
714fn status_all_profiles(paths: &Paths, json: bool) -> Result<(), String> {
715    let snapshot = load_snapshot(paths, false)?;
716    let current_saved_id = current_saved_id(paths, &snapshot.tokens);
717    let mut ctx = ListCtx::new(paths, true, true, false);
718    if json {
719        ctx.use_color = false;
720    }
721
722    let ordered = ordered_profile_ids(&snapshot, current_saved_id.as_deref());
723    let filtered: Vec<String> = ordered
724        .into_iter()
725        .filter(|id| current_saved_id.as_deref() != Some(id.as_str()))
726        .collect();
727
728    let (current_entry, list_entries) = rayon::join(
729        || {
730            make_current(
731                paths,
732                current_saved_id.as_deref(),
733                &snapshot.labels,
734                &snapshot.tokens,
735                &ctx,
736            )
737        },
738        || make_entries(&filtered, &snapshot, None, &ctx),
739    );
740
741    if json {
742        let mut profiles = Vec::new();
743        if let Some(entry) = current_entry {
744            profiles.push(entry);
745        }
746        profiles.extend(list_entries);
747        return print_all_status_json(profiles);
748    }
749
750    if current_entry.is_none() && list_entries.is_empty() {
751        let message = format_no_profiles(paths, ctx.use_color);
752        print_output_block(&message);
753        return Ok(());
754    }
755
756    let mut lines = Vec::new();
757    if let Some(err) = ctx.base_url_error.as_deref() {
758        lines.push(format_error(err));
759        if current_entry.is_some() || !list_entries.is_empty() {
760            push_separator(&mut lines, true);
761        }
762    }
763    if let Some(entry) = current_entry {
764        lines.extend(render_entries(&[entry], &ctx, true));
765        if !list_entries.is_empty() {
766            push_separator(&mut lines, true);
767            lines.push(String::new());
768        }
769    }
770
771    if !list_entries.is_empty() {
772        lines.extend(render_entries(&list_entries, &ctx, true));
773    }
774
775    let output = lines.join("\n");
776    print_output_block(&output);
777    Ok(())
778}
779
780pub type Labels = BTreeMap<String, String>;
781
782const PROFILES_INDEX_VERSION: u8 = 3;
783
784#[derive(Debug, Serialize, Deserialize)]
785pub(crate) struct ProfilesIndex {
786    #[serde(default = "profiles_index_version")]
787    version: u8,
788    #[serde(default)]
789    profiles: BTreeMap<String, ProfileIndexEntry>,
790}
791
792impl Default for ProfilesIndex {
793    fn default() -> Self {
794        Self {
795            version: PROFILES_INDEX_VERSION,
796            profiles: BTreeMap::new(),
797        }
798    }
799}
800
801#[derive(Debug, Clone, Default, Serialize, Deserialize)]
802struct ProfileIndexEntry {
803    #[serde(default)]
804    account_id: Option<String>,
805    #[serde(default)]
806    email: Option<String>,
807    #[serde(default)]
808    plan: Option<String>,
809    #[serde(default)]
810    label: Option<String>,
811    #[serde(default)]
812    is_api_key: bool,
813    #[serde(default)]
814    principal_id: Option<String>,
815    #[serde(default)]
816    workspace_or_org_id: Option<String>,
817    #[serde(default)]
818    plan_type_key: Option<String>,
819}
820
821fn profiles_index_version() -> u8 {
822    PROFILES_INDEX_VERSION
823}
824
825fn has_legacy_schema(contents: &str) -> bool {
826    serde_json::from_str::<serde_json::Value>(contents)
827        .ok()
828        .and_then(|value| value.as_object().cloned())
829        .map(|obj| {
830            obj.contains_key("last_used")
831                || obj.contains_key("active_profile_id")
832                || obj.contains_key("update_cache")
833                || obj.contains_key("default_profile_id")
834        })
835        .unwrap_or(false)
836}
837
838pub(crate) fn read_profiles_index(paths: &Paths) -> Result<ProfilesIndex, String> {
839    if !paths.profiles_index.exists() {
840        return Ok(ProfilesIndex::default());
841    }
842    let contents = fs::read_to_string(&paths.profiles_index)
843        .map_err(|err| crate::msg2(PROFILE_ERR_READ_INDEX, paths.profiles_index.display(), err))?;
844    let had_legacy_schema = has_legacy_schema(&contents);
845    let mut index: ProfilesIndex = serde_json::from_str(&contents).map_err(|_| {
846        crate::msg1(
847            PROFILE_ERR_INDEX_INVALID_JSON,
848            paths.profiles_index.display(),
849        )
850    })?;
851    if index.version < PROFILES_INDEX_VERSION {
852        index.version = PROFILES_INDEX_VERSION;
853    }
854    if had_legacy_schema {
855        let _ = write_profiles_index(paths, &index);
856    }
857    Ok(index)
858}
859
860pub(crate) fn read_profiles_index_relaxed(paths: &Paths) -> ProfilesIndex {
861    match read_profiles_index(paths) {
862        Ok(index) => index,
863        Err(err) => {
864            let normalized = normalize_error(&err);
865            let warning = format_warning(&normalized, use_color_stderr());
866            eprintln!("{warning}");
867            ProfilesIndex::default()
868        }
869    }
870}
871
872pub(crate) fn write_profiles_index(paths: &Paths, index: &ProfilesIndex) -> Result<(), String> {
873    let json = serde_json::to_string_pretty(index)
874        .map_err(|err| crate::msg1(PROFILE_ERR_SERIALIZE_INDEX, err))?;
875    crate::common::write_atomic_private(&paths.profiles_index, format!("{json}\n").as_bytes())
876        .map_err(|err| crate::msg1(PROFILE_ERR_WRITE_INDEX, err))
877}
878
879pub(crate) fn repair_profiles_metadata(paths: &Paths) -> Result<Vec<String>, String> {
880    let _lock = lock_usage(paths)?;
881
882    let had_index = paths.profiles_index.exists();
883    let mut repairs = Vec::new();
884    let mut should_write = false;
885    let mut normalized_index = false;
886    let mut index = if !had_index {
887        should_write = true;
888        repairs.push("Initialized profiles index".to_string());
889        ProfilesIndex::default()
890    } else {
891        let contents = fs::read_to_string(&paths.profiles_index).map_err(|err| {
892            crate::msg2(PROFILE_ERR_READ_INDEX, paths.profiles_index.display(), err)
893        })?;
894        let had_legacy_schema = has_legacy_schema(&contents);
895        match serde_json::from_str::<ProfilesIndex>(&contents) {
896            Ok(mut index) => {
897                if index.version < PROFILES_INDEX_VERSION {
898                    index.version = PROFILES_INDEX_VERSION;
899                    normalized_index = true;
900                }
901                if had_legacy_schema {
902                    normalized_index = true;
903                }
904                if normalized_index {
905                    should_write = true;
906                    repairs.push("Normalized profiles index format".to_string());
907                }
908                index
909            }
910            Err(_) => {
911                should_write = true;
912                let backup_path = next_profiles_index_backup_path(&paths.profiles_index);
913                write_atomic(&backup_path, contents.as_bytes())?;
914                repairs.push(format!(
915                    "Backed up invalid profiles index to {}",
916                    backup_path.display()
917                ));
918                repairs.push("Rebuilt invalid profiles index".to_string());
919                ProfilesIndex::default()
920            }
921        }
922    };
923
924    let ids = collect_profile_ids(&paths.profiles)?;
925    let before_entries = index.profiles.len();
926
927    prune_profiles_index(&mut index, &paths.profiles)?;
928    let pruned = before_entries.saturating_sub(index.profiles.len());
929    if pruned > 0 {
930        should_write = true;
931        repairs.push(format!(
932            "Pruned {pruned} stale profile index {}",
933            if pruned == 1 { "entry" } else { "entries" }
934        ));
935    }
936
937    let mut indexed = 0usize;
938    for id in ids {
939        if index.profiles.contains_key(&id) {
940            continue;
941        }
942        let path = profile_path_for_id(&paths.profiles, &id);
943        match read_tokens(&path) {
944            Ok(tokens) if is_profile_ready(&tokens) => {}
945            _ => continue,
946        }
947        index.profiles.insert(id, ProfileIndexEntry::default());
948        indexed += 1;
949    }
950    if indexed > 0 {
951        should_write = true;
952        repairs.push(format!(
953            "Indexed {indexed} saved {}",
954            if indexed == 1 { "profile" } else { "profiles" }
955        ));
956    }
957
958    if should_write {
959        write_profiles_index(paths, &index)?;
960    }
961    Ok(repairs)
962}
963
964fn next_profiles_index_backup_path(path: &Path) -> PathBuf {
965    let base = path.with_extension("json.bak");
966    if !base.exists() {
967        return base;
968    }
969    let mut idx = 1usize;
970    loop {
971        let candidate = path.with_extension(format!("json.bak.{idx}"));
972        if !candidate.exists() {
973            return candidate;
974        }
975        idx += 1;
976    }
977}
978
979fn prune_profiles_index(index: &mut ProfilesIndex, profiles_dir: &Path) -> Result<(), String> {
980    let ids = collect_profile_ids(profiles_dir)?;
981    index.profiles.retain(|id, _| ids.contains(id));
982    Ok(())
983}
984
985fn sync_profiles_index(index: &mut ProfilesIndex, labels: &Labels) {
986    for (id, entry) in index.profiles.iter_mut() {
987        entry.label = label_for_id(labels, id);
988    }
989}
990
991fn labels_from_index(index: &ProfilesIndex) -> Labels {
992    let mut labels = Labels::new();
993    for (id, entry) in &index.profiles {
994        let Some(label) = entry.label.as_deref() else {
995            continue;
996        };
997        let trimmed = label.trim();
998        if trimmed.is_empty() || labels.contains_key(trimmed) {
999            continue;
1000        }
1001        labels.insert(trimmed.to_string(), id.clone());
1002    }
1003    labels
1004}
1005
1006fn update_profiles_index_entry(
1007    index: &mut ProfilesIndex,
1008    id: &str,
1009    tokens: Option<&Tokens>,
1010    label: Option<String>,
1011) {
1012    let entry = index.profiles.entry(id.to_string()).or_default();
1013    if let Some(tokens) = tokens {
1014        let (email, plan) = extract_email_and_plan(tokens);
1015        entry.email = email;
1016        entry.plan = plan;
1017        entry.account_id = token_account_id(tokens).map(str::to_string);
1018        entry.is_api_key = is_api_key_profile(tokens);
1019        if let Some(identity) = extract_profile_identity(tokens) {
1020            entry.principal_id = Some(identity.principal_id);
1021            entry.workspace_or_org_id = Some(identity.workspace_or_org_id);
1022            entry.plan_type_key = Some(identity.plan_type);
1023        }
1024    }
1025    if let Some(label) = label {
1026        entry.label = Some(label);
1027    }
1028}
1029
1030pub fn prune_labels(labels: &mut Labels, profiles_dir: &Path) {
1031    labels.retain(|_, id| profile_path_for_id(profiles_dir, id).is_file());
1032}
1033
1034pub fn assign_label(labels: &mut Labels, label: &str, id: &str) -> Result<(), String> {
1035    let trimmed = trim_label(label)?;
1036    if let Some(existing) = labels.get(trimmed)
1037        && existing != id
1038    {
1039        return Err(crate::msg2(
1040            PROFILE_ERR_LABEL_EXISTS,
1041            trimmed,
1042            format_list_hint(use_color_stderr()),
1043        ));
1044    }
1045    remove_labels_for_id(labels, id);
1046    labels.insert(trimmed.to_string(), id.to_string());
1047    Ok(())
1048}
1049
1050pub fn remove_labels_for_id(labels: &mut Labels, id: &str) {
1051    labels.retain(|_, value| value != id);
1052}
1053
1054pub fn label_for_id(labels: &Labels, id: &str) -> Option<String> {
1055    labels.iter().find_map(|(label, value)| {
1056        if value == id {
1057            Some(label.clone())
1058        } else {
1059            None
1060        }
1061    })
1062}
1063
1064fn labels_by_id(labels: &Labels) -> BTreeMap<String, String> {
1065    let mut out = BTreeMap::new();
1066    for (label, id) in labels {
1067        out.entry(id.clone()).or_insert_with(|| label.clone());
1068    }
1069    out
1070}
1071
1072pub fn resolve_label_id(labels: &Labels, label: &str) -> Result<String, String> {
1073    let trimmed = trim_label(label)?;
1074    labels.get(trimmed).cloned().ok_or_else(|| {
1075        crate::msg2(
1076            PROFILE_ERR_LABEL_NOT_FOUND,
1077            trimmed,
1078            format_list_hint(use_color_stderr()),
1079        )
1080    })
1081}
1082
1083fn resolve_label_target_id(
1084    store: &ProfileStore,
1085    label: Option<&str>,
1086    id: Option<&str>,
1087) -> Result<String, String> {
1088    if let Some(label) = label {
1089        return resolve_label_id(&store.labels, label);
1090    }
1091
1092    let Some(id) = id else {
1093        unreachable!("clap enforces label target selector")
1094    };
1095    if store.profiles_index.profiles.contains_key(id) {
1096        return Ok(id.to_string());
1097    }
1098    Err(crate::msg2(
1099        PROFILE_ERR_ID_NO_MATCH,
1100        id,
1101        format_list_hint(use_color_stderr()),
1102    ))
1103}
1104
1105pub fn profile_files(profiles_dir: &Path) -> Result<Vec<PathBuf>, String> {
1106    let mut files = Vec::new();
1107    if !profiles_dir.exists() {
1108        return Ok(files);
1109    }
1110    let entries = fs::read_dir(profiles_dir)
1111        .map_err(|err| crate::msg1(PROFILE_ERR_READ_PROFILES_DIR, err))?;
1112    for entry in entries {
1113        let entry = entry.map_err(|err| crate::msg1(PROFILE_ERR_READ_PROFILES_DIR, err))?;
1114        let path = entry.path();
1115        if !is_profile_file(&path) {
1116            continue;
1117        }
1118        files.push(path);
1119    }
1120    Ok(files)
1121}
1122
1123pub fn profile_id_from_path(path: &Path) -> Option<String> {
1124    path.file_stem()
1125        .and_then(|value| value.to_str())
1126        .filter(|stem| !stem.is_empty())
1127        .map(|stem| stem.to_string())
1128}
1129
1130pub fn profile_path_for_id(profiles_dir: &Path, id: &str) -> PathBuf {
1131    profiles_dir.join(format!("{id}.json"))
1132}
1133
1134pub fn collect_profile_ids(profiles_dir: &Path) -> Result<HashSet<String>, String> {
1135    let mut ids = HashSet::new();
1136    for path in profile_files(profiles_dir)? {
1137        if let Some(stem) = profile_id_from_path(&path) {
1138            ids.insert(stem);
1139        }
1140    }
1141    Ok(ids)
1142}
1143
1144fn resolve_export_ids(
1145    paths: &Paths,
1146    store: &ProfileStore,
1147    label: Option<&str>,
1148    ids: &[String],
1149) -> Result<Vec<String>, String> {
1150    if let Some(label) = label {
1151        return Ok(vec![resolve_label_target_id(store, Some(label), None)?]);
1152    }
1153
1154    let available_ids = collect_profile_ids(&paths.profiles)?;
1155    if ids.is_empty() {
1156        let mut all: Vec<String> = available_ids.into_iter().collect();
1157        all.sort();
1158        return Ok(all);
1159    }
1160
1161    let mut selected = Vec::new();
1162    let mut seen = HashSet::new();
1163    for id in ids {
1164        if !available_ids.contains(id) {
1165            return Err(crate::msg2(
1166                PROFILE_ERR_ID_NO_MATCH,
1167                id,
1168                format_list_hint(use_color_stderr()),
1169            ));
1170        }
1171        if seen.insert(id.clone()) {
1172            selected.push(id.clone());
1173        }
1174    }
1175    Ok(selected)
1176}
1177
1178fn prepare_import_profile(profile: ExportedProfile) -> Result<PreparedImportProfile, String> {
1179    let ExportedProfile {
1180        id,
1181        label,
1182        contents,
1183    } = profile;
1184    let mut bytes = serde_json::to_vec_pretty(&contents).map_err(|err| {
1185        format!(
1186            "Error: Exported profile '{}' could not be serialized: {err}",
1187            id
1188        )
1189    })?;
1190    bytes.push(b'\n');
1191
1192    let auth: AuthFile = serde_json::from_value(contents)
1193        .map_err(|err| format!("Error: Exported profile '{}' is invalid JSON: {err}", id))?;
1194    let tokens = if let Some(tokens) = auth.tokens {
1195        tokens
1196    } else if let Some(api_key) = auth.openai_api_key.as_deref() {
1197        tokens_from_api_key(api_key)
1198    } else {
1199        return Err(format!(
1200            "Error: Exported profile '{}' is missing tokens or API key.",
1201            id
1202        ));
1203    };
1204    if !is_profile_ready(&tokens) {
1205        return Err(format!("Error: Exported profile '{}' is incomplete.", id));
1206    }
1207
1208    Ok(PreparedImportProfile {
1209        id,
1210        label,
1211        contents: bytes,
1212        tokens,
1213    })
1214}
1215
1216fn validate_import_profile_id(id: &str) -> Result<(), String> {
1217    let mut components = Path::new(id).components();
1218    if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
1219        return Err(format!("Error: Imported profile id '{}' is not safe.", id));
1220    }
1221    if matches!(id, "profiles" | "update") {
1222        return Err(format!("Error: Imported profile id '{}' is reserved.", id));
1223    }
1224    Ok(())
1225}
1226
1227fn cleanup_imported_profiles(paths: &Paths, ids: &[String]) {
1228    for id in ids {
1229        let _ = fs::remove_file(profile_path_for_id(&paths.profiles, id));
1230    }
1231}
1232
1233fn tighten_export_permissions(path: &Path) -> Result<(), String> {
1234    #[cfg(unix)]
1235    {
1236        use std::os::unix::fs::PermissionsExt;
1237        let permissions = fs::Permissions::from_mode(0o600);
1238        fs::set_permissions(path, permissions).map_err(|err| {
1239            format!(
1240                "Error: Could not secure export file {}: {err}",
1241                path.display()
1242            )
1243        })?;
1244    }
1245    Ok(())
1246}
1247
1248pub fn load_profile_tokens_map(
1249    paths: &Paths,
1250) -> Result<BTreeMap<String, Result<Tokens, String>>, String> {
1251    let mut map = BTreeMap::new();
1252    for path in profile_files(&paths.profiles)? {
1253        let Some(stem) = profile_id_from_path(&path) else {
1254            continue;
1255        };
1256        match read_tokens(&path) {
1257            Ok(tokens) => {
1258                map.insert(stem, Ok(tokens));
1259            }
1260            Err(err) => {
1261                map.insert(stem, Err(normalize_error(&err)));
1262            }
1263        }
1264    }
1265    Ok(map)
1266}
1267
1268pub(crate) fn resolve_save_id(
1269    paths: &Paths,
1270    profiles_index: &mut ProfilesIndex,
1271    tokens: &Tokens,
1272) -> Result<String, String> {
1273    let (_, email, plan) = require_identity(tokens)?;
1274    let identity =
1275        extract_profile_identity(tokens).ok_or_else(|| AUTH_ERR_INCOMPLETE_ACCOUNT.to_string())?;
1276    let (desired_base, desired, candidates) = desired_candidates(paths, &identity, &email, &plan)?;
1277    if let Some(primary) = pick_primary(&candidates).filter(|primary| primary != &desired) {
1278        return rename_profile_id(paths, profiles_index, &primary, &desired_base, &identity);
1279    }
1280    Ok(desired)
1281}
1282
1283pub(crate) fn resolve_sync_id(
1284    paths: &Paths,
1285    profiles_index: &mut ProfilesIndex,
1286    tokens: &Tokens,
1287) -> Result<Option<String>, String> {
1288    let Ok((_, email, plan)) = require_identity(tokens) else {
1289        return Ok(None);
1290    };
1291    let Some(identity) = extract_profile_identity(tokens) else {
1292        return Ok(None);
1293    };
1294    let (desired_base, desired, candidates) = desired_candidates(paths, &identity, &email, &plan)?;
1295    if candidates.len() == 1 {
1296        return Ok(candidates.first().cloned());
1297    }
1298    if candidates.iter().any(|id| id == &desired) {
1299        return Ok(Some(desired));
1300    }
1301    let Some(primary) = pick_primary(&candidates) else {
1302        return Ok(None);
1303    };
1304    if primary != desired {
1305        let renamed = rename_profile_id(paths, profiles_index, &primary, &desired_base, &identity)?;
1306        return Ok(Some(renamed));
1307    }
1308    Ok(Some(primary))
1309}
1310
1311pub(crate) fn cached_profile_ids(
1312    tokens_map: &BTreeMap<String, Result<Tokens, String>>,
1313    identity: &ProfileIdentityKey,
1314) -> Vec<String> {
1315    tokens_map
1316        .iter()
1317        .filter_map(|(id, result)| {
1318            result
1319                .as_ref()
1320                .ok()
1321                .filter(|tokens| matches_identity(tokens, identity))
1322                .map(|_| id.clone())
1323        })
1324        .collect()
1325}
1326
1327pub(crate) fn pick_primary(candidates: &[String]) -> Option<String> {
1328    candidates.iter().min().cloned()
1329}
1330
1331fn desired_candidates(
1332    paths: &Paths,
1333    identity: &ProfileIdentityKey,
1334    email: &str,
1335    plan: &str,
1336) -> Result<(String, String, Vec<String>), String> {
1337    let (desired_base, desired) = desired_id(paths, identity, email, plan);
1338    let candidates = scan_profile_ids(&paths.profiles, identity)?;
1339    Ok((desired_base, desired, candidates))
1340}
1341
1342fn desired_id(
1343    paths: &Paths,
1344    identity: &ProfileIdentityKey,
1345    email: &str,
1346    plan: &str,
1347) -> (String, String) {
1348    let desired_base = profile_base(email, plan);
1349    let desired = unique_id(&desired_base, identity, &paths.profiles);
1350    (desired_base, desired)
1351}
1352
1353fn profile_base(email: &str, plan_label: &str) -> String {
1354    let email = sanitize_part(email);
1355    let plan = sanitize_part(plan_label);
1356    let email = if email.is_empty() {
1357        "unknown".to_string()
1358    } else {
1359        email
1360    };
1361    let plan = if plan.is_empty() {
1362        "unknown".to_string()
1363    } else {
1364        plan
1365    };
1366    format!("{email}-{plan}")
1367}
1368
1369fn sanitize_part(value: &str) -> String {
1370    let mut out = String::with_capacity(value.len());
1371    let mut last_dash = false;
1372    for ch in value.chars() {
1373        let next = if ch.is_ascii_alphanumeric() {
1374            Some(ch.to_ascii_lowercase())
1375        } else if matches!(ch, '@' | '.' | '-' | '_' | '+') {
1376            Some(ch)
1377        } else {
1378            Some('-')
1379        };
1380        if let Some(next) = next {
1381            if next == '-' {
1382                if last_dash {
1383                    continue;
1384                }
1385                last_dash = true;
1386            } else {
1387                last_dash = false;
1388            }
1389            out.push(next);
1390        }
1391    }
1392    out.trim_matches('-').to_string()
1393}
1394
1395fn unique_id(base: &str, identity: &ProfileIdentityKey, profiles_dir: &Path) -> String {
1396    let mut candidate = base.to_string();
1397    let suffix = short_identity_suffix(identity);
1398    let mut attempts = 0usize;
1399    loop {
1400        let path = profile_path_for_id(profiles_dir, &candidate);
1401        if !path.is_file() {
1402            return candidate;
1403        }
1404        if read_tokens(&path)
1405            .ok()
1406            .is_some_and(|tokens| matches_identity(&tokens, identity))
1407        {
1408            return candidate;
1409        }
1410        attempts += 1;
1411        if attempts == 1 {
1412            candidate = format!("{base}-{suffix}");
1413        } else {
1414            candidate = format!("{base}-{suffix}-{attempts}");
1415        }
1416    }
1417}
1418
1419fn short_identity_suffix(identity: &ProfileIdentityKey) -> String {
1420    let source = if identity.workspace_or_org_id == "unknown" {
1421        identity.principal_id.as_str()
1422    } else {
1423        identity.workspace_or_org_id.as_str()
1424    };
1425    let suffix: String = source.chars().take(6).collect();
1426    if suffix.is_empty() {
1427        "id".to_string()
1428    } else {
1429        suffix
1430    }
1431}
1432
1433fn scan_profile_ids(
1434    profiles_dir: &Path,
1435    identity: &ProfileIdentityKey,
1436) -> Result<Vec<String>, String> {
1437    let mut matches = Vec::new();
1438    for path in profile_files(profiles_dir)? {
1439        let Ok(tokens) = read_tokens(&path) else {
1440            continue;
1441        };
1442        if !matches_identity(&tokens, identity) {
1443            continue;
1444        }
1445        if let Some(stem) = profile_id_from_path(&path) {
1446            matches.push(stem);
1447        }
1448    }
1449    Ok(matches)
1450}
1451
1452fn matches_identity(tokens: &Tokens, identity: &ProfileIdentityKey) -> bool {
1453    extract_profile_identity(tokens).is_some_and(|candidate| candidate == *identity)
1454}
1455
1456fn rename_profile_id(
1457    paths: &Paths,
1458    profiles_index: &mut ProfilesIndex,
1459    from: &str,
1460    target_base: &str,
1461    identity: &ProfileIdentityKey,
1462) -> Result<String, String> {
1463    let desired = unique_id(target_base, identity, &paths.profiles);
1464    if from == desired {
1465        return Ok(desired);
1466    }
1467    let from_path = profile_path_for_id(&paths.profiles, from);
1468    let to_path = profile_path_for_id(&paths.profiles, &desired);
1469    if !from_path.is_file() {
1470        return Err(crate::msg1(PROFILE_ERR_ID_NOT_FOUND, from));
1471    }
1472    fs::rename(&from_path, &to_path)
1473        .map_err(|err| crate::msg2(PROFILE_ERR_RENAME_PROFILE, from, err))?;
1474    if let Some(entry) = profiles_index.profiles.remove(from) {
1475        profiles_index.profiles.insert(desired.clone(), entry);
1476    }
1477    Ok(desired)
1478}
1479
1480pub(crate) struct Snapshot {
1481    pub(crate) labels: Labels,
1482    pub(crate) tokens: BTreeMap<String, Result<Tokens, String>>,
1483    pub(crate) index: ProfilesIndex,
1484}
1485
1486pub(crate) fn sync_current(paths: &Paths, index: &mut ProfilesIndex) -> Result<(), String> {
1487    let Some(tokens) = read_tokens_opt(&paths.auth) else {
1488        return Ok(());
1489    };
1490    let id = match resolve_sync_id(paths, index, &tokens)? {
1491        Some(id) => id,
1492        None => return Ok(()),
1493    };
1494    let target = profile_path_for_id(&paths.profiles, &id);
1495    sync_profile(paths, &target)?;
1496    let label = label_for_id(&labels_from_index(index), &id);
1497    update_profiles_index_entry(index, &id, Some(&tokens), label);
1498    Ok(())
1499}
1500
1501fn sync_profile(paths: &Paths, target: &Path) -> Result<(), String> {
1502    copy_atomic(&paths.auth, target).map_err(|err| crate::msg1(PROFILE_ERR_SYNC_CURRENT, err))?;
1503    #[cfg(unix)]
1504    {
1505        use std::os::unix::fs::PermissionsExt;
1506        fs::set_permissions(target, fs::Permissions::from_mode(0o600))
1507            .map_err(|err| crate::msg1(PROFILE_ERR_SYNC_CURRENT, err))?;
1508    }
1509    Ok(())
1510}
1511
1512pub(crate) fn load_snapshot(paths: &Paths, strict_labels: bool) -> Result<Snapshot, String> {
1513    let _lock = lock_usage(paths)?;
1514    let tokens = load_profile_tokens_map(paths)?;
1515    let ids: HashSet<String> = tokens.keys().cloned().collect();
1516    let mut index = if strict_labels {
1517        read_profiles_index(paths)?
1518    } else {
1519        read_profiles_index_relaxed(paths)
1520    };
1521    let _ = prune_profiles_index(&mut index, &paths.profiles);
1522    for id in &ids {
1523        index.profiles.entry(id.clone()).or_default();
1524    }
1525    let labels = labels_from_index(&index);
1526
1527    Ok(Snapshot {
1528        labels,
1529        tokens,
1530        index,
1531    })
1532}
1533
1534pub(crate) fn unsaved_reason(
1535    paths: &Paths,
1536    tokens_map: &BTreeMap<String, Result<Tokens, String>>,
1537) -> Option<String> {
1538    let tokens = read_tokens_opt(&paths.auth)?;
1539    let identity = extract_profile_identity(&tokens)?;
1540    let candidates = cached_profile_ids(tokens_map, &identity);
1541    if candidates.is_empty() {
1542        return Some(PROFILE_UNSAVED_NO_MATCH.to_string());
1543    }
1544    None
1545}
1546
1547pub(crate) fn current_saved_id(
1548    paths: &Paths,
1549    tokens_map: &BTreeMap<String, Result<Tokens, String>>,
1550) -> Option<String> {
1551    let tokens = read_tokens_opt(&paths.auth)?;
1552    let identity = extract_profile_identity(&tokens)?;
1553    let candidates = cached_profile_ids(tokens_map, &identity);
1554    pick_primary(&candidates)
1555}
1556
1557pub(crate) struct ProfileStore {
1558    _lock: UsageLock,
1559    pub(crate) labels: Labels,
1560    pub(crate) profiles_index: ProfilesIndex,
1561}
1562
1563impl ProfileStore {
1564    pub(crate) fn load(paths: &Paths) -> Result<Self, String> {
1565        let lock = lock_usage(paths)?;
1566        let mut profiles_index = read_profiles_index_relaxed(paths);
1567        let _ = prune_profiles_index(&mut profiles_index, &paths.profiles);
1568        let ids = collect_profile_ids(&paths.profiles)?;
1569        for id in &ids {
1570            profiles_index.profiles.entry(id.clone()).or_default();
1571        }
1572        let labels = labels_from_index(&profiles_index);
1573        Ok(Self {
1574            _lock: lock,
1575            labels,
1576            profiles_index,
1577        })
1578    }
1579
1580    pub(crate) fn save(&mut self, paths: &Paths) -> Result<(), String> {
1581        prune_labels(&mut self.labels, &paths.profiles);
1582        prune_profiles_index(&mut self.profiles_index, &paths.profiles)?;
1583        sync_profiles_index(&mut self.profiles_index, &self.labels);
1584        write_profiles_index(paths, &self.profiles_index)?;
1585        Ok(())
1586    }
1587}
1588
1589fn profile_not_found(use_color: bool) -> String {
1590    crate::msg1(PROFILE_MSG_NOT_FOUND, format_list_hint(use_color))
1591}
1592
1593fn load_snapshot_ordered(
1594    paths: &Paths,
1595    strict_labels: bool,
1596    no_profiles_message: &str,
1597) -> Result<(Snapshot, Vec<String>), String> {
1598    let snapshot = load_snapshot(paths, strict_labels)?;
1599    let current_saved = current_saved_id(paths, &snapshot.tokens);
1600    let ordered = ordered_profile_ids(&snapshot, current_saved.as_deref());
1601    if ordered.is_empty() {
1602        return Err(no_profiles_message.to_string());
1603    }
1604    Ok((snapshot, ordered))
1605}
1606
1607#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
1608struct ProfileOrderKey {
1609    current_rank: u8,
1610    label_missing: bool,
1611    label: String,
1612    email_missing: bool,
1613    email: String,
1614    id: String,
1615}
1616
1617fn ordered_profile_ids(snapshot: &Snapshot, current_saved_id: Option<&str>) -> Vec<String> {
1618    let labels_by_id = labels_by_id(&snapshot.labels);
1619    let mut keyed: Vec<(String, ProfileOrderKey)> = snapshot
1620        .tokens
1621        .keys()
1622        .cloned()
1623        .map(|id| {
1624            let label = labels_by_id
1625                .get(&id)
1626                .cloned()
1627                .or_else(|| {
1628                    snapshot
1629                        .index
1630                        .profiles
1631                        .get(&id)
1632                        .and_then(|entry| entry.label.clone())
1633                })
1634                .map(|value| value.trim().to_ascii_lowercase())
1635                .filter(|value| !value.is_empty())
1636                .unwrap_or_default();
1637            let email = snapshot
1638                .tokens
1639                .get(&id)
1640                .and_then(|result| result.as_ref().ok())
1641                .and_then(|tokens| extract_email_and_plan(tokens).0)
1642                .or_else(|| {
1643                    snapshot
1644                        .index
1645                        .profiles
1646                        .get(&id)
1647                        .and_then(|entry| entry.email.clone())
1648                })
1649                .map(|value| value.trim().to_ascii_lowercase())
1650                .filter(|value| !value.is_empty())
1651                .unwrap_or_default();
1652            let key = ProfileOrderKey {
1653                current_rank: if current_saved_id == Some(id.as_str()) {
1654                    0
1655                } else {
1656                    1
1657                },
1658                label_missing: label.is_empty(),
1659                label,
1660                email_missing: email.is_empty(),
1661                email,
1662                id: id.to_ascii_lowercase(),
1663            };
1664            (id, key)
1665        })
1666        .collect();
1667    keyed.sort_by(|left, right| left.1.cmp(&right.1));
1668    keyed.into_iter().map(|(id, _)| id).collect()
1669}
1670
1671fn copy_profile(source: &Path, dest: &Path, context: &str) -> Result<(), String> {
1672    copy_atomic(source, dest)
1673        .map_err(|err| crate::msg3(PROFILE_ERR_COPY_CONTEXT, context, dest.display(), err))?;
1674    #[cfg(unix)]
1675    {
1676        use std::os::unix::fs::PermissionsExt;
1677        fs::set_permissions(dest, fs::Permissions::from_mode(0o600))
1678            .map_err(|err| crate::msg3(PROFILE_ERR_COPY_CONTEXT, context, dest.display(), err))?;
1679    }
1680    Ok(())
1681}
1682
1683fn make_candidates(paths: &Paths, snapshot: &Snapshot, ordered: &[String]) -> Vec<Candidate> {
1684    let current_saved = current_saved_id(paths, &snapshot.tokens);
1685    build_candidates(ordered, snapshot, current_saved.as_deref())
1686}
1687
1688fn pick_one(
1689    action: &str,
1690    label: Option<&str>,
1691    id: Option<&str>,
1692    snapshot: &Snapshot,
1693    candidates: &[Candidate],
1694) -> Result<Candidate, String> {
1695    if let Some(label) = label {
1696        select_by_label(label, &snapshot.labels, candidates)
1697    } else if let Some(id) = id {
1698        select_by_id(id, candidates)
1699    } else if !io::stdin().is_terminal() {
1700        require_tty(action)?;
1701        unreachable!("require_tty should always return Err in non-interactive mode")
1702    } else {
1703        select_single_profile("", candidates)
1704    }
1705}
1706
1707fn pick_many(
1708    action: &str,
1709    label: Option<&str>,
1710    ids: &[String],
1711    snapshot: &Snapshot,
1712    candidates: &[Candidate],
1713) -> Result<Vec<Candidate>, String> {
1714    if let Some(label) = label {
1715        Ok(vec![select_by_label(label, &snapshot.labels, candidates)?])
1716    } else if !ids.is_empty() {
1717        select_many_by_id(ids, candidates)
1718    } else {
1719        require_tty(action)?;
1720        select_multiple_profiles("", candidates)
1721    }
1722}
1723
1724pub(crate) struct ProfileInfo {
1725    pub(crate) display: String,
1726    pub(crate) email: Option<String>,
1727    pub(crate) plan: Option<String>,
1728    pub(crate) is_free: bool,
1729}
1730
1731pub(crate) fn profile_info(
1732    tokens: Option<&Tokens>,
1733    label: Option<String>,
1734    is_current: bool,
1735    use_color: bool,
1736) -> ProfileInfo {
1737    profile_info_with_fallback(tokens, None, label, is_current, use_color)
1738}
1739
1740fn profile_info_with_fallback(
1741    tokens: Option<&Tokens>,
1742    fallback: Option<&ProfileIndexEntry>,
1743    label: Option<String>,
1744    is_current: bool,
1745    use_color: bool,
1746) -> ProfileInfo {
1747    let (email, plan) = if let Some(tokens) = tokens {
1748        extract_email_and_plan(tokens)
1749    } else if let Some(entry) = fallback {
1750        (entry.email.clone(), entry.plan.clone())
1751    } else {
1752        (None, None)
1753    };
1754    let is_free = is_free_plan(plan.as_deref());
1755    let display =
1756        crate::format_profile_display(email.clone(), plan.clone(), label, is_current, use_color);
1757    ProfileInfo {
1758        display,
1759        email,
1760        plan,
1761        is_free,
1762    }
1763}
1764
1765#[derive(Debug)]
1766pub(crate) enum LoadChoice {
1767    SaveAndContinue,
1768    ContinueWithoutSaving,
1769    Cancel,
1770}
1771
1772pub(crate) fn prompt_unsaved_load(paths: &Paths, reason: &str) -> Result<LoadChoice, String> {
1773    let is_tty = io::stdin().is_terminal();
1774    if !is_tty {
1775        let hint = format_save_before_load_or_force(paths, use_color_stderr());
1776        return Err(crate::msg1(PROFILE_ERR_CURRENT_NOT_SAVED, hint));
1777    }
1778    let selection = Select::new(
1779        "",
1780        vec![
1781            PROFILE_PROMPT_SAVE_AND_CONTINUE,
1782            PROFILE_PROMPT_CONTINUE_WITHOUT_SAVING,
1783            PROFILE_PROMPT_CANCEL,
1784        ],
1785    )
1786    .with_render_config(inquire_select_render_config())
1787    .prompt();
1788    prompt_unsaved_load_with(paths, reason, is_tty, selection)
1789}
1790
1791fn prompt_unsaved_load_with(
1792    paths: &Paths,
1793    reason: &str,
1794    is_tty: bool,
1795    selection: Result<&str, inquire::error::InquireError>,
1796) -> Result<LoadChoice, String> {
1797    if !is_tty {
1798        let hint = format_save_before_load_or_force(paths, use_color_stderr());
1799        return Err(crate::msg1(PROFILE_ERR_CURRENT_NOT_SAVED, hint));
1800    }
1801    let warning = format_warning(
1802        &crate::msg1(PROFILE_WARN_CURRENT_NOT_SAVED_REASON, reason),
1803        use_color_stderr(),
1804    );
1805    eprintln!("{warning}");
1806    match selection {
1807        Ok(PROFILE_PROMPT_SAVE_AND_CONTINUE) => Ok(LoadChoice::SaveAndContinue),
1808        Ok(PROFILE_PROMPT_CONTINUE_WITHOUT_SAVING) => Ok(LoadChoice::ContinueWithoutSaving),
1809        Ok(_) => Ok(LoadChoice::Cancel),
1810        Err(err) if is_inquire_cancel(&err) => Ok(LoadChoice::Cancel),
1811        Err(err) => Err(crate::msg1(PROFILE_ERR_PROMPT_LOAD, err)),
1812    }
1813}
1814
1815pub(crate) fn build_candidates(
1816    ordered: &[String],
1817    snapshot: &Snapshot,
1818    current_saved_id: Option<&str>,
1819) -> Vec<Candidate> {
1820    let mut candidates = Vec::with_capacity(ordered.len());
1821    let use_color = use_color_stderr();
1822    let labels_by_id = labels_by_id(&snapshot.labels);
1823    for id in ordered {
1824        let label = labels_by_id.get(id).cloned();
1825        let tokens = snapshot
1826            .tokens
1827            .get(id)
1828            .and_then(|result| result.as_ref().ok());
1829        let index_entry = snapshot.index.profiles.get(id);
1830        let is_current = current_saved_id == Some(id.as_str());
1831        let info = profile_info_with_fallback(tokens, index_entry, label, is_current, use_color);
1832        let marker = if is_current {
1833            current_profile_marker(use_color)
1834        } else {
1835            String::new()
1836        };
1837        candidates.push(Candidate {
1838            id: id.clone(),
1839            display: format!("{}{}", info.display, marker),
1840        });
1841    }
1842    candidates
1843}
1844
1845pub(crate) fn require_tty(action: &str) -> Result<(), String> {
1846    require_tty_with(io::stdin().is_terminal(), action)
1847}
1848
1849fn require_tty_with(is_tty: bool, action: &str) -> Result<(), String> {
1850    if is_tty {
1851        Ok(())
1852    } else {
1853        Err(crate::msg3(
1854            PROFILE_ERR_TTY_REQUIRED,
1855            action,
1856            command_name(),
1857            action,
1858        ))
1859    }
1860}
1861
1862pub(crate) fn select_single_profile(
1863    title: &str,
1864    candidates: &[Candidate],
1865) -> Result<Candidate, String> {
1866    let options = candidates.to_vec();
1867    let render_config = inquire_select_render_config();
1868    let prompt = Select::new(title, options)
1869        .with_help_message(PROFILE_LOAD_HELP)
1870        .with_render_config(render_config)
1871        .prompt();
1872    handle_inquire_result(prompt, "selection")
1873}
1874
1875pub(crate) fn select_multiple_profiles(
1876    title: &str,
1877    candidates: &[Candidate],
1878) -> Result<Vec<Candidate>, String> {
1879    let options = candidates.to_vec();
1880    let render_config = inquire_select_render_config();
1881    let prompt = MultiSelect::new(title, options)
1882        .with_help_message(PROFILE_DELETE_HELP)
1883        .with_render_config(render_config)
1884        .prompt();
1885    let selections = handle_inquire_result(prompt, "selection")?;
1886    if selections.is_empty() {
1887        return Err(CANCELLED_MESSAGE.to_string());
1888    }
1889    Ok(selections)
1890}
1891
1892pub(crate) fn select_by_label(
1893    label: &str,
1894    labels: &Labels,
1895    candidates: &[Candidate],
1896) -> Result<Candidate, String> {
1897    let id = resolve_label_id(labels, label)?;
1898    let Some(candidate) = candidates.iter().find(|candidate| candidate.id == id) else {
1899        return Err(crate::msg2(
1900            PROFILE_ERR_LABEL_NO_MATCH,
1901            label,
1902            format_list_hint(use_color_stderr()),
1903        ));
1904    };
1905    Ok(candidate.clone())
1906}
1907
1908pub(crate) fn select_by_id(id: &str, candidates: &[Candidate]) -> Result<Candidate, String> {
1909    let Some(candidate) = candidates.iter().find(|candidate| candidate.id == id) else {
1910        return Err(crate::msg2(
1911            PROFILE_ERR_ID_NO_MATCH,
1912            id,
1913            format_list_hint(use_color_stderr()),
1914        ));
1915    };
1916    Ok(candidate.clone())
1917}
1918
1919fn select_many_by_id(ids: &[String], candidates: &[Candidate]) -> Result<Vec<Candidate>, String> {
1920    let mut selections = Vec::with_capacity(ids.len());
1921    let mut seen = HashSet::new();
1922    for id in ids {
1923        if !seen.insert(id.clone()) {
1924            continue;
1925        }
1926        selections.push(select_by_id(id, candidates)?);
1927    }
1928    Ok(selections)
1929}
1930
1931pub(crate) fn confirm_delete_profiles(displays: &[String]) -> Result<bool, String> {
1932    let is_tty = io::stdin().is_terminal();
1933    if !is_tty {
1934        return Err(PROFILE_ERR_DELETE_CONFIRM_REQUIRED.to_string());
1935    }
1936    let prompt = if displays.len() == 1 {
1937        crate::msg1(PROFILE_PROMPT_DELETE_ONE, &displays[0])
1938    } else {
1939        let count = displays.len();
1940        eprintln!("{}", crate::msg1(PROFILE_PROMPT_DELETE_MANY, count));
1941        for display in displays {
1942            eprintln!(" - {display}");
1943        }
1944        PROFILE_PROMPT_DELETE_SELECTED.to_string()
1945    };
1946    let selection = Confirm::new(&prompt)
1947        .with_default(false)
1948        .with_render_config(inquire_select_render_config())
1949        .prompt();
1950    confirm_delete_profiles_with(is_tty, selection)
1951}
1952
1953fn confirm_delete_profiles_with(
1954    is_tty: bool,
1955    selection: Result<bool, inquire::error::InquireError>,
1956) -> Result<bool, String> {
1957    if !is_tty {
1958        return Err(PROFILE_ERR_DELETE_CONFIRM_REQUIRED.to_string());
1959    }
1960    match selection {
1961        Ok(value) => Ok(value),
1962        Err(err) if is_inquire_cancel(&err) => Err(CANCELLED_MESSAGE.to_string()),
1963        Err(err) => Err(crate::msg1(PROFILE_ERR_PROMPT_DELETE, err)),
1964    }
1965}
1966
1967#[derive(Clone)]
1968pub(crate) struct Candidate {
1969    pub(crate) id: String,
1970    pub(crate) display: String,
1971}
1972
1973impl fmt::Display for Candidate {
1974    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1975        let header = format_entry_header(&self.display, use_color_stderr());
1976        write!(f, "{header}")
1977    }
1978}
1979
1980fn render_entries(entries: &[Entry], ctx: &ListCtx, allow_plain_spacing: bool) -> Vec<String> {
1981    let mut lines = Vec::with_capacity((entries.len().max(1)) * 4);
1982    for (idx, entry) in entries.iter().enumerate() {
1983        let mut entry_lines = Vec::new();
1984        let mut header = format_entry_header(&entry.display, ctx.use_color);
1985        if ctx.show_id
1986            && let Some(id) = entry.id.as_deref()
1987        {
1988            header.push_str(&format_profile_id_suffix(id, ctx.use_color));
1989        }
1990        if ctx.show_current_marker && entry.is_current {
1991            header.push_str(&current_profile_marker(ctx.use_color));
1992        }
1993        let show_detail_lines = ctx.show_usage || entry.always_show_details;
1994        if !show_detail_lines {
1995            if let Some(err) = entry.error_summary.as_deref() {
1996                header.push_str(&format!("  {err}"));
1997                entry_lines.push(header);
1998            } else {
1999                entry_lines.push(header);
2000            }
2001        } else {
2002            entry_lines.push(header);
2003            entry_lines.push(String::new());
2004            entry_lines.extend(entry.details.iter().flat_map(|line| {
2005                if line.is_empty() {
2006                    vec![String::new()]
2007                } else {
2008                    line.lines()
2009                        .enumerate()
2010                        .map(|(index, part)| {
2011                            if part.is_empty() {
2012                                String::new()
2013                            } else if index == 0 {
2014                                format!(" {part}")
2015                            } else {
2016                                part.to_string()
2017                            }
2018                        })
2019                        .collect::<Vec<_>>()
2020                }
2021            }));
2022        }
2023        lines.extend(entry_lines);
2024        if idx + 1 < entries.len() {
2025            push_separator(&mut lines, allow_plain_spacing);
2026            if ctx.show_usage && allow_plain_spacing {
2027                lines.push(String::new());
2028            }
2029        }
2030    }
2031    lines
2032}
2033
2034fn push_separator(lines: &mut Vec<String>, allow_plain_spacing: bool) {
2035    if !is_plain() || allow_plain_spacing {
2036        lines.push(String::new());
2037    }
2038}
2039
2040fn current_profile_marker(use_color: bool) -> String {
2041    style_text(" <- active", use_color, |text| text.dimmed().italic())
2042}
2043
2044fn format_profile_id_suffix(id: &str, use_color: bool) -> String {
2045    style_text(&format!(" [id: {id}]"), use_color, |text| text.dimmed())
2046}
2047
2048fn make_error(
2049    id: Option<String>,
2050    label: Option<String>,
2051    index_entry: Option<&ProfileIndexEntry>,
2052    use_color: bool,
2053    message: &str,
2054    summary_label: &str,
2055    is_current: bool,
2056) -> Entry {
2057    let info = profile_info_with_fallback(None, index_entry, label.clone(), is_current, use_color);
2058    let is_saved = id.is_some();
2059    Entry {
2060        id,
2061        label,
2062        email: info.email.clone(),
2063        plan: info.plan.clone(),
2064        is_api_key: index_entry.map(|entry| entry.is_api_key).unwrap_or(false),
2065        is_saved,
2066        display: info.display,
2067        details: vec![format_error(message)],
2068        warnings: Vec::new(),
2069        usage: None,
2070        error_summary: Some(error_summary(summary_label, message)),
2071        always_show_details: false,
2072        is_current,
2073    }
2074}
2075
2076fn unavailable_lines(message: &str, use_color: bool) -> Vec<String> {
2077    let (summary, detail) = usage_message_parts(message);
2078    let mut lines = vec![format_usage_unavailable(&summary, use_color)];
2079    if let Some(detail) = detail {
2080        lines.extend(
2081            detail
2082                .lines()
2083                .filter(|line| !line.is_empty())
2084                .map(|line| format!("      {line}")),
2085        );
2086    }
2087    lines
2088}
2089
2090fn plain_error_lines(message: &str, use_color: bool) -> Vec<String> {
2091    let mut lines = message.lines();
2092    let Some(first) = lines.next() else {
2093        return Vec::new();
2094    };
2095
2096    let mut headline = first.to_string();
2097    let mut tail: Vec<String> = lines.map(str::to_string).collect();
2098    let mut merged_status = false;
2099    if let Some(second) = tail.first() {
2100        let second = second.trim();
2101        if second.starts_with("unexpected status ") {
2102            headline = format!("{headline} ({second})");
2103            tail.remove(0);
2104            merged_status = true;
2105        }
2106    }
2107
2108    let mut rendered = vec![format_error(&headline)];
2109    rendered.extend(tail.into_iter().enumerate().map(|(index, line)| {
2110        let adjusted_index = if merged_status { index + 1 } else { index };
2111        let text = if adjusted_index == 0 {
2112            line
2113        } else {
2114            format!(" {line}")
2115        };
2116        if adjusted_index == 0 {
2117            text
2118        } else {
2119            crate::ui::style_text(&text, use_color, |text| text.dimmed())
2120        }
2121    }));
2122    rendered
2123}
2124
2125fn usage_message_parts(message: &str) -> (String, Option<String>) {
2126    let normalized = normalize_error(message);
2127    let mut lines = normalized
2128        .lines()
2129        .map(str::trim)
2130        .filter(|line| !line.is_empty());
2131    let summary = lines.next().unwrap_or_default().to_string();
2132    let detail_lines: Vec<&str> = lines.collect();
2133    let detail = if detail_lines.is_empty() {
2134        None
2135    } else {
2136        Some(detail_lines.join("\n"))
2137    };
2138    (summary, detail)
2139}
2140
2141#[derive(Clone, Serialize)]
2142struct StatusUsageJson {
2143    state: &'static str,
2144    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2145    buckets: Vec<crate::usage::UsageSnapshotBucket>,
2146    #[serde(skip_serializing_if = "Option::is_none")]
2147    status_code: Option<u16>,
2148    #[serde(skip_serializing_if = "Option::is_none")]
2149    summary: Option<String>,
2150    #[serde(skip_serializing_if = "Option::is_none")]
2151    detail: Option<String>,
2152}
2153
2154impl StatusUsageJson {
2155    fn ok(buckets: Vec<crate::usage::UsageSnapshotBucket>) -> Self {
2156        Self {
2157            state: "ok",
2158            buckets,
2159            status_code: None,
2160            summary: None,
2161            detail: None,
2162        }
2163    }
2164
2165    fn from_message(state: &'static str, status_code: Option<u16>, message: &str) -> Self {
2166        let (summary, detail) = usage_message_parts(message);
2167        Self {
2168            state,
2169            buckets: Vec::new(),
2170            status_code,
2171            summary: Some(summary),
2172            detail,
2173        }
2174    }
2175
2176    fn from_fetch_error(err: &crate::usage::UsageFetchError) -> Self {
2177        Self::from_message("error", err.status_code(), &err.message())
2178    }
2179
2180    fn unavailable(message: &str) -> Self {
2181        Self::from_message("unavailable", None, message)
2182    }
2183}
2184
2185fn detail_lines(
2186    tokens: &mut Tokens,
2187    email: Option<&str>,
2188    plan: Option<&str>,
2189    ctx: &ListCtx,
2190    source_path: &Path,
2191) -> (Vec<String>, Option<String>, Option<StatusUsageJson>, bool) {
2192    let use_color = ctx.use_color;
2193    let initial_account_id = token_account_id(tokens).map(str::to_string);
2194    let access_token = tokens.access_token.clone();
2195    if is_api_key_profile(tokens) {
2196        if ctx.show_usage {
2197            let message = crate::msg2(
2198                UI_ERROR_TWO_LINE,
2199                USAGE_UNAVAILABLE_API_KEY_TITLE,
2200                USAGE_UNAVAILABLE_API_KEY_DETAIL,
2201            );
2202            return (
2203                vec![format_error(&message)],
2204                None,
2205                Some(StatusUsageJson::unavailable(&message)),
2206                false,
2207            );
2208        }
2209        return (Vec::new(), None, None, false);
2210    }
2211    let unavailable_text = usage_unavailable();
2212    if let Some(message) = profile_error(tokens, email, plan) {
2213        let missing_access = access_token.is_none() || initial_account_id.is_none();
2214        let missing_identity_only =
2215            message == AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN && !missing_access;
2216        if !missing_identity_only {
2217            if ctx.show_usage && missing_access && email.is_some() && plan.is_some() {
2218                return (
2219                    unavailable_lines(unavailable_text, use_color),
2220                    None,
2221                    Some(StatusUsageJson::unavailable(unavailable_text)),
2222                    false,
2223                );
2224            }
2225            let details = vec![format_error(message)];
2226            let summary = Some(error_summary(PROFILE_SUMMARY_ERROR, message));
2227            return (
2228                details,
2229                summary,
2230                Some(StatusUsageJson::from_message("error", None, message)),
2231                false,
2232            );
2233        }
2234    }
2235    if ctx.show_usage {
2236        if let Some(err) = ctx.base_url_error.as_deref() {
2237            return (
2238                vec![format_error(err)],
2239                Some(error_summary(PROFILE_SUMMARY_USAGE_ERROR, err)),
2240                Some(StatusUsageJson::from_message("error", None, err)),
2241                false,
2242            );
2243        }
2244        let Some(base_url) = ctx.base_url.as_deref() else {
2245            return (Vec::new(), None, None, false);
2246        };
2247        let Some(access_token) = access_token.as_deref() else {
2248            return (Vec::new(), None, None, false);
2249        };
2250        let Some(account_id) = initial_account_id.as_deref() else {
2251            return (Vec::new(), None, None, false);
2252        };
2253        match crate::usage::fetch_usage_status(
2254            base_url,
2255            access_token,
2256            account_id,
2257            unavailable_text,
2258            ctx.now,
2259        ) {
2260            Ok((details, buckets)) => (details, None, Some(StatusUsageJson::ok(buckets)), false),
2261            Err(err) if err.status_code() == Some(401) => {
2262                match crate::auth::refresh_profile_tokens(source_path, tokens) {
2263                    Ok(()) => {
2264                        let Some(access_token) = tokens.access_token.as_deref() else {
2265                            let message = AUTH_ERR_INCOMPLETE_ACCOUNT;
2266                            return (
2267                                vec![format_error(message)],
2268                                Some(error_summary(PROFILE_SUMMARY_AUTH_ERROR, message)),
2269                                Some(StatusUsageJson::from_message("error", None, message)),
2270                                true,
2271                            );
2272                        };
2273                        let Some(account_id) =
2274                            token_account_id(tokens).or(initial_account_id.as_deref())
2275                        else {
2276                            let message = AUTH_ERR_INCOMPLETE_ACCOUNT;
2277                            return (
2278                                vec![format_error(message)],
2279                                Some(error_summary(PROFILE_SUMMARY_AUTH_ERROR, message)),
2280                                Some(StatusUsageJson::from_message("error", None, message)),
2281                                true,
2282                            );
2283                        };
2284                        match crate::usage::fetch_usage_status(
2285                            base_url,
2286                            access_token,
2287                            account_id,
2288                            unavailable_text,
2289                            ctx.now,
2290                        ) {
2291                            Ok((details, buckets)) => {
2292                                (details, None, Some(StatusUsageJson::ok(buckets)), true)
2293                            }
2294                            Err(err) if err.status_code() == Some(401) => (
2295                                plain_error_lines(&err.plain_message(), use_color),
2296                                Some(error_summary(PROFILE_SUMMARY_AUTH_ERROR, &err.message())),
2297                                Some(StatusUsageJson::from_fetch_error(&err)),
2298                                true,
2299                            ),
2300                            Err(err) => (
2301                                plain_error_lines(&err.plain_message(), use_color),
2302                                Some(error_summary(PROFILE_SUMMARY_USAGE_ERROR, &err.message())),
2303                                Some(StatusUsageJson::from_fetch_error(&err)),
2304                                true,
2305                            ),
2306                        }
2307                    }
2308                    Err(err) => (
2309                        vec![format_error(&err)],
2310                        Some(error_summary(PROFILE_SUMMARY_AUTH_ERROR, &err)),
2311                        Some(StatusUsageJson::from_message("error", None, &err)),
2312                        false,
2313                    ),
2314                }
2315            }
2316            Err(err) => (
2317                plain_error_lines(&err.plain_message(), use_color),
2318                Some(error_summary(PROFILE_SUMMARY_USAGE_ERROR, &err.message())),
2319                Some(StatusUsageJson::from_fetch_error(&err)),
2320                false,
2321            ),
2322        }
2323    } else {
2324        (Vec::new(), None, None, false)
2325    }
2326}
2327
2328#[cfg(test)]
2329fn is_http_401_message(message: &str) -> bool {
2330    let message = message.to_ascii_lowercase();
2331    message.contains("(401)") || message.contains("unauthorized")
2332}
2333
2334fn make_entry(
2335    label: Option<String>,
2336    tokens_result: Option<&Result<Tokens, String>>,
2337    index_entry: Option<&ProfileIndexEntry>,
2338    profile_path: &Path,
2339    ctx: &ListCtx,
2340    is_current: bool,
2341) -> Entry {
2342    let use_color = ctx.use_color;
2343    let label_for_error = label.clone().or_else(|| profile_id_from_path(profile_path));
2344    let mut tokens = match tokens_result {
2345        Some(Ok(tokens)) => tokens.clone(),
2346        Some(Err(err)) => {
2347            return make_error(
2348                profile_id_from_path(profile_path),
2349                label_for_error,
2350                index_entry,
2351                use_color,
2352                err,
2353                PROFILE_SUMMARY_ERROR,
2354                is_current,
2355            );
2356        }
2357        None => {
2358            return make_error(
2359                profile_id_from_path(profile_path),
2360                label_for_error,
2361                index_entry,
2362                use_color,
2363                PROFILE_SUMMARY_FILE_MISSING,
2364                PROFILE_SUMMARY_ERROR,
2365                is_current,
2366            );
2367        }
2368    };
2369    let label_value = label.clone();
2370    let info = profile_info(Some(&tokens), label, is_current, use_color);
2371    let is_api_key = is_api_key_profile(&tokens);
2372    let (details, summary, usage, _) = detail_lines(
2373        &mut tokens,
2374        info.email.as_deref(),
2375        info.plan.as_deref(),
2376        ctx,
2377        profile_path,
2378    );
2379    Entry {
2380        id: profile_id_from_path(profile_path),
2381        label: label_value,
2382        email: info.email,
2383        plan: info.plan,
2384        is_api_key,
2385        is_saved: true,
2386        display: info.display,
2387        details,
2388        warnings: Vec::new(),
2389        usage,
2390        error_summary: summary,
2391        always_show_details: info.is_free,
2392        is_current,
2393    }
2394}
2395
2396fn make_saved(
2397    id: &str,
2398    snapshot: &Snapshot,
2399    labels_by_id: &BTreeMap<String, String>,
2400    current_saved_id: Option<&str>,
2401    ctx: &ListCtx,
2402) -> Entry {
2403    let profile_path = ctx.profiles_dir.join(format!("{id}.json"));
2404    let label = labels_by_id.get(id).cloned();
2405    let is_current = current_saved_id == Some(id);
2406    make_entry(
2407        label,
2408        snapshot.tokens.get(id),
2409        snapshot.index.profiles.get(id),
2410        &profile_path,
2411        ctx,
2412        is_current,
2413    )
2414}
2415
2416fn make_entries(
2417    ordered: &[String],
2418    snapshot: &Snapshot,
2419    current_saved_id: Option<&str>,
2420    ctx: &ListCtx,
2421) -> Vec<Entry> {
2422    let labels_by_id = labels_by_id(&snapshot.labels);
2423    let build = |id: &String| make_saved(id, snapshot, &labels_by_id, current_saved_id, ctx);
2424    if ctx.show_usage && ordered.len() >= 3 {
2425        let workers = usage_concurrency().min(ordered.len());
2426        if workers <= 1 {
2427            return ordered.iter().map(build).collect();
2428        }
2429        if let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(workers).build() {
2430            let mut indexed: Vec<(usize, Entry)> = pool.install(|| {
2431                ordered
2432                    .par_iter()
2433                    .enumerate()
2434                    .map(|(idx, id)| (idx, build(id)))
2435                    .collect()
2436            });
2437            indexed.sort_by_key(|(idx, _)| *idx);
2438            return indexed.into_iter().map(|(_, entry)| entry).collect();
2439        }
2440        return ordered.iter().map(build).collect();
2441    }
2442
2443    ordered.iter().map(build).collect()
2444}
2445
2446fn usage_concurrency() -> usize {
2447    env::var(USAGE_CONCURRENCY_ENV)
2448        .ok()
2449        .and_then(|value| value.trim().parse::<usize>().ok())
2450        .filter(|value| *value > 0)
2451        .map(|value| value.clamp(1, MAX_USAGE_CONCURRENCY))
2452        .unwrap_or(DEFAULT_USAGE_CONCURRENCY)
2453}
2454
2455fn make_current(
2456    paths: &Paths,
2457    current_saved_id: Option<&str>,
2458    labels: &Labels,
2459    tokens_map: &BTreeMap<String, Result<Tokens, String>>,
2460    ctx: &ListCtx,
2461) -> Option<Entry> {
2462    if !paths.auth.is_file() {
2463        return None;
2464    }
2465    let mut tokens = match read_tokens(&paths.auth) {
2466        Ok(tokens) => tokens,
2467        Err(err) => {
2468            return Some(make_error(
2469                None,
2470                None,
2471                None,
2472                ctx.use_color,
2473                &err,
2474                PROFILE_SUMMARY_ERROR,
2475                true,
2476            ));
2477        }
2478    };
2479    let resolved_saved_id = extract_profile_identity(&tokens).and_then(|identity| {
2480        let candidates = cached_profile_ids(tokens_map, &identity);
2481        pick_primary(&candidates)
2482    });
2483    let effective_saved_id = current_saved_id.or(resolved_saved_id.as_deref());
2484    let label = effective_saved_id.and_then(|id| label_for_id(labels, id));
2485    let use_color = ctx.use_color;
2486    let label_value = label.clone();
2487    let info = profile_info(Some(&tokens), label, true, use_color);
2488    let plan_is_free = info.is_free;
2489    let is_api_key = is_api_key_profile(&tokens);
2490    let can_save = is_profile_ready(&tokens);
2491    let is_unsaved = effective_saved_id.is_none() && can_save;
2492    let (mut details, mut summary, mut usage, refreshed) = detail_lines(
2493        &mut tokens,
2494        info.email.as_deref(),
2495        info.plan.as_deref(),
2496        ctx,
2497        &paths.auth,
2498    );
2499    if refreshed && let Some(saved_id) = effective_saved_id {
2500        let target = profile_path_for_id(&ctx.profiles_dir, saved_id);
2501        if let Err(err) = sync_profile(paths, &target) {
2502            details = vec![format_error(&err)];
2503            summary = Some(error_summary(PROFILE_SUMMARY_ERROR, &err));
2504            usage = Some(StatusUsageJson::from_message("error", None, &err));
2505        }
2506    }
2507
2508    let warnings = if is_unsaved {
2509        format_unsaved_warning(false)
2510    } else {
2511        Vec::new()
2512    };
2513
2514    if is_unsaved {
2515        if use_color {
2516            details.extend(format_unsaved_warning(true));
2517        } else {
2518            details.extend(warnings.clone());
2519        }
2520    }
2521
2522    Some(Entry {
2523        id: effective_saved_id.map(str::to_string),
2524        label: label_value,
2525        email: info.email,
2526        plan: info.plan,
2527        is_api_key,
2528        is_saved: effective_saved_id.is_some(),
2529        display: info.display,
2530        details,
2531        warnings,
2532        usage,
2533        error_summary: summary,
2534        always_show_details: is_unsaved || (plan_is_free && !ctx.show_usage),
2535        is_current: true,
2536    })
2537}
2538
2539fn error_summary(label: &str, message: &str) -> String {
2540    let (summary, _) = usage_message_parts(message);
2541    format!("{label}: {summary}")
2542}
2543
2544struct ListCtx {
2545    base_url: Option<String>,
2546    base_url_error: Option<String>,
2547    now: DateTime<Local>,
2548    show_usage: bool,
2549    show_current_marker: bool,
2550    show_id: bool,
2551    use_color: bool,
2552    profiles_dir: PathBuf,
2553}
2554
2555impl ListCtx {
2556    fn new(paths: &Paths, show_usage: bool, show_current_marker: bool, show_id: bool) -> Self {
2557        let (base_url, base_url_error) = if show_usage {
2558            match read_base_url(paths) {
2559                Ok(url) => (Some(url), None),
2560                Err(err) => (None, Some(err)),
2561            }
2562        } else {
2563            (None, None)
2564        };
2565
2566        Self {
2567            base_url,
2568            base_url_error,
2569            now: Local::now(),
2570            show_usage,
2571            show_current_marker,
2572            show_id,
2573            use_color: use_color_stdout(),
2574            profiles_dir: paths.profiles.clone(),
2575        }
2576    }
2577}
2578
2579#[derive(Clone)]
2580struct Entry {
2581    id: Option<String>,
2582    label: Option<String>,
2583    email: Option<String>,
2584    plan: Option<String>,
2585    is_api_key: bool,
2586    is_saved: bool,
2587    display: String,
2588    details: Vec<String>,
2589    warnings: Vec<String>,
2590    usage: Option<StatusUsageJson>,
2591    error_summary: Option<String>,
2592    always_show_details: bool,
2593    is_current: bool,
2594}
2595
2596#[derive(Serialize)]
2597struct ListedProfile {
2598    id: Option<String>,
2599    label: Option<String>,
2600    email: Option<String>,
2601    plan: Option<String>,
2602    is_current: bool,
2603    is_saved: bool,
2604    is_api_key: bool,
2605    error: Option<String>,
2606}
2607
2608#[derive(Serialize)]
2609struct ListedProfiles {
2610    profiles: Vec<ListedProfile>,
2611}
2612
2613#[derive(Serialize)]
2614struct StatusProfileJson {
2615    id: Option<String>,
2616    label: Option<String>,
2617    email: Option<String>,
2618    plan: Option<String>,
2619    is_current: bool,
2620    is_saved: bool,
2621    is_api_key: bool,
2622    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2623    warnings: Vec<String>,
2624    usage: Option<StatusUsageJson>,
2625    error: Option<StatusErrorJson>,
2626}
2627
2628#[derive(Serialize)]
2629struct StatusErrorJson {
2630    summary: StatusErrorSummaryJson,
2631    #[serde(skip_serializing_if = "Option::is_none")]
2632    status_code: Option<u16>,
2633    #[serde(skip_serializing_if = "Option::is_none")]
2634    detail: Option<String>,
2635}
2636
2637#[derive(Serialize)]
2638struct StatusErrorSummaryJson {
2639    message: String,
2640    #[serde(skip_serializing_if = "Option::is_none")]
2641    response: Option<serde_json::Value>,
2642}
2643
2644#[derive(Serialize)]
2645struct AllStatusJson {
2646    profiles: Vec<StatusProfileJson>,
2647}
2648
2649fn print_list_json(entries: &[Entry]) -> Result<(), String> {
2650    let profiles = entries
2651        .iter()
2652        .map(|entry| ListedProfile {
2653            id: entry.id.clone(),
2654            label: entry.label.clone(),
2655            email: entry.email.clone(),
2656            plan: entry.plan.clone(),
2657            is_current: entry.is_current,
2658            is_saved: entry.is_saved,
2659            is_api_key: entry.is_api_key,
2660            error: entry.error_summary.clone(),
2661        })
2662        .collect();
2663    let json = serde_json::to_string_pretty(&ListedProfiles { profiles })
2664        .map_err(|err| crate::msg1(PROFILE_ERR_SERIALIZE_INDEX, err))?;
2665    println!("{json}");
2666    Ok(())
2667}
2668
2669fn status_error_summary_json(summary: String) -> StatusErrorSummaryJson {
2670    let summary = crate::sanitize_for_terminal(&summary);
2671    let Some((start, end, response)) = extract_embedded_json_object(&summary) else {
2672        return StatusErrorSummaryJson {
2673            message: summary,
2674            response: None,
2675        };
2676    };
2677
2678    StatusErrorSummaryJson {
2679        message: strip_embedded_json_segment(&summary, start, end),
2680        response: Some(response),
2681    }
2682}
2683
2684fn extract_embedded_json_object(summary: &str) -> Option<(usize, usize, serde_json::Value)> {
2685    for (start, ch) in summary.char_indices() {
2686        if ch != '{' {
2687            continue;
2688        }
2689        let Some(end) = find_json_object_end(summary, start) else {
2690            continue;
2691        };
2692        let candidate = &summary[start..end];
2693        let Ok(value) = serde_json::from_str::<serde_json::Value>(candidate) else {
2694            continue;
2695        };
2696        return Some((start, end, value));
2697    }
2698    None
2699}
2700
2701fn find_json_object_end(text: &str, start: usize) -> Option<usize> {
2702    let mut depth = 0usize;
2703    let mut in_string = false;
2704    let mut escaped = false;
2705
2706    for (offset, ch) in text[start..].char_indices() {
2707        let idx = start + offset;
2708        if in_string {
2709            if escaped {
2710                escaped = false;
2711                continue;
2712            }
2713            match ch {
2714                '\\' => escaped = true,
2715                '"' => in_string = false,
2716                _ => {}
2717            }
2718            continue;
2719        }
2720
2721        match ch {
2722            '"' => in_string = true,
2723            '{' => depth += 1,
2724            '}' => {
2725                if depth == 0 {
2726                    return None;
2727                }
2728                depth -= 1;
2729                if depth == 0 {
2730                    return Some(idx + ch.len_utf8());
2731                }
2732            }
2733            _ => {}
2734        }
2735    }
2736
2737    None
2738}
2739
2740fn strip_embedded_json_segment(text: &str, start: usize, end: usize) -> String {
2741    let left = text[..start].trim_end_matches([' ', ':']);
2742    let right = text[end..].trim_start_matches([',', ' ']);
2743    match (left.is_empty(), right.is_empty()) {
2744        (true, true) => String::new(),
2745        (true, false) => right.to_string(),
2746        (false, true) => left.to_string(),
2747        (false, false) => format!("{left}, {right}"),
2748    }
2749}
2750
2751fn status_profile_json(entry: Entry) -> StatusProfileJson {
2752    let mut usage = entry.usage.map(|usage| StatusUsageJson {
2753        state: usage.state,
2754        buckets: usage.buckets,
2755        status_code: usage.status_code,
2756        summary: usage
2757            .summary
2758            .map(|summary| crate::sanitize_for_terminal(&summary)),
2759        detail: usage
2760            .detail
2761            .map(|detail| crate::sanitize_for_terminal(&detail)),
2762    });
2763    let mut top_level_summary = entry
2764        .error_summary
2765        .map(|error| crate::sanitize_for_terminal(&error));
2766    let mut error = None;
2767    if let Some(usage_json) = usage.as_mut()
2768        && usage_json.state == "error"
2769    {
2770        let status_code = usage_json.status_code.take();
2771        let detail = usage_json.detail.take();
2772        let usage_summary = usage_json.summary.take();
2773        let summary = top_level_summary.take().or(usage_summary);
2774        error = summary.map(|summary| StatusErrorJson {
2775            summary: status_error_summary_json(summary),
2776            status_code,
2777            detail,
2778        });
2779    }
2780    if error.is_none() {
2781        error = top_level_summary.map(|summary| StatusErrorJson {
2782            summary: status_error_summary_json(summary),
2783            status_code: None,
2784            detail: None,
2785        });
2786    }
2787
2788    StatusProfileJson {
2789        id: entry.id,
2790        label: entry.label,
2791        email: entry.email,
2792        plan: entry.plan,
2793        is_current: entry.is_current,
2794        is_saved: entry.is_saved,
2795        is_api_key: entry.is_api_key,
2796        warnings: entry
2797            .warnings
2798            .into_iter()
2799            .map(|warning| crate::sanitize_for_terminal(&warning))
2800            .collect(),
2801        usage,
2802        error,
2803    }
2804}
2805
2806fn print_current_status_json(current: Option<Entry>) -> Result<(), String> {
2807    let payload = current.map(status_profile_json);
2808    let json = serde_json::to_string_pretty(&payload)
2809        .map_err(|err| crate::msg1(PROFILE_ERR_SERIALIZE_INDEX, err))?;
2810    println!("{json}");
2811    Ok(())
2812}
2813
2814fn print_all_status_json(profiles: Vec<Entry>) -> Result<(), String> {
2815    let payload = AllStatusJson {
2816        profiles: profiles.into_iter().map(status_profile_json).collect(),
2817    };
2818    let json = serde_json::to_string_pretty(&payload)
2819        .map_err(|err| crate::msg1(PROFILE_ERR_SERIALIZE_INDEX, err))?;
2820    println!("{json}");
2821    Ok(())
2822}
2823
2824fn handle_inquire_result<T>(
2825    result: Result<T, inquire::error::InquireError>,
2826    context: &str,
2827) -> Result<T, String> {
2828    match result {
2829        Ok(value) => Ok(value),
2830        Err(err) if is_inquire_cancel(&err) => Err(CANCELLED_MESSAGE.to_string()),
2831        Err(err) => Err(crate::msg2(PROFILE_ERR_PROMPT_CONTEXT, context, err)),
2832    }
2833}
2834
2835fn trim_label(label: &str) -> Result<&str, String> {
2836    let trimmed = label.trim();
2837    if trimmed.is_empty() {
2838        return Err(PROFILE_ERR_LABEL_EMPTY.to_string());
2839    }
2840    Ok(trimmed)
2841}
2842
2843fn is_profile_file(path: &Path) -> bool {
2844    let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
2845        return false;
2846    };
2847    if ext != "json" {
2848        return false;
2849    }
2850    !matches!(
2851        path.file_name().and_then(|name| name.to_str()),
2852        Some("profiles.json" | "update.json")
2853    )
2854}
2855
2856#[cfg(test)]
2857mod tests {
2858    use super::*;
2859    use crate::test_utils::{build_id_token, make_paths, set_env_guard};
2860    use base64::Engine;
2861    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2862    use std::fs;
2863    use std::path::{Path, PathBuf};
2864
2865    fn write_auth(
2866        path: &Path,
2867        account_id: &str,
2868        email: &str,
2869        plan: &str,
2870        access: &str,
2871        refresh: &str,
2872    ) {
2873        let id_token = build_id_token(email, plan);
2874        let value = serde_json::json!({
2875            "tokens": {
2876                "account_id": account_id,
2877                "id_token": id_token,
2878                "access_token": access,
2879                "refresh_token": refresh
2880            }
2881        });
2882        fs::write(path, serde_json::to_string(&value).unwrap()).unwrap();
2883    }
2884
2885    fn write_profile(paths: &Paths, id: &str, account_id: &str, email: &str, plan: &str) {
2886        let id_token = build_id_token(email, plan);
2887        let value = serde_json::json!({
2888            "tokens": {
2889                "account_id": account_id,
2890                "id_token": id_token,
2891                "access_token": "acc",
2892                "refresh_token": "ref"
2893            }
2894        });
2895        let path = profile_path_for_id(&paths.profiles, id);
2896        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
2897    }
2898
2899    fn build_id_token_with_user(email: &str, plan: &str, user_id: &str) -> String {
2900        let header = serde_json::json!({
2901            "alg": "none",
2902            "typ": "JWT",
2903        });
2904        let auth = serde_json::json!({
2905            "chatgpt_plan_type": plan,
2906            "chatgpt_user_id": user_id,
2907        });
2908        let payload = serde_json::json!({
2909            "email": email,
2910            "https://api.openai.com/auth": auth,
2911        });
2912        let header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
2913        let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
2914        format!("{header}.{payload}.")
2915    }
2916
2917    fn write_auth_with_user(
2918        path: &Path,
2919        account_id: &str,
2920        email: &str,
2921        plan: &str,
2922        user_id: &str,
2923        access: &str,
2924        refresh: &str,
2925    ) {
2926        let id_token = build_id_token_with_user(email, plan, user_id);
2927        let value = serde_json::json!({
2928            "tokens": {
2929                "account_id": account_id,
2930                "id_token": id_token,
2931                "access_token": access,
2932                "refresh_token": refresh
2933            }
2934        });
2935        fs::write(path, serde_json::to_string(&value).unwrap()).unwrap();
2936    }
2937
2938    fn make_identity(principal: &str, workspace: &str, plan: &str) -> ProfileIdentityKey {
2939        ProfileIdentityKey {
2940            principal_id: principal.to_string(),
2941            workspace_or_org_id: workspace.to_string(),
2942            plan_type: plan.to_string(),
2943        }
2944    }
2945
2946    fn make_tokens(account_id: &str, email: &str, plan: &str) -> Tokens {
2947        Tokens {
2948            account_id: Some(account_id.to_string()),
2949            id_token: Some(build_id_token(email, plan)),
2950            access_token: Some("acc".to_string()),
2951            refresh_token: Some("ref".to_string()),
2952        }
2953    }
2954
2955    #[test]
2956    fn require_tty_with_variants() {
2957        assert!(require_tty_with(true, "load").is_ok());
2958        let err = require_tty_with(false, "load").unwrap_err();
2959        assert!(err.contains("requires a TTY"));
2960    }
2961
2962    #[test]
2963    fn prompt_unsaved_load_with_variants() {
2964        let dir = tempfile::tempdir().expect("tempdir");
2965        let paths = make_paths(dir.path());
2966        let err = prompt_unsaved_load_with(&paths, "reason", false, Ok(PROFILE_PROMPT_CANCEL))
2967            .unwrap_err();
2968        assert!(err.contains("not saved"));
2969        assert!(matches!(
2970            prompt_unsaved_load_with(&paths, "reason", true, Ok(PROFILE_PROMPT_SAVE_AND_CONTINUE))
2971                .unwrap(),
2972            LoadChoice::SaveAndContinue
2973        ));
2974        assert!(matches!(
2975            prompt_unsaved_load_with(
2976                &paths,
2977                "reason",
2978                true,
2979                Ok(PROFILE_PROMPT_CONTINUE_WITHOUT_SAVING)
2980            )
2981            .unwrap(),
2982            LoadChoice::ContinueWithoutSaving
2983        ));
2984        assert!(matches!(
2985            prompt_unsaved_load_with(&paths, "reason", true, Ok(PROFILE_PROMPT_CANCEL)).unwrap(),
2986            LoadChoice::Cancel
2987        ));
2988        let err = prompt_unsaved_load_with(
2989            &paths,
2990            "reason",
2991            true,
2992            Err(inquire::error::InquireError::OperationCanceled),
2993        )
2994        .unwrap();
2995        assert!(matches!(err, LoadChoice::Cancel));
2996    }
2997
2998    #[test]
2999    fn confirm_delete_profiles_with_variants() {
3000        let err = confirm_delete_profiles_with(false, Ok(true)).unwrap_err();
3001        assert!(err.contains("requires confirmation"));
3002        assert!(confirm_delete_profiles_with(true, Ok(true)).unwrap());
3003        let err = confirm_delete_profiles_with(
3004            true,
3005            Err(inquire::error::InquireError::OperationCanceled),
3006        )
3007        .unwrap_err();
3008        assert_eq!(err, CANCELLED_MESSAGE);
3009    }
3010
3011    #[test]
3012    fn label_helpers() {
3013        let mut labels = Labels::new();
3014        assign_label(&mut labels, "Team", "id").unwrap();
3015        assert_eq!(label_for_id(&labels, "id").unwrap(), "Team");
3016        assert_eq!(resolve_label_id(&labels, "Team").unwrap(), "id");
3017        remove_labels_for_id(&mut labels, "id");
3018        assert!(labels.is_empty());
3019        assert!(trim_label(" ").is_err());
3020    }
3021
3022    #[test]
3023    fn ordered_profile_ids_prefers_current_then_label_then_email() {
3024        let mut labels = Labels::new();
3025        labels.insert("alpha".to_string(), "id-a".to_string());
3026        labels.insert("beta".to_string(), "id-b".to_string());
3027        labels.insert("zeta".to_string(), "id-z".to_string());
3028
3029        let mut tokens = BTreeMap::new();
3030        tokens.insert(
3031            "id-z".to_string(),
3032            Ok(make_tokens("acct-z", "z@ex.com", "team")),
3033        );
3034        tokens.insert(
3035            "id-a".to_string(),
3036            Ok(make_tokens("acct-a", "a@ex.com", "team")),
3037        );
3038        tokens.insert(
3039            "id-u1".to_string(),
3040            Ok(make_tokens("acct-u1", "c@ex.com", "team")),
3041        );
3042        tokens.insert(
3043            "id-u2".to_string(),
3044            Ok(make_tokens("acct-u2", "b@ex.com", "team")),
3045        );
3046        tokens.insert(
3047            "id-b".to_string(),
3048            Ok(make_tokens("acct-b", "d@ex.com", "team")),
3049        );
3050
3051        let snapshot = Snapshot {
3052            labels,
3053            tokens,
3054            index: ProfilesIndex::default(),
3055        };
3056        let ordered = ordered_profile_ids(&snapshot, Some("id-z"));
3057        assert_eq!(ordered, vec!["id-z", "id-a", "id-b", "id-u2", "id-u1"]);
3058    }
3059
3060    #[test]
3061    fn usage_concurrency_defaults_and_clamps() {
3062        let _unset = set_env_guard(USAGE_CONCURRENCY_ENV, None);
3063        assert_eq!(usage_concurrency(), DEFAULT_USAGE_CONCURRENCY);
3064
3065        let _zero = set_env_guard(USAGE_CONCURRENCY_ENV, Some("0"));
3066        assert_eq!(usage_concurrency(), DEFAULT_USAGE_CONCURRENCY);
3067
3068        let _bad = set_env_guard(USAGE_CONCURRENCY_ENV, Some("oops"));
3069        assert_eq!(usage_concurrency(), DEFAULT_USAGE_CONCURRENCY);
3070
3071        let _small = set_env_guard(USAGE_CONCURRENCY_ENV, Some("3"));
3072        assert_eq!(usage_concurrency(), 3);
3073
3074        let _high = set_env_guard(USAGE_CONCURRENCY_ENV, Some("999"));
3075        assert_eq!(usage_concurrency(), MAX_USAGE_CONCURRENCY);
3076    }
3077
3078    #[test]
3079    fn profiles_index_roundtrip() {
3080        let dir = tempfile::tempdir().expect("tempdir");
3081        let paths = make_paths(dir.path());
3082        let mut index = ProfilesIndex::default();
3083        index.profiles.insert(
3084            "id".to_string(),
3085            ProfileIndexEntry {
3086                account_id: Some("acct".to_string()),
3087                email: Some("me@example.com".to_string()),
3088                plan: Some("Team".to_string()),
3089                label: Some("work".to_string()),
3090                is_api_key: false,
3091                principal_id: Some("principal-1".to_string()),
3092                workspace_or_org_id: Some("workspace-1".to_string()),
3093                plan_type_key: Some("team".to_string()),
3094            },
3095        );
3096        write_profiles_index(&paths, &index).unwrap();
3097        let read_back = read_profiles_index(&paths).unwrap();
3098        let entry = read_back.profiles.get("id").unwrap();
3099        assert_eq!(entry.account_id.as_deref(), Some("acct"));
3100        assert_eq!(entry.email.as_deref(), Some("me@example.com"));
3101        assert_eq!(entry.plan.as_deref(), Some("Team"));
3102        assert_eq!(entry.label.as_deref(), Some("work"));
3103        assert!(!entry.is_api_key);
3104        assert_eq!(entry.principal_id.as_deref(), Some("principal-1"));
3105        assert_eq!(entry.workspace_or_org_id.as_deref(), Some("workspace-1"));
3106        assert_eq!(entry.plan_type_key.as_deref(), Some("team"));
3107    }
3108
3109    #[test]
3110    fn read_profiles_index_does_not_rewrite_when_legacy_strings_only_appear_in_values() {
3111        let dir = tempfile::tempdir().expect("tempdir");
3112        let paths = make_paths(dir.path());
3113        fs::create_dir_all(&paths.profiles).unwrap();
3114        let raw = serde_json::json!({
3115            "version": PROFILES_INDEX_VERSION,
3116            "profiles": {
3117                "id": {
3118                    "label": "default_profile_id update_cache active_profile_id last_used",
3119                    "is_api_key": false
3120                }
3121            }
3122        })
3123        .to_string();
3124        fs::write(&paths.profiles_index, &raw).unwrap();
3125
3126        let _ = read_profiles_index(&paths).unwrap();
3127        let after = fs::read_to_string(&paths.profiles_index).unwrap();
3128        assert_eq!(after, raw);
3129    }
3130
3131    #[test]
3132    fn profiles_index_prunes_missing_profiles() {
3133        let dir = tempfile::tempdir().expect("tempdir");
3134        let paths = make_paths(dir.path());
3135        fs::create_dir_all(&paths.profiles).unwrap();
3136        let mut index = ProfilesIndex::default();
3137        index
3138            .profiles
3139            .insert("missing".to_string(), ProfileIndexEntry::default());
3140        prune_profiles_index(&mut index, &paths.profiles).unwrap();
3141        assert!(index.profiles.is_empty());
3142    }
3143
3144    #[test]
3145    fn sanitize_helpers() {
3146        assert_eq!(sanitize_part("A B"), "a-b");
3147        assert_eq!(profile_base("", ""), "unknown-unknown");
3148        let identity = make_identity("principal", "workspace123", "team");
3149        assert_eq!(short_identity_suffix(&identity), "worksp");
3150        let unknown_workspace = make_identity("principal123", "unknown", "team");
3151        assert_eq!(short_identity_suffix(&unknown_workspace), "princi");
3152    }
3153
3154    #[test]
3155    fn unique_id_conflicts() {
3156        let dir = tempfile::tempdir().expect("tempdir");
3157        let paths = make_paths(dir.path());
3158        fs::create_dir_all(&paths.profiles).unwrap();
3159        write_profile(&paths, "base", "acct", "a@b.com", "pro");
3160        let id = unique_id(
3161            "base",
3162            &make_identity("acct", "acct", "pro"),
3163            &paths.profiles,
3164        );
3165        assert_eq!(id, "base");
3166        let id = unique_id(
3167            "base",
3168            &make_identity("other", "other", "pro"),
3169            &paths.profiles,
3170        );
3171        assert!(id.starts_with("base-"));
3172    }
3173
3174    #[test]
3175    fn load_profile_tokens_map_handles_invalid() {
3176        let dir = tempfile::tempdir().expect("tempdir");
3177        let paths = make_paths(dir.path());
3178        fs::create_dir_all(&paths.profiles).unwrap();
3179        let bad_path = paths.profiles.join("bad.json");
3180        write_profile(&paths, "valid", "acct", "a@b.com", "pro");
3181        fs::write(&bad_path, "not-json").unwrap();
3182        let index = serde_json::json!({
3183            "version": 1,
3184            "active_profile_id": null,
3185            "profiles": {
3186                "bad": {
3187                    "label": "bad",
3188                    "last_used": 1,
3189                    "added_at": 1
3190                }
3191            }
3192        });
3193        fs::write(
3194            &paths.profiles_index,
3195            serde_json::to_string(&index).unwrap(),
3196        )
3197        .unwrap();
3198        let map = load_profile_tokens_map(&paths).unwrap();
3199        assert!(map.contains_key("valid"));
3200        let bad = map.get("bad").expect("bad entry retained");
3201        assert!(bad.is_err());
3202        assert!(bad_path.is_file());
3203
3204        let index_contents = fs::read_to_string(&paths.profiles_index).unwrap();
3205        assert!(index_contents.contains("\"bad\""));
3206    }
3207
3208    #[test]
3209    fn load_profile_tokens_map_ignores_update_cache_file() {
3210        let dir = tempfile::tempdir().expect("tempdir");
3211        let paths = make_paths(dir.path());
3212        fs::create_dir_all(&paths.profiles).unwrap();
3213        fs::write(
3214            &paths.update_cache,
3215            serde_json::json!({
3216                "latest_version": "0.1.0",
3217                "last_checked_at": "2026-01-01T00:00:00Z"
3218            })
3219            .to_string(),
3220        )
3221        .unwrap();
3222        let map = load_profile_tokens_map(&paths).unwrap();
3223        assert!(map.is_empty());
3224        assert!(paths.update_cache.is_file());
3225    }
3226
3227    #[cfg(unix)]
3228    #[test]
3229    fn load_profile_tokens_map_remove_error() {
3230        use std::os::unix::fs::PermissionsExt;
3231        let dir = tempfile::tempdir().expect("tempdir");
3232        let paths = make_paths(dir.path());
3233        fs::create_dir_all(&paths.profiles).unwrap();
3234        let bad_path = paths.profiles.join("bad.json");
3235        fs::write(&bad_path, "not-json").unwrap();
3236        let perms = fs::Permissions::from_mode(0o400);
3237        fs::set_permissions(&paths.profiles, perms).unwrap();
3238        let map = load_profile_tokens_map(&paths).unwrap();
3239        assert!(map.contains_key("bad"));
3240        fs::set_permissions(&paths.profiles, fs::Permissions::from_mode(0o700)).unwrap();
3241        assert!(bad_path.is_file());
3242    }
3243
3244    #[test]
3245    fn resolve_save_and_sync_ids() {
3246        let dir = tempfile::tempdir().expect("tempdir");
3247        let paths = make_paths(dir.path());
3248        fs::create_dir_all(&paths.profiles).unwrap();
3249        write_profile(&paths, "one", "acct", "a@b.com", "pro");
3250        let tokens = read_tokens(&paths.profiles.join("one.json")).unwrap();
3251        let mut index = ProfilesIndex::default();
3252        let id = resolve_save_id(&paths, &mut index, &tokens).unwrap();
3253        assert!(!id.is_empty());
3254        let id = resolve_sync_id(&paths, &mut index, &tokens).unwrap();
3255        assert!(id.is_some());
3256    }
3257
3258    #[test]
3259    fn rename_profile_id_errors_when_missing() {
3260        let dir = tempfile::tempdir().expect("tempdir");
3261        let paths = make_paths(dir.path());
3262        fs::create_dir_all(&paths.profiles).unwrap();
3263        let mut index = ProfilesIndex::default();
3264        let err = rename_profile_id(
3265            &paths,
3266            &mut index,
3267            "missing",
3268            "base",
3269            &make_identity("acct", "acct", "pro"),
3270        )
3271        .unwrap_err();
3272        assert!(err.contains("not found"));
3273    }
3274
3275    #[test]
3276    fn render_helpers() {
3277        let entry = Entry {
3278            id: Some("alpha@example.com-team".to_string()),
3279            label: Some("alpha".to_string()),
3280            email: Some("alpha@example.com".to_string()),
3281            plan: Some("team".to_string()),
3282            is_api_key: false,
3283            is_saved: true,
3284            display: "Display".to_string(),
3285            details: vec!["detail".to_string()],
3286            warnings: Vec::new(),
3287            usage: None,
3288            error_summary: None,
3289            always_show_details: true,
3290            is_current: false,
3291        };
3292        let ctx = ListCtx {
3293            base_url: None,
3294            base_url_error: None,
3295            now: chrono::Local::now(),
3296            show_usage: false,
3297            show_current_marker: false,
3298            show_id: true,
3299            use_color: false,
3300            profiles_dir: PathBuf::new(),
3301        };
3302        let lines = render_entries(&[entry], &ctx, true);
3303        assert!(!lines.is_empty());
3304        push_separator(&mut vec!["a".to_string()], true);
3305    }
3306
3307    #[test]
3308    fn render_entries_preserves_ansi_display_in_color_mode() {
3309        colored::control::set_override(true);
3310        let entry = Entry {
3311            id: Some("alpha@example.com-team".to_string()),
3312            label: Some("alpha".to_string()),
3313            email: Some("alpha@example.com".to_string()),
3314            plan: Some("team".to_string()),
3315            is_api_key: false,
3316            is_saved: true,
3317            display: "\u{1b}[32malpha@example.com\u{1b}[0m".to_string(),
3318            details: Vec::new(),
3319            warnings: Vec::new(),
3320            usage: None,
3321            error_summary: None,
3322            always_show_details: false,
3323            is_current: false,
3324        };
3325        let ctx = ListCtx {
3326            base_url: None,
3327            base_url_error: None,
3328            now: chrono::Local::now(),
3329            show_usage: false,
3330            show_current_marker: false,
3331            show_id: false,
3332            use_color: true,
3333            profiles_dir: PathBuf::new(),
3334        };
3335        let lines = render_entries(&[entry], &ctx, true);
3336        colored::control::unset_override();
3337
3338        assert!(!lines.is_empty());
3339        assert!(lines[0].contains("\u{1b}[32m"));
3340        assert_eq!(crate::ui::strip_ansi(&lines[0]), "alpha@example.com");
3341    }
3342
3343    #[test]
3344    fn plain_error_lines_merges_unexpected_status_into_summary() {
3345        let lines = plain_error_lines(
3346            "deactivated_workspace\nunexpected status 402 Payment Required\nURL: http://localhost/backend-api/wham/usage",
3347            false,
3348        );
3349
3350        assert_eq!(
3351            lines[0],
3352            "Error: deactivated_workspace (unexpected status 402 Payment Required)"
3353        );
3354        assert_eq!(lines[1], " URL: http://localhost/backend-api/wham/usage");
3355    }
3356
3357    #[test]
3358    fn render_entries_status_all_has_extra_gap_between_profiles() {
3359        let entries = vec![
3360            Entry {
3361                id: Some("one".to_string()),
3362                label: None,
3363                email: Some("one@example.com".to_string()),
3364                plan: Some("team".to_string()),
3365                is_api_key: false,
3366                is_saved: true,
3367                display: "One".to_string(),
3368                details: vec!["5 hour: 10% left".to_string()],
3369                warnings: Vec::new(),
3370                usage: None,
3371                error_summary: None,
3372                always_show_details: true,
3373                is_current: false,
3374            },
3375            Entry {
3376                id: Some("two".to_string()),
3377                label: None,
3378                email: Some("two@example.com".to_string()),
3379                plan: Some("team".to_string()),
3380                is_api_key: false,
3381                is_saved: true,
3382                display: "Two".to_string(),
3383                details: vec!["5 hour: 20% left".to_string()],
3384                warnings: Vec::new(),
3385                usage: None,
3386                error_summary: None,
3387                always_show_details: true,
3388                is_current: false,
3389            },
3390        ];
3391        let ctx = ListCtx {
3392            base_url: None,
3393            base_url_error: None,
3394            now: chrono::Local::now(),
3395            show_usage: true,
3396            show_current_marker: false,
3397            show_id: false,
3398            use_color: false,
3399            profiles_dir: PathBuf::new(),
3400        };
3401        let lines = render_entries(&entries, &ctx, true);
3402        let first_profile_last_line = 2;
3403        assert_eq!(lines[first_profile_last_line + 1], "");
3404        assert_eq!(lines[first_profile_last_line + 2], "");
3405    }
3406
3407    #[test]
3408    fn strip_ansi_sequences_removes_color_codes() {
3409        assert_eq!(crate::ui::strip_ansi("\u{1b}[31mtext\u{1b}[0m"), "text");
3410    }
3411
3412    #[test]
3413    fn handle_inquire_result_variants() {
3414        let ok: Result<i32, inquire::error::InquireError> = Ok(1);
3415        assert_eq!(handle_inquire_result(ok, "selection").unwrap(), 1);
3416        let err: Result<(), inquire::error::InquireError> =
3417            Err(inquire::error::InquireError::OperationCanceled);
3418        let err = handle_inquire_result(err, "selection").unwrap_err();
3419        assert_eq!(err, CANCELLED_MESSAGE);
3420    }
3421
3422    #[test]
3423    fn is_http_401_message_variants() {
3424        assert!(is_http_401_message(&crate::msg2(
3425            crate::UI_ERROR_TWO_LINE,
3426            crate::AUTH_REFRESH_401_TITLE,
3427            crate::AUTH_RELOGIN_AND_SAVE
3428        )));
3429        assert!(is_http_401_message("Error: Unauthorized (401)"));
3430        assert!(!is_http_401_message(&crate::msg1(
3431            "Error: {}",
3432            crate::USAGE_UNAVAILABLE_402_TITLE
3433        )));
3434    }
3435
3436    #[test]
3437    fn sync_and_status_paths() {
3438        let dir = tempfile::tempdir().expect("tempdir");
3439        let paths = make_paths(dir.path());
3440        fs::create_dir_all(&paths.profiles).unwrap();
3441        write_auth(&paths.auth, "acct", "a@b.com", "pro", "acc", "ref");
3442        crate::ensure_paths(&paths).unwrap();
3443        save_profile(&paths, Some("team".to_string()), false).unwrap();
3444        list_profiles(&paths, false, false).unwrap();
3445        status_profiles(&paths, false, None, None, false).unwrap();
3446        status_profiles(&paths, true, None, None, false).unwrap();
3447    }
3448
3449    #[test]
3450    fn delete_profile_by_label() {
3451        let dir = tempfile::tempdir().expect("tempdir");
3452        let paths = make_paths(dir.path());
3453        fs::create_dir_all(&paths.profiles).unwrap();
3454        write_auth(&paths.auth, "acct", "a@b.com", "pro", "acc", "ref");
3455        crate::ensure_paths(&paths).unwrap();
3456        save_profile(&paths, Some("team".to_string()), false).unwrap();
3457        delete_profile(&paths, true, Some("team".to_string()), vec![], false).unwrap();
3458    }
3459
3460    #[test]
3461    fn composite_identity_repeated_save_dedupes() {
3462        let dir = tempfile::tempdir().expect("tempdir");
3463        let paths = make_paths(dir.path());
3464        fs::create_dir_all(&paths.profiles).unwrap();
3465        write_auth_with_user(
3466            &paths.auth,
3467            "acct-1",
3468            "same@example.com",
3469            "pro",
3470            "user-1",
3471            "acc",
3472            "ref",
3473        );
3474        crate::ensure_paths(&paths).unwrap();
3475
3476        save_profile(&paths, None, false).unwrap();
3477        save_profile(&paths, None, false).unwrap();
3478
3479        let ids = collect_profile_ids(&paths.profiles).unwrap();
3480        assert_eq!(ids.len(), 1);
3481        assert!(ids.contains("same@example.com-pro"));
3482    }
3483
3484    #[test]
3485    fn composite_identity_keeps_team_and_pro_separate() {
3486        let dir = tempfile::tempdir().expect("tempdir");
3487        let paths = make_paths(dir.path());
3488        fs::create_dir_all(&paths.profiles).unwrap();
3489        crate::ensure_paths(&paths).unwrap();
3490
3491        write_auth_with_user(
3492            &paths.auth,
3493            "acct-1",
3494            "same@example.com",
3495            "pro",
3496            "user-1",
3497            "acc",
3498            "ref",
3499        );
3500        save_profile(&paths, None, false).unwrap();
3501
3502        write_auth_with_user(
3503            &paths.auth,
3504            "acct-1",
3505            "same@example.com",
3506            "team",
3507            "user-1",
3508            "acc",
3509            "ref",
3510        );
3511        save_profile(&paths, None, false).unwrap();
3512
3513        let ids = collect_profile_ids(&paths.profiles).unwrap();
3514        assert_eq!(ids.len(), 2);
3515        assert!(ids.contains("same@example.com-pro"));
3516        assert!(ids.contains("same@example.com-team"));
3517    }
3518
3519    #[test]
3520    fn composite_identity_separates_users_in_same_workspace_plan() {
3521        let dir = tempfile::tempdir().expect("tempdir");
3522        let paths = make_paths(dir.path());
3523        fs::create_dir_all(&paths.profiles).unwrap();
3524        crate::ensure_paths(&paths).unwrap();
3525
3526        write_auth_with_user(
3527            &paths.auth,
3528            "acct-1",
3529            "same@example.com",
3530            "pro",
3531            "user-1",
3532            "acc",
3533            "ref",
3534        );
3535        save_profile(&paths, None, false).unwrap();
3536
3537        write_auth_with_user(
3538            &paths.auth,
3539            "acct-1",
3540            "same@example.com",
3541            "pro",
3542            "user-2",
3543            "acc",
3544            "ref",
3545        );
3546        save_profile(&paths, None, false).unwrap();
3547
3548        let ids = collect_profile_ids(&paths.profiles).unwrap();
3549        assert_eq!(ids.len(), 2);
3550        assert!(ids.contains("same@example.com-pro"));
3551        assert!(
3552            ids.iter()
3553                .any(|id| id.starts_with("same@example.com-pro-acct"))
3554        );
3555    }
3556}