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(×tamp_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}