Skip to main content

gemini_cli/rate_limits/
mod.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use nils_common::env as shared_env;
5
6use crate::auth;
7use crate::fs as gemini_fs;
8use crate::paths;
9use crate::rate_limits::client::{UsageRequest, fetch_usage};
10
11pub use nils_common::rate_limits_ansi as ansi;
12pub mod client;
13pub mod render;
14
15#[derive(Clone, Debug, Default)]
16pub struct RateLimitsOptions {
17    pub clear_cache: bool,
18    pub debug: bool,
19    pub cached: bool,
20    pub no_refresh_auth: bool,
21    pub json: bool,
22    pub one_line: bool,
23    pub all: bool,
24    pub async_mode: bool,
25    pub jobs: Option<String>,
26    pub secret: Option<String>,
27}
28
29#[derive(Clone, Debug)]
30pub struct CacheEntry {
31    pub non_weekly_label: String,
32    pub non_weekly_remaining: i64,
33    pub non_weekly_reset_epoch: Option<i64>,
34    pub weekly_remaining: i64,
35    pub weekly_reset_epoch: i64,
36}
37
38pub const DIAG_SCHEMA_VERSION: &str = "gemini-cli.diag.rate-limits.v1";
39pub const DIAG_COMMAND: &str = "diag rate-limits";
40
41#[derive(Clone, Debug)]
42struct RateLimitSummary {
43    non_weekly_label: String,
44    non_weekly_remaining: i64,
45    non_weekly_reset_epoch: Option<i64>,
46    weekly_remaining: i64,
47    weekly_reset_epoch: i64,
48}
49
50#[derive(Clone, Debug)]
51struct JsonResultItem {
52    name: String,
53    target_file: String,
54    status: String,
55    ok: bool,
56    source: String,
57    summary: Option<RateLimitSummary>,
58    raw_usage: Option<String>,
59    error_code: Option<String>,
60    error_message: Option<String>,
61}
62
63struct Row {
64    name: String,
65    window_label: String,
66    non_weekly_remaining: i64,
67    non_weekly_reset_epoch: Option<i64>,
68    weekly_remaining: i64,
69    weekly_reset_epoch: Option<i64>,
70}
71
72impl Row {
73    fn empty(name: String) -> Self {
74        Self {
75            name,
76            window_label: String::new(),
77            non_weekly_remaining: -1,
78            non_weekly_reset_epoch: None,
79            weekly_remaining: -1,
80            weekly_reset_epoch: None,
81        }
82    }
83
84    fn sort_key(&self) -> (i32, i64, String) {
85        if let Some(epoch) = self.weekly_reset_epoch {
86            (0, epoch, self.name.clone())
87        } else {
88            (1, i64::MAX, self.name.clone())
89        }
90    }
91}
92
93pub fn run(args: &RateLimitsOptions) -> i32 {
94    let cached_mode = args.cached;
95    let output_json = args.json;
96
97    if args.async_mode {
98        if !cached_mode {
99            maybe_sync_all_mode_auth_silent(args.debug);
100        }
101        if output_json {
102            return run_async_json_mode(args);
103        }
104        return run_async_mode(args);
105    }
106
107    if cached_mode {
108        if output_json {
109            emit_error_json(
110                "invalid-flag-combination",
111                "gemini-rate-limits: --json is not supported with --cached",
112                Some(json_obj(vec![(
113                    "flags".to_string(),
114                    json_array(vec![json_string("--json"), json_string("--cached")]),
115                )])),
116            );
117            return 64;
118        }
119        if args.clear_cache {
120            eprintln!("gemini-rate-limits: -c is not compatible with --cached");
121            return 64;
122        }
123    }
124
125    if output_json && args.one_line {
126        emit_error_json(
127            "invalid-flag-combination",
128            "gemini-rate-limits: --one-line is not compatible with --json",
129            Some(json_obj(vec![(
130                "flags".to_string(),
131                json_array(vec![json_string("--one-line"), json_string("--json")]),
132            )])),
133        );
134        return 64;
135    }
136
137    if args.clear_cache
138        && let Err(err) = clear_starship_cache()
139    {
140        if output_json {
141            emit_error_json("cache-clear-failed", &err, None);
142        } else {
143            eprintln!("{err}");
144        }
145        return 1;
146    }
147
148    let default_all_enabled = shared_env::env_truthy("GEMINI_RATE_LIMITS_DEFAULT_ALL_ENABLED");
149    let all_mode = args.all
150        || (!args.cached
151            && !output_json
152            && args.secret.is_none()
153            && default_all_enabled
154            && !args.async_mode);
155
156    if all_mode {
157        if !cached_mode {
158            maybe_sync_all_mode_auth_silent(args.debug);
159        }
160        if args.secret.is_some() {
161            eprintln!(
162                "gemini-rate-limits: usage: gemini-rate-limits [-c] [--cached] [--no-refresh-auth] [--json] [--one-line] [--all] [secret.json]"
163            );
164            return 64;
165        }
166        if output_json {
167            return run_all_json_mode(args, cached_mode);
168        }
169        return run_all_mode(args, cached_mode);
170    }
171
172    run_single_mode(args, cached_mode, output_json)
173}
174
175fn maybe_sync_all_mode_auth_silent(debug_mode: bool) {
176    if let Err(err) = sync_auth_silent()
177        && debug_mode
178    {
179        eprintln!("{err}");
180    }
181}
182
183fn sync_auth_silent() -> Result<(), String> {
184    let auth_file = match paths::resolve_auth_file() {
185        Some(path) => path,
186        None => return Ok(()),
187    };
188
189    if !auth_file.is_file() {
190        return Ok(());
191    }
192
193    let auth_key = match auth::identity_key_from_auth_file(&auth_file) {
194        Ok(Some(key)) => key,
195        _ => return Ok(()),
196    };
197
198    let auth_last_refresh = auth::last_refresh_from_auth_file(&auth_file).ok().flatten();
199    let auth_contents = fs::read(&auth_file)
200        .map_err(|_| format!("gemini-rate-limits: failed to read {}", auth_file.display()))?;
201
202    if let Some(secret_dir) = paths::resolve_secret_dir()
203        && let Ok(entries) = fs::read_dir(&secret_dir)
204    {
205        for entry in entries.flatten() {
206            let path = entry.path();
207            if path.extension().and_then(|value| value.to_str()) != Some("json") {
208                continue;
209            }
210
211            let candidate_key = match auth::identity_key_from_auth_file(&path) {
212                Ok(Some(key)) => key,
213                _ => continue,
214            };
215            if candidate_key != auth_key {
216                continue;
217            }
218
219            let secret_contents = fs::read(&path)
220                .map_err(|_| format!("gemini-rate-limits: failed to read {}", path.display()))?;
221            if secret_contents == auth_contents {
222                continue;
223            }
224
225            auth::write_atomic(&path, &auth_contents, auth::SECRET_FILE_MODE)
226                .map_err(|_| format!("gemini-rate-limits: failed to write {}", path.display()))?;
227
228            if let Some(timestamp_path) = sync_timestamp_path(&path) {
229                let _ = auth::write_timestamp(&timestamp_path, auth_last_refresh.as_deref());
230            }
231        }
232    }
233
234    if let Some(auth_timestamp) = sync_timestamp_path(&auth_file) {
235        let _ = auth::write_timestamp(&auth_timestamp, auth_last_refresh.as_deref());
236    }
237
238    Ok(())
239}
240
241fn sync_timestamp_path(target_file: &Path) -> Option<PathBuf> {
242    let cache_dir = paths::resolve_secret_cache_dir()?;
243    let name = target_file
244        .file_name()
245        .and_then(|name| name.to_str())
246        .unwrap_or("auth.json");
247    Some(cache_dir.join(format!("{name}.timestamp")))
248}
249
250fn run_single_mode(args: &RateLimitsOptions, cached_mode: bool, output_json: bool) -> i32 {
251    let target_file = match resolve_single_target(args.secret.as_deref()) {
252        Ok(path) => path,
253        Err(message) => {
254            if output_json {
255                emit_error_json("target-not-found", &message, None);
256            } else {
257                eprintln!("{message}");
258            }
259            return 1;
260        }
261    };
262
263    if cached_mode {
264        let cache_entry = match read_cache_entry(&target_file) {
265            Ok(entry) => entry,
266            Err(err) => {
267                eprintln!("{err}");
268                return 1;
269            }
270        };
271        let name = secret_name_for_target(&target_file).unwrap_or_else(|| {
272            target_file
273                .file_stem()
274                .and_then(|value| value.to_str())
275                .unwrap_or("auth")
276                .to_string()
277        });
278        let summary = RateLimitSummary {
279            non_weekly_label: cache_entry.non_weekly_label,
280            non_weekly_remaining: cache_entry.non_weekly_remaining,
281            non_weekly_reset_epoch: cache_entry.non_weekly_reset_epoch,
282            weekly_remaining: cache_entry.weekly_remaining,
283            weekly_reset_epoch: cache_entry.weekly_reset_epoch,
284        };
285        if args.one_line {
286            let line = render_line_for_summary(&name, &summary, true, "%m-%d %H:%M");
287            println!("{line}");
288        } else {
289            print_rate_limits_remaining(&summary, "%m-%d %H:%M");
290        }
291        return 0;
292    }
293
294    match collect_summary_from_network(&target_file, !args.no_refresh_auth) {
295        Ok((summary, raw_usage)) => {
296            if output_json {
297                let item = JsonResultItem {
298                    name: secret_name_for_target(&target_file)
299                        .unwrap_or_else(|| "auth".to_string()),
300                    target_file: target_file_name(&target_file),
301                    status: "ok".to_string(),
302                    ok: true,
303                    source: "network".to_string(),
304                    summary: Some(summary.clone()),
305                    raw_usage,
306                    error_code: None,
307                    error_message: None,
308                };
309                emit_single_envelope("single", true, &item);
310            } else {
311                let name = secret_name_for_target(&target_file).unwrap_or_else(|| {
312                    target_file
313                        .file_stem()
314                        .and_then(|value| value.to_str())
315                        .unwrap_or("auth")
316                        .to_string()
317                });
318                if args.one_line {
319                    let line =
320                        render_line_for_summary(&name, &summary, args.one_line, "%m-%d %H:%M");
321                    println!("{line}");
322                } else {
323                    print_rate_limits_remaining(&summary, "%m-%d %H:%M");
324                }
325            }
326            0
327        }
328        Err(err) => {
329            if output_json {
330                emit_error_json(&err.code, &err.message, err.details);
331            } else {
332                eprintln!("{}", err.message);
333            }
334            err.exit_code
335        }
336    }
337}
338
339fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool) -> i32 {
340    let secret_files = match collect_secret_files() {
341        Ok(value) => value,
342        Err(err) => {
343            eprintln!("{err}");
344            return 1;
345        }
346    };
347
348    let current_name = current_secret_basename(&secret_files);
349
350    let mut rc = 0;
351    let mut rows: Vec<Row> = Vec::new();
352    let mut window_labels = std::collections::HashSet::new();
353
354    for target in secret_files {
355        let name = secret_name_for_target(&target).unwrap_or_else(|| target_file_name(&target));
356        let mut row = Row::empty(name.clone());
357
358        if cached_mode {
359            match read_cache_entry(&target) {
360                Ok(summary) => {
361                    row.window_label = summary.non_weekly_label.clone();
362                    row.non_weekly_remaining = summary.non_weekly_remaining;
363                    row.non_weekly_reset_epoch = summary.non_weekly_reset_epoch;
364                    row.weekly_remaining = summary.weekly_remaining;
365                    row.weekly_reset_epoch = Some(summary.weekly_reset_epoch);
366                    window_labels.insert(row.window_label.clone());
367                }
368                Err(err) => {
369                    eprintln!("{name}: {err}");
370                    rc = 1;
371                }
372            }
373            rows.push(row);
374            continue;
375        }
376
377        match collect_summary_from_network(&target, !args.no_refresh_auth) {
378            Ok((summary, _raw)) => {
379                row.window_label = summary.non_weekly_label.clone();
380                row.non_weekly_remaining = summary.non_weekly_remaining;
381                row.non_weekly_reset_epoch = summary.non_weekly_reset_epoch;
382                row.weekly_remaining = summary.weekly_remaining;
383                row.weekly_reset_epoch = Some(summary.weekly_reset_epoch);
384                window_labels.insert(row.window_label.clone());
385            }
386            Err(err) => {
387                eprintln!("{name}: {}", err.message);
388                rc = 1;
389            }
390        }
391        rows.push(row);
392    }
393
394    println!("\n🚦 Gemini rate limits for all accounts\n");
395
396    let mut non_weekly_header = "Non-weekly".to_string();
397    let multiple_labels = window_labels.len() != 1;
398    if !multiple_labels && let Some(label) = window_labels.iter().next() {
399        non_weekly_header = label.clone();
400    }
401
402    let now_epoch = now_epoch_seconds();
403
404    println!(
405        "{:<15}  {:>8}  {:>7}  {:>8}  {:>7}  {:<18}",
406        "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
407    );
408    println!("----------------------------------------------------------------------------");
409
410    rows.sort_by_key(|row| row.sort_key());
411
412    for row in rows {
413        let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
414            if row.non_weekly_remaining >= 0 {
415                format!("{}:{}%", row.window_label, row.non_weekly_remaining)
416            } else {
417                "-".to_string()
418            }
419        } else if row.non_weekly_remaining >= 0 {
420            format!("{}%", row.non_weekly_remaining)
421        } else {
422            "-".to_string()
423        };
424
425        let non_weekly_left = row
426            .non_weekly_reset_epoch
427            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
428            .unwrap_or_else(|| "-".to_string());
429        let weekly_left = row
430            .weekly_reset_epoch
431            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
432            .unwrap_or_else(|| "-".to_string());
433        let reset_display = row
434            .weekly_reset_epoch
435            .and_then(render::format_epoch_local_datetime_with_offset)
436            .unwrap_or_else(|| "-".to_string());
437
438        let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
439        let weekly_display = if row.weekly_remaining >= 0 {
440            ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
441        } else {
442            ansi::format_percent_cell("-", 8, None)
443        };
444
445        let is_current = current_name.as_deref() == Some(row.name.as_str());
446        let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
447
448        println!(
449            "{}  {}  {:>7}  {}  {:>7}  {:<18}",
450            name_display,
451            non_weekly_display,
452            non_weekly_left,
453            weekly_display,
454            weekly_left,
455            reset_display
456        );
457    }
458
459    rc
460}
461
462fn run_all_json_mode(args: &RateLimitsOptions, cached_mode: bool) -> i32 {
463    let secret_files = match collect_secret_files() {
464        Ok(value) => value,
465        Err(err) => {
466            emit_error_json("secret-discovery-failed", &err, None);
467            return 1;
468        }
469    };
470
471    let mut items: Vec<JsonResultItem> = Vec::new();
472    let mut rc = 0;
473
474    for target in secret_files {
475        let name = secret_name_for_target(&target).unwrap_or_else(|| target_file_name(&target));
476        if cached_mode {
477            match read_cache_entry(&target) {
478                Ok(entry) => items.push(JsonResultItem {
479                    name,
480                    target_file: target_file_name(&target),
481                    status: "ok".to_string(),
482                    ok: true,
483                    source: "cache".to_string(),
484                    summary: Some(RateLimitSummary {
485                        non_weekly_label: entry.non_weekly_label,
486                        non_weekly_remaining: entry.non_weekly_remaining,
487                        non_weekly_reset_epoch: entry.non_weekly_reset_epoch,
488                        weekly_remaining: entry.weekly_remaining,
489                        weekly_reset_epoch: entry.weekly_reset_epoch,
490                    }),
491                    raw_usage: None,
492                    error_code: None,
493                    error_message: None,
494                }),
495                Err(err) => {
496                    rc = 1;
497                    items.push(JsonResultItem {
498                        name,
499                        target_file: target_file_name(&target),
500                        status: "error".to_string(),
501                        ok: false,
502                        source: "cache".to_string(),
503                        summary: None,
504                        raw_usage: None,
505                        error_code: Some("cache-read-failed".to_string()),
506                        error_message: Some(err),
507                    });
508                }
509            }
510            continue;
511        }
512
513        match collect_summary_from_network(&target, !args.no_refresh_auth) {
514            Ok((summary, raw_usage)) => items.push(JsonResultItem {
515                name,
516                target_file: target_file_name(&target),
517                status: "ok".to_string(),
518                ok: true,
519                source: "network".to_string(),
520                summary: Some(summary),
521                raw_usage,
522                error_code: None,
523                error_message: None,
524            }),
525            Err(err) => {
526                rc = 1;
527                items.push(JsonResultItem {
528                    name,
529                    target_file: target_file_name(&target),
530                    status: "error".to_string(),
531                    ok: false,
532                    source: "network".to_string(),
533                    summary: None,
534                    raw_usage: None,
535                    error_code: Some(err.code),
536                    error_message: Some(err.message),
537                });
538            }
539        }
540    }
541
542    items.sort_by(|a, b| a.name.cmp(&b.name));
543    emit_collection_envelope("all", rc == 0, &items);
544    rc
545}
546
547fn run_async_mode(args: &RateLimitsOptions) -> i32 {
548    if args.one_line {
549        eprintln!("gemini-rate-limits: --async does not support --one-line");
550        return 64;
551    }
552    if let Some(secret) = args.secret.as_deref() {
553        eprintln!("gemini-rate-limits: --async does not accept positional args: {secret}");
554        eprintln!("hint: async always queries all secrets under GEMINI_SECRET_DIR");
555        return 64;
556    }
557    if args.clear_cache && args.cached {
558        eprintln!("gemini-rate-limits: --async: -c is not compatible with --cached");
559        return 64;
560    }
561    if args.clear_cache
562        && let Err(err) = clear_starship_cache()
563    {
564        eprintln!("{err}");
565        return 1;
566    }
567    run_all_mode(args, args.cached)
568}
569
570fn run_async_json_mode(args: &RateLimitsOptions) -> i32 {
571    if args.one_line {
572        emit_error_json(
573            "invalid-flag-combination",
574            "gemini-rate-limits: --async does not support --one-line",
575            Some(json_obj(vec![
576                ("flag".to_string(), json_string("--one-line")),
577                ("mode".to_string(), json_string("async")),
578            ])),
579        );
580        return 64;
581    }
582    if let Some(secret) = args.secret.as_deref() {
583        emit_error_json(
584            "invalid-positional-arg",
585            &format!("gemini-rate-limits: --async does not accept positional args: {secret}"),
586            Some(json_obj(vec![
587                ("secret".to_string(), json_string(secret)),
588                ("mode".to_string(), json_string("async")),
589            ])),
590        );
591        return 64;
592    }
593    if args.clear_cache && args.cached {
594        emit_error_json(
595            "invalid-flag-combination",
596            "gemini-rate-limits: --async: -c is not compatible with --cached",
597            Some(json_obj(vec![(
598                "flags".to_string(),
599                json_array(vec![
600                    json_string("--async"),
601                    json_string("--cached"),
602                    json_string("-c"),
603                ]),
604            )])),
605        );
606        return 64;
607    }
608    if args.clear_cache
609        && let Err(err) = clear_starship_cache()
610    {
611        emit_error_json("cache-clear-failed", &err, None);
612        return 1;
613    }
614
615    let secret_files = match collect_secret_files() {
616        Ok(value) => value,
617        Err(err) => {
618            emit_error_json("secret-discovery-failed", &err, None);
619            return 1;
620        }
621    };
622
623    let mut items: Vec<JsonResultItem> = Vec::new();
624    let mut rc = 0;
625    for target in secret_files {
626        let name = secret_name_for_target(&target).unwrap_or_else(|| target_file_name(&target));
627        match collect_summary_from_network(&target, !args.no_refresh_auth) {
628            Ok((summary, raw_usage)) => items.push(JsonResultItem {
629                name,
630                target_file: target_file_name(&target),
631                status: "ok".to_string(),
632                ok: true,
633                source: "network".to_string(),
634                summary: Some(summary),
635                raw_usage,
636                error_code: None,
637                error_message: None,
638            }),
639            Err(err) => {
640                if err.code == "missing-access-token"
641                    && let Ok(cached) = read_cache_entry(&target)
642                {
643                    items.push(JsonResultItem {
644                        name,
645                        target_file: target_file_name(&target),
646                        status: "ok".to_string(),
647                        ok: true,
648                        source: "cache-fallback".to_string(),
649                        summary: Some(RateLimitSummary {
650                            non_weekly_label: cached.non_weekly_label,
651                            non_weekly_remaining: cached.non_weekly_remaining,
652                            non_weekly_reset_epoch: cached.non_weekly_reset_epoch,
653                            weekly_remaining: cached.weekly_remaining,
654                            weekly_reset_epoch: cached.weekly_reset_epoch,
655                        }),
656                        raw_usage: None,
657                        error_code: None,
658                        error_message: None,
659                    });
660                    continue;
661                }
662
663                rc = 1;
664                items.push(JsonResultItem {
665                    name,
666                    target_file: target_file_name(&target),
667                    status: "error".to_string(),
668                    ok: false,
669                    source: "network".to_string(),
670                    summary: None,
671                    raw_usage: None,
672                    error_code: Some(err.code),
673                    error_message: Some(err.message),
674                });
675            }
676        }
677    }
678    items.sort_by(|a, b| a.name.cmp(&b.name));
679    emit_collection_envelope("async", rc == 0, &items);
680    rc
681}
682
683pub fn clear_starship_cache() -> Result<(), String> {
684    let root =
685        cache_root().ok_or_else(|| "gemini-rate-limits: cache root unavailable".to_string())?;
686    if !root.is_absolute() {
687        return Err(format!(
688            "gemini-rate-limits: refusing to clear cache with non-absolute cache root: {}",
689            root.display()
690        ));
691    }
692    if root == Path::new("/") {
693        return Err(format!(
694            "gemini-rate-limits: refusing to clear cache with invalid cache root: {}",
695            root.display()
696        ));
697    }
698
699    let cache_dir = root.join("gemini").join("starship-rate-limits");
700    let cache_dir_str = cache_dir.to_string_lossy();
701    if !cache_dir_str.ends_with("/gemini/starship-rate-limits") {
702        return Err(format!(
703            "gemini-rate-limits: refusing to clear unexpected cache dir: {}",
704            cache_dir.display()
705        ));
706    }
707
708    if cache_dir.is_dir() {
709        let _ = fs::remove_dir_all(&cache_dir);
710    }
711    Ok(())
712}
713
714pub fn cache_file_for_target(target_file: &Path) -> Result<PathBuf, String> {
715    let cache_dir = starship_cache_dir()
716        .ok_or_else(|| "gemini-rate-limits: cache dir unavailable".to_string())?;
717
718    if let Some(secret_dir) = paths::resolve_secret_dir() {
719        if target_file.starts_with(&secret_dir) {
720            let display = secret_file_basename(target_file)?;
721            let key = cache_key(&display)?;
722            return Ok(cache_dir.join(format!("{key}.kv")));
723        }
724
725        if let Some(secret_name) = secret_name_for_auth(target_file, &secret_dir) {
726            let key = cache_key(&secret_name)?;
727            return Ok(cache_dir.join(format!("{key}.kv")));
728        }
729    }
730
731    let hash = gemini_fs::sha256_file(target_file).map_err(|err| err.to_string())?;
732    Ok(cache_dir.join(format!("auth_{}.kv", hash.to_lowercase())))
733}
734
735pub fn secret_name_for_target(target_file: &Path) -> Option<String> {
736    let secret_dir = paths::resolve_secret_dir()?;
737    if target_file.starts_with(&secret_dir) {
738        return secret_file_basename(target_file).ok();
739    }
740    secret_name_for_auth(target_file, &secret_dir)
741}
742
743pub fn read_cache_entry(target_file: &Path) -> Result<CacheEntry, String> {
744    let cache_file = cache_file_for_target(target_file)?;
745    if !cache_file.is_file() {
746        return Err(format!(
747            "gemini-rate-limits: cache not found (run gemini-rate-limits without --cached, or gemini-cli starship, to populate): {}",
748            cache_file.display()
749        ));
750    }
751
752    let content = fs::read_to_string(&cache_file).map_err(|_| {
753        format!(
754            "gemini-rate-limits: failed to read cache: {}",
755            cache_file.display()
756        )
757    })?;
758    let mut non_weekly_label: Option<String> = None;
759    let mut non_weekly_remaining: Option<i64> = None;
760    let mut non_weekly_reset_epoch: Option<i64> = None;
761    let mut weekly_remaining: Option<i64> = None;
762    let mut weekly_reset_epoch: Option<i64> = None;
763
764    for line in content.lines() {
765        if let Some(value) = line.strip_prefix("non_weekly_label=") {
766            non_weekly_label = Some(value.to_string());
767        } else if let Some(value) = line.strip_prefix("non_weekly_remaining=") {
768            non_weekly_remaining = value.parse::<i64>().ok();
769        } else if let Some(value) = line.strip_prefix("non_weekly_reset_epoch=") {
770            non_weekly_reset_epoch = value.parse::<i64>().ok();
771        } else if let Some(value) = line.strip_prefix("weekly_remaining=") {
772            weekly_remaining = value.parse::<i64>().ok();
773        } else if let Some(value) = line.strip_prefix("weekly_reset_epoch=") {
774            weekly_reset_epoch = value.parse::<i64>().ok();
775        }
776    }
777
778    let non_weekly_label = match non_weekly_label {
779        Some(value) if !value.trim().is_empty() => value,
780        _ => {
781            return Err(format!(
782                "gemini-rate-limits: invalid cache (missing non-weekly data): {}",
783                cache_file.display()
784            ));
785        }
786    };
787    let non_weekly_remaining = match non_weekly_remaining {
788        Some(value) => value,
789        None => {
790            return Err(format!(
791                "gemini-rate-limits: invalid cache (missing non-weekly data): {}",
792                cache_file.display()
793            ));
794        }
795    };
796    let weekly_remaining = match weekly_remaining {
797        Some(value) => value,
798        None => {
799            return Err(format!(
800                "gemini-rate-limits: invalid cache (missing weekly data): {}",
801                cache_file.display()
802            ));
803        }
804    };
805    let weekly_reset_epoch = match weekly_reset_epoch {
806        Some(value) => value,
807        None => {
808            return Err(format!(
809                "gemini-rate-limits: invalid cache (missing weekly data): {}",
810                cache_file.display()
811            ));
812        }
813    };
814
815    Ok(CacheEntry {
816        non_weekly_label,
817        non_weekly_remaining,
818        non_weekly_reset_epoch,
819        weekly_remaining,
820        weekly_reset_epoch,
821    })
822}
823
824pub fn write_starship_cache(
825    target_file: &Path,
826    fetched_at_epoch: i64,
827    non_weekly_label: &str,
828    non_weekly_remaining: i64,
829    weekly_remaining: i64,
830    weekly_reset_epoch: i64,
831    non_weekly_reset_epoch: Option<i64>,
832) -> Result<(), String> {
833    let cache_file = cache_file_for_target(target_file)?;
834    if let Some(parent) = cache_file.parent() {
835        fs::create_dir_all(parent).map_err(|err| err.to_string())?;
836    }
837
838    let mut lines = Vec::new();
839    lines.push(format!("fetched_at={fetched_at_epoch}"));
840    lines.push(format!("non_weekly_label={non_weekly_label}"));
841    lines.push(format!("non_weekly_remaining={non_weekly_remaining}"));
842    if let Some(epoch) = non_weekly_reset_epoch {
843        lines.push(format!("non_weekly_reset_epoch={epoch}"));
844    }
845    lines.push(format!("weekly_remaining={weekly_remaining}"));
846    lines.push(format!("weekly_reset_epoch={weekly_reset_epoch}"));
847
848    let data = lines.join("\n");
849    gemini_fs::write_atomic(&cache_file, data.as_bytes(), gemini_fs::SECRET_FILE_MODE)
850        .map_err(|err| err.to_string())
851}
852
853const DEFAULT_CODE_ASSIST_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com";
854const DEFAULT_CODE_ASSIST_API_VERSION: &str = "v1internal";
855const DEFAULT_CODE_ASSIST_PROJECT: &str = "projects/default";
856
857fn run_code_assist_endpoint() -> String {
858    env_non_empty("CODE_ASSIST_ENDPOINT")
859        .or_else(|| env_non_empty("GEMINI_CODE_ASSIST_ENDPOINT"))
860        .unwrap_or_else(|| DEFAULT_CODE_ASSIST_ENDPOINT.to_string())
861}
862
863fn run_code_assist_api_version() -> String {
864    env_non_empty("CODE_ASSIST_API_VERSION")
865        .or_else(|| env_non_empty("GEMINI_CODE_ASSIST_API_VERSION"))
866        .unwrap_or_else(|| DEFAULT_CODE_ASSIST_API_VERSION.to_string())
867}
868
869fn run_code_assist_project() -> String {
870    let raw = env_non_empty("GEMINI_CODE_ASSIST_PROJECT")
871        .or_else(|| env_non_empty("GOOGLE_CLOUD_PROJECT"))
872        .or_else(|| env_non_empty("GOOGLE_CLOUD_PROJECT_ID"))
873        .unwrap_or_else(|| DEFAULT_CODE_ASSIST_PROJECT.to_string());
874
875    if raw.starts_with("projects/") {
876        raw
877    } else {
878        format!("projects/{raw}")
879    }
880}
881
882fn run_connect_timeout() -> u64 {
883    std::env::var("GEMINI_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS")
884        .ok()
885        .and_then(|raw| raw.trim().parse::<u64>().ok())
886        .unwrap_or(2)
887}
888
889fn run_max_time() -> u64 {
890    std::env::var("GEMINI_RATE_LIMITS_CURL_MAX_TIME_SECONDS")
891        .ok()
892        .and_then(|raw| raw.trim().parse::<u64>().ok())
893        .unwrap_or(8)
894}
895
896fn collect_summary_from_network(
897    target_file: &Path,
898    refresh_on_401: bool,
899) -> Result<(RateLimitSummary, Option<String>), RunError> {
900    let request = UsageRequest {
901        target_file: target_file.to_path_buf(),
902        refresh_on_401,
903        endpoint: run_code_assist_endpoint(),
904        api_version: run_code_assist_api_version(),
905        project: run_code_assist_project(),
906        connect_timeout_seconds: run_connect_timeout(),
907        max_time_seconds: run_max_time(),
908    };
909    let usage = fetch_usage(&request).map_err(|message| {
910        let (code, exit_code) = if message.contains("missing access_token") {
911            ("missing-access-token".to_string(), 2)
912        } else {
913            ("request-failed".to_string(), 3)
914        };
915        RunError {
916            code,
917            message,
918            details: None,
919            exit_code,
920        }
921    })?;
922
923    let usage_data = render::parse_usage(&usage.body).ok_or_else(|| RunError {
924        code: "invalid-usage-payload".to_string(),
925        message: "gemini-rate-limits: invalid usage payload".to_string(),
926        details: Some(json_obj(vec![(
927            "raw_usage".to_string(),
928            usage.body.clone(),
929        )])),
930        exit_code: 3,
931    })?;
932    let values = render::render_values(&usage_data);
933    let weekly = render::weekly_values(&values);
934    let summary = RateLimitSummary {
935        non_weekly_label: weekly.non_weekly_label.clone(),
936        non_weekly_remaining: weekly.non_weekly_remaining,
937        non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
938        weekly_remaining: weekly.weekly_remaining,
939        weekly_reset_epoch: weekly.weekly_reset_epoch,
940    };
941
942    let now_epoch = now_epoch_seconds();
943    if now_epoch > 0 {
944        let _ = write_starship_cache(
945            target_file,
946            now_epoch,
947            &summary.non_weekly_label,
948            summary.non_weekly_remaining,
949            summary.weekly_remaining,
950            summary.weekly_reset_epoch,
951            summary.non_weekly_reset_epoch,
952        );
953    }
954
955    let raw_usage = if usage.body.trim_start().starts_with('{') {
956        Some(usage.body)
957    } else {
958        None
959    };
960
961    Ok((summary, raw_usage))
962}
963
964fn collect_secret_files() -> Result<Vec<PathBuf>, String> {
965    let secret_dir = paths::resolve_secret_dir().unwrap_or_default();
966    if !secret_dir.is_dir() {
967        return Err(format!(
968            "gemini-rate-limits: GEMINI_SECRET_DIR not found: {}",
969            secret_dir.display()
970        ));
971    }
972
973    let mut files: Vec<PathBuf> = fs::read_dir(&secret_dir)
974        .map_err(|err| format!("gemini-rate-limits: failed to read GEMINI_SECRET_DIR: {err}"))?
975        .flatten()
976        .map(|entry| entry.path())
977        .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
978        .collect();
979
980    files.sort();
981
982    if files.is_empty() {
983        return Err(format!(
984            "gemini-rate-limits: no secrets found under GEMINI_SECRET_DIR: {}",
985            secret_dir.display()
986        ));
987    }
988
989    Ok(files)
990}
991
992fn resolve_single_target(secret: Option<&str>) -> Result<PathBuf, String> {
993    if let Some(raw) = secret {
994        if raw.trim().is_empty() {
995            return Err("gemini-rate-limits: empty secret target".to_string());
996        }
997        let path = if raw.contains('/') || raw.starts_with('.') {
998            PathBuf::from(raw)
999        } else if let Some(secret_dir) = paths::resolve_secret_dir() {
1000            let mut file = raw.to_string();
1001            if !file.ends_with(".json") {
1002                file.push_str(".json");
1003            }
1004            secret_dir.join(file)
1005        } else {
1006            PathBuf::from(raw)
1007        };
1008
1009        if !path.is_file() {
1010            return Err(format!(
1011                "gemini-rate-limits: target file not found: {}",
1012                path.display()
1013            ));
1014        }
1015        return Ok(path);
1016    }
1017
1018    let auth = paths::resolve_auth_file().ok_or_else(|| {
1019        "gemini-rate-limits: GEMINI_AUTH_FILE is not configured and no secret provided".to_string()
1020    })?;
1021    if !auth.is_file() {
1022        return Err(format!(
1023            "gemini-rate-limits: target file not found: {}",
1024            auth.display()
1025        ));
1026    }
1027    Ok(auth)
1028}
1029
1030fn secret_file_basename(path: &Path) -> Result<String, String> {
1031    let file = path
1032        .file_name()
1033        .and_then(|name| name.to_str())
1034        .unwrap_or_default();
1035    let base = file.trim_end_matches(".json");
1036    if base.is_empty() {
1037        return Err("missing secret basename".to_string());
1038    }
1039    Ok(base.to_string())
1040}
1041
1042fn cache_key(name: &str) -> Result<String, String> {
1043    if name.is_empty() {
1044        return Err("missing cache key name".to_string());
1045    }
1046    let mut key = String::new();
1047    for ch in name.to_lowercase().chars() {
1048        if ch.is_ascii_alphanumeric() {
1049            key.push(ch);
1050        } else {
1051            key.push('_');
1052        }
1053    }
1054    while key.starts_with('_') {
1055        key.remove(0);
1056    }
1057    while key.ends_with('_') {
1058        key.pop();
1059    }
1060    if key.is_empty() {
1061        return Err("invalid cache key name".to_string());
1062    }
1063    Ok(key)
1064}
1065
1066fn secret_name_for_auth(auth_file: &Path, secret_dir: &Path) -> Option<String> {
1067    let auth_key = auth::identity_key_from_auth_file(auth_file)
1068        .ok()
1069        .flatten()?;
1070    let entries = fs::read_dir(secret_dir).ok()?;
1071    for entry in entries.flatten() {
1072        let path = entry.path();
1073        if path.extension().and_then(|s| s.to_str()) != Some("json") {
1074            continue;
1075        }
1076        let candidate_key = match auth::identity_key_from_auth_file(&path).ok().flatten() {
1077            Some(value) => value,
1078            None => continue,
1079        };
1080        if candidate_key == auth_key {
1081            return secret_file_basename(&path).ok();
1082        }
1083    }
1084    None
1085}
1086
1087fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
1088    let auth_file = paths::resolve_auth_file()?;
1089    if !auth_file.is_file() {
1090        return None;
1091    }
1092
1093    let auth_hash = gemini_fs::sha256_file(&auth_file).ok();
1094    if let Some(auth_hash) = auth_hash.as_deref() {
1095        for secret_file in secret_files {
1096            if let Ok(secret_hash) = gemini_fs::sha256_file(secret_file)
1097                && secret_hash == auth_hash
1098                && let Ok(name) = secret_file_basename(secret_file)
1099            {
1100                return Some(name);
1101            }
1102        }
1103    }
1104
1105    let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
1106    if let Some(auth_key) = auth_key.as_deref() {
1107        for secret_file in secret_files {
1108            if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
1109                && candidate_key == auth_key
1110                && let Ok(name) = secret_file_basename(secret_file)
1111            {
1112                return Some(name);
1113            }
1114        }
1115    }
1116
1117    None
1118}
1119
1120fn starship_cache_dir() -> Option<PathBuf> {
1121    let root = cache_root()?;
1122    Some(root.join("gemini").join("starship-rate-limits"))
1123}
1124
1125fn cache_root() -> Option<PathBuf> {
1126    if let Ok(path) = std::env::var("ZSH_CACHE_DIR")
1127        && !path.is_empty()
1128    {
1129        return Some(PathBuf::from(path));
1130    }
1131    let zdotdir = paths::resolve_zdotdir()?;
1132    Some(zdotdir.join("cache"))
1133}
1134
1135fn render_line_for_summary(
1136    name: &str,
1137    summary: &RateLimitSummary,
1138    one_line: bool,
1139    time_format: &str,
1140) -> String {
1141    let reset = render::format_epoch_local(summary.weekly_reset_epoch, time_format)
1142        .unwrap_or_else(|| "?".to_string());
1143    let token_5h = format!(
1144        "{}:{}%",
1145        summary.non_weekly_label, summary.non_weekly_remaining
1146    );
1147    let token_weekly = format!("W:{}%", summary.weekly_remaining);
1148
1149    if one_line {
1150        return format!("{name} {token_5h} {token_weekly} {reset}");
1151    }
1152    format!("{token_5h} {token_weekly} {reset}")
1153}
1154
1155fn print_rate_limits_remaining(summary: &RateLimitSummary, time_format: &str) {
1156    println!("Rate limits remaining");
1157    let non_weekly_reset = summary
1158        .non_weekly_reset_epoch
1159        .and_then(|epoch| render::format_epoch_local(epoch, time_format))
1160        .unwrap_or_else(|| "?".to_string());
1161    let weekly_reset = render::format_epoch_local(summary.weekly_reset_epoch, time_format)
1162        .unwrap_or_else(|| "?".to_string());
1163    println!(
1164        "{} {}% • {}",
1165        summary.non_weekly_label, summary.non_weekly_remaining, non_weekly_reset
1166    );
1167    println!("Weekly {}% • {}", summary.weekly_remaining, weekly_reset);
1168}
1169
1170fn target_file_name(path: &Path) -> String {
1171    if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
1172        name.to_string()
1173    } else {
1174        path.to_string_lossy().to_string()
1175    }
1176}
1177
1178fn now_epoch_seconds() -> i64 {
1179    std::time::SystemTime::now()
1180        .duration_since(std::time::UNIX_EPOCH)
1181        .map(|duration| duration.as_secs() as i64)
1182        .unwrap_or(0)
1183}
1184
1185fn env_non_empty(key: &str) -> Option<String> {
1186    std::env::var(key)
1187        .ok()
1188        .map(|raw| raw.trim().to_string())
1189        .filter(|raw| !raw.is_empty())
1190}
1191
1192struct RunError {
1193    code: String,
1194    message: String,
1195    details: Option<String>,
1196    exit_code: i32,
1197}
1198
1199fn emit_single_envelope(mode: &str, ok: bool, result: &JsonResultItem) {
1200    println!(
1201        "{{\"schema_version\":\"{}\",\"command\":\"{}\",\"mode\":\"{}\",\"ok\":{},\"result\":{}}}",
1202        DIAG_SCHEMA_VERSION,
1203        DIAG_COMMAND,
1204        json_escape(mode),
1205        if ok { "true" } else { "false" },
1206        result.to_json()
1207    );
1208}
1209
1210fn emit_collection_envelope(mode: &str, ok: bool, results: &[JsonResultItem]) {
1211    let mut body = String::new();
1212    body.push('[');
1213    for (index, result) in results.iter().enumerate() {
1214        if index > 0 {
1215            body.push(',');
1216        }
1217        body.push_str(&result.to_json());
1218    }
1219    body.push(']');
1220
1221    println!(
1222        "{{\"schema_version\":\"{}\",\"command\":\"{}\",\"mode\":\"{}\",\"ok\":{},\"results\":{}}}",
1223        DIAG_SCHEMA_VERSION,
1224        DIAG_COMMAND,
1225        json_escape(mode),
1226        if ok { "true" } else { "false" },
1227        body
1228    );
1229}
1230
1231fn emit_error_json(code: &str, message: &str, details: Option<String>) {
1232    print!(
1233        "{{\"schema_version\":\"{}\",\"command\":\"{}\",\"ok\":false,\"error\":{{\"code\":\"{}\",\"message\":\"{}\"",
1234        DIAG_SCHEMA_VERSION,
1235        DIAG_COMMAND,
1236        json_escape(code),
1237        json_escape(message),
1238    );
1239    if let Some(details) = details {
1240        print!(",\"details\":{}", details);
1241    }
1242    println!("}}}}");
1243}
1244
1245impl JsonResultItem {
1246    fn to_json(&self) -> String {
1247        let mut s = String::new();
1248        s.push('{');
1249        push_field(&mut s, "name", &json_string(&self.name), true);
1250        push_field(
1251            &mut s,
1252            "target_file",
1253            &json_string(&self.target_file),
1254            false,
1255        );
1256        push_field(&mut s, "status", &json_string(&self.status), false);
1257        push_field(&mut s, "ok", if self.ok { "true" } else { "false" }, false);
1258        push_field(&mut s, "source", &json_string(&self.source), false);
1259
1260        if let Some(summary) = &self.summary {
1261            push_field(&mut s, "summary", &summary.to_json(), false);
1262        }
1263
1264        if let Some(raw_usage) = &self.raw_usage {
1265            let trimmed = raw_usage.trim();
1266            if trimmed.starts_with('{') && trimmed.ends_with('}') {
1267                push_field(&mut s, "raw_usage", trimmed, false);
1268            } else {
1269                push_field(&mut s, "raw_usage", &json_string(trimmed), false);
1270            }
1271        } else {
1272            push_field(&mut s, "raw_usage", "null", false);
1273        }
1274
1275        if let (Some(code), Some(message)) = (&self.error_code, &self.error_message) {
1276            let error_json = format!(
1277                "{{\"code\":\"{}\",\"message\":\"{}\"}}",
1278                json_escape(code),
1279                json_escape(message)
1280            );
1281            push_field(&mut s, "error", &error_json, false);
1282        }
1283
1284        s.push('}');
1285        s
1286    }
1287}
1288
1289impl RateLimitSummary {
1290    fn to_json(&self) -> String {
1291        let mut s = String::new();
1292        s.push('{');
1293        push_field(
1294            &mut s,
1295            "non_weekly_label",
1296            &json_string(&self.non_weekly_label),
1297            true,
1298        );
1299        push_field(
1300            &mut s,
1301            "non_weekly_remaining",
1302            &self.non_weekly_remaining.to_string(),
1303            false,
1304        );
1305        match self.non_weekly_reset_epoch {
1306            Some(value) => push_field(&mut s, "non_weekly_reset_epoch", &value.to_string(), false),
1307            None => push_field(&mut s, "non_weekly_reset_epoch", "null", false),
1308        }
1309        push_field(
1310            &mut s,
1311            "weekly_remaining",
1312            &self.weekly_remaining.to_string(),
1313            false,
1314        );
1315        push_field(
1316            &mut s,
1317            "weekly_reset_epoch",
1318            &self.weekly_reset_epoch.to_string(),
1319            false,
1320        );
1321        s.push('}');
1322        s
1323    }
1324}
1325
1326fn push_field(buf: &mut String, key: &str, value_json: &str, first: bool) {
1327    if !first {
1328        buf.push(',');
1329    }
1330    buf.push('"');
1331    buf.push_str(&json_escape(key));
1332    buf.push_str("\":");
1333    buf.push_str(value_json);
1334}
1335
1336fn json_string(raw: &str) -> String {
1337    format!("\"{}\"", json_escape(raw))
1338}
1339
1340fn json_array(values: Vec<String>) -> String {
1341    let mut out = String::from("[");
1342    for (index, value) in values.iter().enumerate() {
1343        if index > 0 {
1344            out.push(',');
1345        }
1346        out.push_str(value);
1347    }
1348    out.push(']');
1349    out
1350}
1351
1352fn json_obj(fields: Vec<(String, String)>) -> String {
1353    let mut out = String::from("{");
1354    for (index, (key, value)) in fields.iter().enumerate() {
1355        if index > 0 {
1356            out.push(',');
1357        }
1358        out.push('"');
1359        out.push_str(&json_escape(key));
1360        out.push_str("\":");
1361        out.push_str(value);
1362    }
1363    out.push('}');
1364    out
1365}
1366
1367fn json_escape(raw: &str) -> String {
1368    let mut escaped = String::with_capacity(raw.len());
1369    for ch in raw.chars() {
1370        match ch {
1371            '"' => escaped.push_str("\\\""),
1372            '\\' => escaped.push_str("\\\\"),
1373            '\u{08}' => escaped.push_str("\\b"),
1374            '\u{0C}' => escaped.push_str("\\f"),
1375            '\n' => escaped.push_str("\\n"),
1376            '\r' => escaped.push_str("\\r"),
1377            '\t' => escaped.push_str("\\t"),
1378            ch if ch.is_control() => escaped.push_str(&format!("\\u{:04x}", ch as u32)),
1379            ch => escaped.push(ch),
1380        }
1381    }
1382    escaped
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use nils_test_support::{EnvGuard, GlobalStateLock};
1389
1390    fn set_env(lock: &GlobalStateLock, key: &str, value: impl AsRef<std::ffi::OsStr>) -> EnvGuard {
1391        let value = value.as_ref().to_string_lossy().into_owned();
1392        EnvGuard::set(lock, key, &value)
1393    }
1394
1395    #[test]
1396    fn cache_key_normalizes_and_rejects_empty() {
1397        assert_eq!(cache_key("Alpha.Work").expect("key"), "alpha_work");
1398        assert!(cache_key("___").is_err());
1399    }
1400
1401    #[test]
1402    fn secret_file_basename_requires_non_empty_name() {
1403        assert_eq!(
1404            secret_file_basename(Path::new("/tmp/alpha.json")).expect("basename"),
1405            "alpha"
1406        );
1407        assert!(secret_file_basename(Path::new("/tmp/.json")).is_err());
1408    }
1409
1410    #[test]
1411    fn env_truthy_accepts_expected_variants() {
1412        let lock = GlobalStateLock::new();
1413        let _v1 = set_env(&lock, "GEMINI_TEST_TRUTHY", "true");
1414        assert!(shared_env::env_truthy("GEMINI_TEST_TRUTHY"));
1415        let _v2 = set_env(&lock, "GEMINI_TEST_TRUTHY", "ON");
1416        assert!(shared_env::env_truthy("GEMINI_TEST_TRUTHY"));
1417        let _v3 = set_env(&lock, "GEMINI_TEST_TRUTHY", "0");
1418        assert!(!shared_env::env_truthy("GEMINI_TEST_TRUTHY"));
1419    }
1420
1421    #[test]
1422    fn render_line_for_summary_formats_name_and_one_line() {
1423        let summary = RateLimitSummary {
1424            non_weekly_label: "5h".to_string(),
1425            non_weekly_remaining: 94,
1426            non_weekly_reset_epoch: Some(1700003600),
1427            weekly_remaining: 88,
1428            weekly_reset_epoch: 1700600000,
1429        };
1430        assert_eq!(
1431            render_line_for_summary("alpha", &summary, false, "%m-%d %H:%M"),
1432            "5h:94% W:88% 11-21 20:53"
1433        );
1434        assert_eq!(
1435            render_line_for_summary("alpha", &summary, true, "%m-%d %H:%M"),
1436            "alpha 5h:94% W:88% 11-21 20:53"
1437        );
1438    }
1439
1440    #[test]
1441    fn json_helpers_escape_and_build_structures() {
1442        assert_eq!(json_escape("a\"b\\n"), "a\\\"b\\\\n");
1443        assert_eq!(
1444            json_array(vec![json_string("a"), json_string("b")]),
1445            "[\"a\",\"b\"]"
1446        );
1447        assert_eq!(
1448            json_obj(vec![
1449                ("k1".to_string(), json_string("v1")),
1450                ("k2".to_string(), "2".to_string())
1451            ]),
1452            "{\"k1\":\"v1\",\"k2\":2}"
1453        );
1454    }
1455
1456    #[test]
1457    fn rate_limit_summary_to_json_includes_null_non_weekly_reset() {
1458        let summary = RateLimitSummary {
1459            non_weekly_label: "5h".to_string(),
1460            non_weekly_remaining: 90,
1461            non_weekly_reset_epoch: None,
1462            weekly_remaining: 80,
1463            weekly_reset_epoch: 1700600000,
1464        };
1465        let rendered = summary.to_json();
1466        assert!(rendered.contains("\"non_weekly_reset_epoch\":null"));
1467        assert!(rendered.contains("\"weekly_reset_epoch\":1700600000"));
1468    }
1469
1470    #[test]
1471    fn json_result_item_to_json_supports_error_and_raw_usage_variants() {
1472        let item = JsonResultItem {
1473            name: "alpha".to_string(),
1474            target_file: "alpha.json".to_string(),
1475            status: "error".to_string(),
1476            ok: false,
1477            source: "network".to_string(),
1478            summary: None,
1479            raw_usage: Some("{\"rate_limit\":{}}".to_string()),
1480            error_code: Some("request-failed".to_string()),
1481            error_message: Some("boom".to_string()),
1482        };
1483        let rendered = item.to_json();
1484        assert!(rendered.contains("\"raw_usage\":{\"rate_limit\":{}}"));
1485        assert!(rendered.contains("\"error\":{\"code\":\"request-failed\",\"message\":\"boom\"}"));
1486    }
1487
1488    #[test]
1489    fn collect_secret_files_returns_sorted_json_files() {
1490        let lock = GlobalStateLock::new();
1491        let dir = tempfile::TempDir::new().expect("tempdir");
1492        let secrets = dir.path().join("secrets");
1493        std::fs::create_dir_all(&secrets).expect("secrets");
1494        std::fs::write(secrets.join("b.json"), "{}").expect("b");
1495        std::fs::write(secrets.join("a.json"), "{}").expect("a");
1496        std::fs::write(secrets.join("skip.txt"), "x").expect("skip");
1497        let _secret = set_env(&lock, "GEMINI_SECRET_DIR", &secrets);
1498
1499        let files = collect_secret_files().expect("files");
1500        assert_eq!(
1501            files
1502                .iter()
1503                .map(|p| p.file_name().and_then(|v| v.to_str()).unwrap_or_default())
1504                .collect::<Vec<_>>(),
1505            vec!["a.json", "b.json"]
1506        );
1507    }
1508
1509    #[test]
1510    fn resolve_single_target_appends_json_when_secret_dir_is_configured() {
1511        let lock = GlobalStateLock::new();
1512        let dir = tempfile::TempDir::new().expect("tempdir");
1513        let secrets = dir.path().join("secrets");
1514        std::fs::create_dir_all(&secrets).expect("secrets");
1515        let target = secrets.join("alpha.json");
1516        std::fs::write(&target, "{}").expect("target");
1517        let _secret = set_env(&lock, "GEMINI_SECRET_DIR", &secrets);
1518
1519        let resolved = resolve_single_target(Some("alpha")).expect("resolved");
1520        assert_eq!(resolved, target);
1521    }
1522
1523    #[test]
1524    fn clear_starship_cache_rejects_non_absolute_cache_root() {
1525        let lock = GlobalStateLock::new();
1526        let _cache = set_env(&lock, "ZSH_CACHE_DIR", "relative-cache");
1527        let err = clear_starship_cache().expect_err("non-absolute should fail");
1528        assert!(err.contains("non-absolute cache root"));
1529    }
1530
1531    #[test]
1532    fn emit_helpers_cover_single_collection_and_error_envelopes() {
1533        let item = JsonResultItem {
1534            name: "alpha".to_string(),
1535            target_file: "alpha.json".to_string(),
1536            status: "ok".to_string(),
1537            ok: true,
1538            source: "network".to_string(),
1539            summary: Some(RateLimitSummary {
1540                non_weekly_label: "5h".to_string(),
1541                non_weekly_remaining: 94,
1542                non_weekly_reset_epoch: Some(1700003600),
1543                weekly_remaining: 88,
1544                weekly_reset_epoch: 1700600000,
1545            }),
1546            raw_usage: Some("{\"rate_limit\":{}}".to_string()),
1547            error_code: None,
1548            error_message: None,
1549        };
1550        emit_single_envelope("single", true, &item);
1551        emit_collection_envelope("all", true, &[item]);
1552        emit_error_json("failure", "boom", None);
1553    }
1554}