Skip to main content

codex_ops/
lib.rs

1pub mod account_history;
2pub mod auth;
3pub mod cli;
4pub mod doctor;
5pub mod error;
6pub mod format;
7pub mod limits;
8pub mod pricing;
9pub mod prompt;
10pub(crate) mod session_scan;
11pub mod stats;
12pub mod storage;
13pub mod time;
14
15use crate::auth::{
16    format_auth_profile_entry, format_auth_profile_list, format_auth_status,
17    list_codex_auth_profiles, read_codex_auth_status, remove_codex_auth_profile,
18    save_current_codex_auth_profile, switch_codex_auth_profile, AuthCommandOptions,
19    AuthProfileEntry, AuthProfileListReport,
20};
21use crate::cli::{
22    AuthCliCommand, AuthCliPaths, AuthProfileCliOptions, AuthRemoveCliOptions,
23    AuthSelectCliOptions, AuthStatusCliOptions, CliCommand, DoctorCliCommand, DoctorCliPaths,
24    LimitCliCommand, ParsedCli, StatCliCommand,
25};
26use crate::doctor::{format_doctor_report, read_doctor_report, DoctorOptions};
27use crate::error::AppError;
28use crate::limits::run_limit_command;
29use crate::prompt::{DialoguerPrompt, Prompt};
30use crate::stats::run_stat_command;
31use chrono::{DateTime, Utc};
32use std::env;
33
34const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
35
36#[derive(Debug, Eq, PartialEq)]
37pub struct CliResult {
38    pub code: i32,
39    pub stdout: String,
40    pub stderr: String,
41}
42
43impl CliResult {
44    fn success(stdout: impl Into<String>) -> Self {
45        Self {
46            code: 0,
47            stdout: ensure_trailing_newline(stdout.into()),
48            stderr: String::new(),
49        }
50    }
51
52    fn app_error(error: AppError) -> Self {
53        Self {
54            code: error.exit_code(),
55            stdout: String::new(),
56            stderr: ensure_trailing_newline(error.message().to_string()),
57        }
58    }
59
60    fn parse_error(code: i32, stderr: impl Into<String>) -> Self {
61        Self {
62            code,
63            stdout: String::new(),
64            stderr: ensure_trailing_newline(stderr.into()),
65        }
66    }
67}
68
69pub fn run_cli<I, S>(args: I) -> CliResult
70where
71    I: IntoIterator<Item = S>,
72    S: Into<String>,
73{
74    let args: Vec<String> = args.into_iter().map(Into::into).collect();
75
76    match cli::parse_cli(&args) {
77        Ok(ParsedCli::Help(help)) => CliResult::success(help),
78        Ok(ParsedCli::Version) => CliResult::success(PACKAGE_VERSION),
79        Ok(ParsedCli::Command(command)) => match *command {
80            CliCommand::Auth(command) => run_auth(command),
81            CliCommand::Doctor(command) => run_doctor(command),
82            CliCommand::Stat(command) => run_stat(command),
83            CliCommand::Limit(command) => run_limit(command),
84        },
85        Err(error) => CliResult::parse_error(error.code, error.message),
86    }
87}
88
89fn run_auth(command: AuthCliCommand) -> CliResult {
90    match command {
91        AuthCliCommand::Status(options) => run_auth_status(options),
92        AuthCliCommand::Save(options) => run_auth_save(options),
93        AuthCliCommand::List(options) => run_auth_list(options),
94        AuthCliCommand::Select(options) => run_auth_select(options),
95        AuthCliCommand::Remove(options) => run_auth_remove(options),
96    }
97}
98
99fn run_auth_status(options: AuthStatusCliOptions) -> CliResult {
100    let result = (|| {
101        let auth_options = auth_command_options(&options.paths);
102        let report = read_codex_auth_status(&auth_options, cli_now()?)?;
103        format_auth_status(&report, options.json, options.include_token_claims)
104    })();
105
106    cli_result_from_string(result)
107}
108
109fn run_auth_save(options: AuthProfileCliOptions) -> CliResult {
110    let result = (|| {
111        let auth_options = auth_command_options(&options.paths);
112        let report = save_current_codex_auth_profile(&auth_options, cli_now()?)?;
113        Ok(format!(
114            "Saved auth profile: {}\nStore: {}\n",
115            format_auth_profile_entry(&report.profile),
116            report.store_dir
117        ))
118    })();
119
120    cli_result_from_string(result)
121}
122
123fn run_auth_list(options: AuthProfileCliOptions) -> CliResult {
124    let result = (|| {
125        let auth_options = auth_command_options(&options.paths);
126        let report = list_codex_auth_profiles(&auth_options, cli_now()?)?;
127        Ok(format_auth_profile_list(&report))
128    })();
129
130    cli_result_from_string(result)
131}
132
133fn run_auth_select(options: AuthSelectCliOptions) -> CliResult {
134    let result = (|| {
135        let now = cli_now()?;
136        let auth_options = auth_command_options(&options.paths);
137        let report = list_codex_auth_profiles(&auth_options, now)?;
138        if let Some(account_id) = options.account_id.as_deref() {
139            return select_auth_profile_by_account_id(account_id, &report, &auth_options, now);
140        }
141
142        if !prompt::stdin_and_stderr_are_terminals() {
143            return Err(AppError::new(
144                "auth select requires an interactive terminal unless --account-id is supplied.",
145            ));
146        }
147
148        let mut prompt = DialoguerPrompt::default();
149        select_auth_profile_interactively(&report, &auth_options, now, &mut prompt)
150    })();
151
152    cli_result_from_string(result)
153}
154
155fn run_auth_remove(options: AuthRemoveCliOptions) -> CliResult {
156    let result = (|| {
157        let now = cli_now()?;
158        let auth_options = auth_command_options(&options.paths);
159        let report = list_codex_auth_profiles(&auth_options, now)?;
160        if report.stored.is_empty() {
161            return Ok("No persisted auth profiles.\n".to_string());
162        }
163
164        if let Some(account_id) = options.account_id.as_deref() {
165            if !options.yes {
166                return Err(AppError::new(
167                    "auth remove --account-id requires --yes when not running interactively.",
168                ));
169            }
170            return remove_auth_profile_by_account_id(account_id, &report, &auth_options, now);
171        }
172
173        if !prompt::stdin_and_stderr_are_terminals() {
174            return Err(AppError::new(
175                "auth remove requires an interactive terminal unless --account-id is supplied.",
176            ));
177        }
178
179        let mut prompt = DialoguerPrompt::default();
180        remove_auth_profiles_interactively(&report, &auth_options, now, &mut prompt)
181    })();
182
183    cli_result_from_string(result)
184}
185
186fn select_auth_profile_interactively(
187    report: &AuthProfileListReport,
188    options: &AuthCommandOptions,
189    now: DateTime<Utc>,
190    prompt: &mut impl Prompt,
191) -> Result<String, AppError> {
192    if report.stored.is_empty() {
193        return Err(AppError::new("No persisted auth profiles."));
194    }
195
196    let items = report
197        .stored
198        .iter()
199        .map(format_auth_profile_entry)
200        .collect::<Vec<_>>();
201    let selected_index = prompt
202        .select("Select auth profile", &items)?
203        .ok_or_else(|| AppError::new("auth select cancelled."))?;
204    let selected = report
205        .stored
206        .get(selected_index)
207        .ok_or_else(|| AppError::new("Prompt returned an invalid auth profile selection."))?;
208
209    select_auth_profile_entry(selected, report, options, now)
210}
211
212fn select_auth_profile_by_account_id(
213    account_id: &str,
214    report: &AuthProfileListReport,
215    options: &AuthCommandOptions,
216    now: DateTime<Utc>,
217) -> Result<String, AppError> {
218    let selected = report
219        .stored
220        .iter()
221        .find(|entry| entry.account_id == account_id)
222        .ok_or_else(|| {
223            AppError::new(format!(
224                "No persisted auth profile found for account id: {account_id}"
225            ))
226        })?;
227
228    select_auth_profile_entry(selected, report, options, now)
229}
230
231fn select_auth_profile_entry(
232    selected: &AuthProfileEntry,
233    report: &AuthProfileListReport,
234    options: &AuthCommandOptions,
235    now: DateTime<Utc>,
236) -> Result<String, AppError> {
237    if Some(&selected.account_id) == report.current.as_ref().map(|entry| &entry.account_id) {
238        return Ok(format!(
239            "Auth profile already active: {}\n",
240            format_auth_profile_entry(selected)
241        ));
242    }
243
244    let switched = switch_codex_auth_profile(&selected.account_id, options, now)?;
245    Ok(format!(
246        "Saved current auth profile: {}\nActivated auth profile: {}\n",
247        format_auth_profile_entry(&switched.saved_current),
248        format_auth_profile_entry(&switched.activated)
249    ))
250}
251
252fn remove_auth_profiles_interactively(
253    report: &AuthProfileListReport,
254    options: &AuthCommandOptions,
255    now: DateTime<Utc>,
256    prompt: &mut impl Prompt,
257) -> Result<String, AppError> {
258    let current_account_id = report
259        .current
260        .as_ref()
261        .map(|entry| entry.account_id.as_str());
262    let candidates = report
263        .stored
264        .iter()
265        .filter(|entry| Some(entry.account_id.as_str()) != current_account_id)
266        .collect::<Vec<_>>();
267    if candidates.is_empty() {
268        return Ok("No removable persisted auth profiles.\n".to_string());
269    }
270
271    let items = candidates
272        .iter()
273        .map(|entry| format_auth_profile_entry(entry))
274        .collect::<Vec<_>>();
275    let selected_indices = prompt
276        .multi_select("Remove auth profiles", &items)?
277        .ok_or_else(|| AppError::new("auth remove cancelled."))?;
278    if selected_indices.is_empty() {
279        return Err(AppError::new("auth remove cancelled."));
280    }
281
282    let selected = selected_indices
283        .into_iter()
284        .map(|index| {
285            candidates
286                .get(index)
287                .copied()
288                .ok_or_else(|| AppError::new("Prompt returned an invalid auth profile selection."))
289        })
290        .collect::<Result<Vec<_>, _>>()?;
291    let confirmed = prompt
292        .confirm("Remove selected auth profiles?", false)?
293        .unwrap_or(false);
294    if !confirmed {
295        return Err(AppError::new("auth remove cancelled."));
296    }
297
298    selected
299        .into_iter()
300        .map(|entry| remove_auth_profile_entry(entry, options, now))
301        .collect::<Result<Vec<_>, _>>()
302        .map(|lines| lines.join("\n"))
303}
304
305fn remove_auth_profile_by_account_id(
306    account_id: &str,
307    report: &AuthProfileListReport,
308    options: &AuthCommandOptions,
309    now: DateTime<Utc>,
310) -> Result<String, AppError> {
311    let selected = report
312        .stored
313        .iter()
314        .find(|entry| entry.account_id == account_id)
315        .ok_or_else(|| {
316            AppError::new(format!(
317                "No persisted auth profile found for account id: {account_id}"
318            ))
319        })?;
320
321    remove_auth_profile_entry(selected, options, now)
322}
323
324fn remove_auth_profile_entry(
325    selected: &AuthProfileEntry,
326    options: &AuthCommandOptions,
327    now: DateTime<Utc>,
328) -> Result<String, AppError> {
329    let removed = remove_codex_auth_profile(&selected.account_id, options, now)?;
330    Ok(format!(
331        "Removed auth profile: {}",
332        format_auth_profile_entry(&removed.removed)
333    ))
334}
335
336fn run_doctor(command: DoctorCliCommand) -> CliResult {
337    let DoctorCliCommand::Run(options) = command;
338    let result = (|| {
339        let doctor_options = doctor_command_options(&options.paths);
340        let report = read_doctor_report(&doctor_options, cli_now()?);
341        format_doctor_report(&report, options.json)
342    })();
343
344    cli_result_from_string(result)
345}
346
347fn run_stat(command: StatCliCommand) -> CliResult {
348    let result = (|| {
349        run_stat_command(
350            command.view.as_deref(),
351            command.session.as_deref(),
352            command.options,
353            cli_now()?,
354        )
355    })();
356    cli_result_from_string(result)
357}
358
359fn run_limit(command: LimitCliCommand) -> CliResult {
360    let result = (|| run_limit_command(command.command, command.options, cli_now()?))();
361    cli_result_from_string(result)
362}
363
364fn auth_command_options(paths: &AuthCliPaths) -> AuthCommandOptions {
365    AuthCommandOptions {
366        auth_file: paths.auth_file.clone(),
367        codex_home: paths.codex_home.clone(),
368        store_dir: paths.store_dir.clone(),
369        account_history_file: paths.account_history_file.clone(),
370    }
371}
372
373fn doctor_command_options(paths: &DoctorCliPaths) -> DoctorOptions {
374    DoctorOptions {
375        auth_file: paths.auth_file.clone(),
376        codex_home: paths.codex_home.clone(),
377        sessions_dir: paths.sessions_dir.clone(),
378    }
379}
380
381fn cli_now() -> Result<DateTime<Utc>, AppError> {
382    match env::var("CODEX_OPS_FIXED_NOW") {
383        Ok(value) if !value.trim().is_empty() => DateTime::parse_from_rfc3339(value.trim())
384            .map(|date| date.with_timezone(&Utc))
385            .map_err(|_| {
386                AppError::new("Invalid CODEX_OPS_FIXED_NOW. Expected an ISO date string.")
387            }),
388        _ => Ok(Utc::now()),
389    }
390}
391
392fn cli_result_from_string(result: Result<String, AppError>) -> CliResult {
393    match result {
394        Ok(stdout) => CliResult::success(stdout),
395        Err(error) => CliResult::app_error(error),
396    }
397}
398
399fn ensure_trailing_newline(mut value: String) -> String {
400    if !value.ends_with('\n') {
401        value.push('\n');
402    }
403    value
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn root_help_lists_top_level_commands() {
412        let result = run_cli(["--help"]);
413
414        assert_eq!(result.code, 0);
415        assert!(result.stderr.is_empty());
416        assert!(result.stdout.contains("Usage: codex-ops"));
417        assert!(result.stdout.contains("auth"));
418        assert!(result.stdout.contains("doctor"));
419        assert!(result.stdout.contains("stat"));
420        assert!(result.stdout.contains("limit"));
421        assert!(!result.stdout.contains("cycle"));
422    }
423
424    #[test]
425    fn auth_help_lists_profile_commands() {
426        let result = run_cli(["auth", "--help"]);
427
428        assert_eq!(result.code, 0);
429        assert!(result.stdout.contains("status"));
430        assert!(result.stdout.contains("save"));
431        assert!(result.stdout.contains("list"));
432        assert!(result.stdout.contains("select"));
433        assert!(result.stdout.contains("remove"));
434    }
435
436    #[test]
437    fn child_help_works() {
438        let result = run_cli(["stat", "--help"]);
439
440        assert_eq!(result.code, 0);
441        assert!(result.stdout.contains("--group-by"));
442        assert!(result.stdout.contains("sessions"));
443    }
444
445    #[test]
446    fn unknown_command_returns_error() {
447        let result = run_cli(["missing"]);
448
449        assert_eq!(result.code, 2);
450        assert!(result.stderr.contains("unrecognized subcommand 'missing'"));
451    }
452
453    #[test]
454    fn removed_cycle_command_returns_unknown_command() {
455        let result = run_cli(["cycle", "current"]);
456
457        assert_eq!(result.code, 2);
458        assert!(result.stdout.is_empty());
459        assert!(result.stderr.contains("unrecognized subcommand 'cycle'"));
460    }
461
462    #[test]
463    fn interactive_auth_select_switches_profile_and_writes_history() {
464        let fixture = AuthPromptFixture::new("select-switch");
465        let now = fixed_now();
466        let current_content = auth_content("account-a", "a@example.test", "plus");
467        let selected_content = auth_content("account-b", "b@example.test", "pro");
468
469        fixture.save_profile(&selected_content, now);
470        std::fs::write(&fixture.auth_file, &current_content).expect("write current auth");
471        let options = fixture.options();
472        let report = list_codex_auth_profiles(&options, now).expect("list profiles");
473        let mut prompt = FakePrompt {
474            select: Some(Some(0)),
475            ..FakePrompt::default()
476        };
477
478        let output =
479            select_auth_profile_interactively(&report, &options, now, &mut prompt).unwrap();
480
481        assert!(output.contains("Activated auth profile: b@example.test(account-b) - pro"));
482        assert_eq!(
483            std::fs::read_to_string(&fixture.auth_file).expect("read auth"),
484            selected_content
485        );
486        let history: serde_json::Value = serde_json::from_str(
487            &std::fs::read_to_string(&fixture.history_file).expect("read history"),
488        )
489        .expect("parse history");
490        assert_eq!(history["switches"][0]["fromAccountId"], "account-a");
491        assert_eq!(history["switches"][0]["toAccountId"], "account-b");
492        assert_eq!(
493            prompt.select_items[0],
494            vec!["b@example.test(account-b) - pro".to_string()]
495        );
496    }
497
498    #[test]
499    fn interactive_auth_select_cancel_has_no_side_effects() {
500        let fixture = AuthPromptFixture::new("select-cancel");
501        let now = fixed_now();
502        let current_content = auth_content("account-a", "a@example.test", "plus");
503        let selected_content = auth_content("account-b", "b@example.test", "pro");
504
505        fixture.save_profile(&selected_content, now);
506        std::fs::write(&fixture.auth_file, &current_content).expect("write current auth");
507        let options = fixture.options();
508        let report = list_codex_auth_profiles(&options, now).expect("list profiles");
509        let mut prompt = FakePrompt {
510            select: Some(None),
511            ..FakePrompt::default()
512        };
513
514        let error =
515            select_auth_profile_interactively(&report, &options, now, &mut prompt).unwrap_err();
516
517        assert_eq!(error.message(), "auth select cancelled.");
518        assert_eq!(
519            std::fs::read_to_string(&fixture.auth_file).expect("read auth"),
520            current_content
521        );
522        assert!(!fixture.history_file.exists());
523    }
524
525    #[test]
526    fn interactive_auth_remove_deletes_selected_profiles_but_not_current() {
527        let fixture = AuthPromptFixture::new("remove-selected");
528        let now = fixed_now();
529        let current_content = auth_content("account-a", "a@example.test", "plus");
530        let other_content = auth_content("account-b", "b@example.test", "pro");
531        let third_content = auth_content("account-c", "c@example.test", "team");
532
533        fixture.save_profile(&current_content, now);
534        fixture.save_profile(&other_content, now);
535        fixture.save_profile(&third_content, now);
536        std::fs::write(&fixture.auth_file, &current_content).expect("write current auth");
537        let options = fixture.options();
538        let report = list_codex_auth_profiles(&options, now).expect("list profiles");
539        let mut prompt = FakePrompt {
540            multi_select: Some(Some(vec![0])),
541            confirm: Some(Some(true)),
542            ..FakePrompt::default()
543        };
544
545        let output =
546            remove_auth_profiles_interactively(&report, &options, now, &mut prompt).unwrap();
547
548        assert!(output.contains("Removed auth profile: b@example.test(account-b) - pro"));
549        assert_eq!(
550            prompt.multi_select_items[0],
551            vec![
552                "b@example.test(account-b) - pro".to_string(),
553                "c@example.test(account-c) - team".to_string()
554            ]
555        );
556        assert!(fixture.profile_file("account-a").exists());
557        assert!(!fixture.profile_file("account-b").exists());
558        assert!(fixture.profile_file("account-c").exists());
559    }
560
561    #[test]
562    fn interactive_auth_remove_confirmation_reject_has_no_side_effects() {
563        let fixture = AuthPromptFixture::new("remove-cancel");
564        let now = fixed_now();
565        let current_content = auth_content("account-a", "a@example.test", "plus");
566        let other_content = auth_content("account-b", "b@example.test", "pro");
567
568        fixture.save_profile(&current_content, now);
569        fixture.save_profile(&other_content, now);
570        std::fs::write(&fixture.auth_file, &current_content).expect("write current auth");
571        let options = fixture.options();
572        let report = list_codex_auth_profiles(&options, now).expect("list profiles");
573        let mut prompt = FakePrompt {
574            multi_select: Some(Some(vec![0])),
575            confirm: Some(Some(false)),
576            ..FakePrompt::default()
577        };
578
579        let error =
580            remove_auth_profiles_interactively(&report, &options, now, &mut prompt).unwrap_err();
581
582        assert_eq!(error.message(), "auth remove cancelled.");
583        assert!(fixture.profile_file("account-a").exists());
584        assert!(fixture.profile_file("account-b").exists());
585    }
586
587    #[derive(Default)]
588    struct FakePrompt {
589        select: Option<Option<usize>>,
590        multi_select: Option<Option<Vec<usize>>>,
591        confirm: Option<Option<bool>>,
592        select_items: Vec<Vec<String>>,
593        multi_select_items: Vec<Vec<String>>,
594    }
595
596    impl Prompt for FakePrompt {
597        fn select(&mut self, _prompt: &str, items: &[String]) -> Result<Option<usize>, AppError> {
598            self.select_items.push(items.to_vec());
599            self.select
600                .take()
601                .ok_or_else(|| AppError::new("missing fake select response"))
602        }
603
604        fn multi_select(
605            &mut self,
606            _prompt: &str,
607            items: &[String],
608        ) -> Result<Option<Vec<usize>>, AppError> {
609            self.multi_select_items.push(items.to_vec());
610            self.multi_select
611                .take()
612                .ok_or_else(|| AppError::new("missing fake multi-select response"))
613        }
614
615        fn confirm(&mut self, _prompt: &str, _default: bool) -> Result<Option<bool>, AppError> {
616            self.confirm
617                .take()
618                .ok_or_else(|| AppError::new("missing fake confirm response"))
619        }
620    }
621
622    struct AuthPromptFixture {
623        root: std::path::PathBuf,
624        auth_file: std::path::PathBuf,
625        store_dir: std::path::PathBuf,
626        history_file: std::path::PathBuf,
627    }
628
629    impl AuthPromptFixture {
630        fn new(label: &str) -> Self {
631            let root = temp_dir(&format!("codex-ops-auth-{label}"));
632            std::fs::create_dir_all(&root).expect("create auth fixture root");
633            Self {
634                auth_file: root.join("auth.json"),
635                store_dir: root.join("auth-profiles"),
636                history_file: root.join("auth-account-history.json"),
637                root,
638            }
639        }
640
641        fn options(&self) -> AuthCommandOptions {
642            AuthCommandOptions {
643                auth_file: Some(self.auth_file.clone()),
644                store_dir: Some(self.store_dir.clone()),
645                account_history_file: Some(self.history_file.clone()),
646                ..AuthCommandOptions::default()
647            }
648        }
649
650        fn save_profile(&self, content: &str, now: DateTime<Utc>) {
651            std::fs::write(&self.auth_file, content).expect("write auth profile content");
652            save_current_codex_auth_profile(
653                &AuthCommandOptions {
654                    auth_file: Some(self.auth_file.clone()),
655                    store_dir: Some(self.store_dir.clone()),
656                    ..AuthCommandOptions::default()
657                },
658                now,
659            )
660            .expect("save auth profile");
661        }
662
663        fn profile_file(&self, account_id: &str) -> std::path::PathBuf {
664            self.store_dir.join(format!("{account_id}.json"))
665        }
666    }
667
668    impl Drop for AuthPromptFixture {
669        fn drop(&mut self) {
670            let _ = std::fs::remove_dir_all(&self.root);
671        }
672    }
673
674    fn fixed_now() -> DateTime<Utc> {
675        DateTime::parse_from_rfc3339("2026-05-13T00:00:00.000Z")
676            .expect("fixed now")
677            .with_timezone(&Utc)
678    }
679
680    fn auth_content(account_id: &str, email: &str, plan: &str) -> String {
681        let payload = serde_json::json!({
682            "sub": format!("auth0|{account_id}"),
683            "email": email,
684            "https://api.openai.com/auth": {
685                "chatgpt_account_id": account_id,
686                "chatgpt_plan_type": plan,
687                "chatgpt_user_id": format!("user-{account_id}"),
688                "user_id": format!("user-{account_id}")
689            }
690        });
691        let token = jwt(r#"{"alg":"RS256","kid":"key-1"}"#, &payload.to_string());
692        serde_json::to_string_pretty(&serde_json::json!({
693            "auth_mode": "chatgpt",
694            "tokens": {
695                "id_token": token,
696                "refresh_token": "synthetic-refresh-token",
697                "account_id": account_id
698            },
699            "last_refresh": "2026-05-12T05:32:41.917677755Z"
700        }))
701        .expect("serialize auth content")
702    }
703
704    fn jwt(header: &str, payload: &str) -> String {
705        format!(
706            "{}.{}.signature",
707            encode_base64url(header),
708            encode_base64url(payload)
709        )
710    }
711
712    fn encode_base64url(value: &str) -> String {
713        const TABLE: &[u8; 64] =
714            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
715        let bytes = value.as_bytes();
716        let mut output = String::new();
717        let mut index = 0;
718
719        while index < bytes.len() {
720            let b0 = bytes[index];
721            let b1 = *bytes.get(index + 1).unwrap_or(&0);
722            let b2 = *bytes.get(index + 2).unwrap_or(&0);
723            output.push(TABLE[(b0 >> 2) as usize] as char);
724            output.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
725            if index + 1 < bytes.len() {
726                output.push(TABLE[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
727            }
728            if index + 2 < bytes.len() {
729                output.push(TABLE[(b2 & 0x3f) as usize] as char);
730            }
731            index += 3;
732        }
733
734        output
735    }
736
737    fn temp_dir(prefix: &str) -> std::path::PathBuf {
738        let millis = std::time::SystemTime::now()
739            .duration_since(std::time::UNIX_EPOCH)
740            .expect("system time")
741            .as_millis();
742        std::env::temp_dir().join(format!("{prefix}-{millis}-{}", std::process::id()))
743    }
744}