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(¤t_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}