1use std::panic::{AssertUnwindSafe, catch_unwind};
27use std::path::{Path, PathBuf};
28
29use serde::{Deserialize, Serialize};
30
31use crate::config::Config;
32use crate::error::{AppError, Result};
33use crate::vendor::VendorId;
34
35pub fn has_local_credentials(vendor: VendorId, config: &Config) -> bool {
42 match vendor {
43 VendorId::Anthropic => anthropic_present(config),
44 VendorId::AnthropicApi => key_present(config, vendor),
45 VendorId::Openai => {
46 config
47 .openai
48 .resolve_auth_path(None)
49 .is_ok_and(|path| crate::openai::creds::read_from(&path).is_ok())
50 || config.openai.accounts.iter().any(|account| {
51 crate::openai::creds::read_from(&account.codex_auth_path).is_ok()
52 })
53 }
54 VendorId::Copilot => copilot_present(),
55 VendorId::Zai => key_present(config, vendor),
56 VendorId::Openrouter => key_present(config, vendor),
57 VendorId::Deepseek => key_present(config, vendor),
58 VendorId::Deepinfra => key_present(config, vendor),
59 VendorId::Kimi => crate::kimi::resolve_auth(&config.kimi).is_ok(),
60 VendorId::Kilo => key_present(config, vendor),
61 VendorId::Novita => key_present(config, vendor),
62 VendorId::Moonshot => key_present(config, vendor),
63 VendorId::Grok => key_present(config, vendor),
64 VendorId::Supergrok => crate::supergrok::scope::ScopePaths::with_overrides(
65 config.supergrok.auth_path.as_deref(),
66 config.supergrok.config_path.as_deref(),
67 )
68 .is_ok_and(|paths| crate::supergrok::direct::read_billing_key(&paths.auth).is_ok()),
69 VendorId::Grokbot => crate::grokbot::secrets_path(&config.grokbot)
73 .map(|path| crate::grokbot::creds::secrets_present_at(&path))
74 .unwrap_or(false),
75 VendorId::Antigravity => antigravity_present(),
76 VendorId::Cursor => cursor_present(config),
77 VendorId::Minimax => key_present(config, vendor),
78 VendorId::Kiro => {
79 let path = match config.kiro.db_path.clone() {
80 Some(path) => path,
81 None => match crate::kiro::db::default_db_path() {
82 Ok(path) => path,
83 Err(_) => return false,
84 },
85 };
86 crate::kiro::db::read_credentials(&path).is_ok()
87 }
88 VendorId::NousResearch => {
89 let store = crate::nous::credentials::CredentialStore::at(
93 crate::nous::credentials::default_credentials_path(),
94 );
95 matches!(store.read_unlocked(), Ok(Some(_)))
96 }
97 VendorId::OpenCodeGo => key_present(config, vendor),
98 VendorId::CommandCode => {
99 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref()).is_ok()
100 }
101 VendorId::Ollama => key_present(config, vendor),
102 VendorId::OrcaRouter => key_present(config, vendor),
103 VendorId::ModelStudio => crate::modelstudio::config_path(&config.modelstudio)
106 .map(|path| crate::modelstudio::creds::config_present_at(&path))
107 .unwrap_or(false),
108 }
109}
110
111fn key_present(config: &Config, vendor: VendorId) -> bool {
118 crate::config::optional_api_key(
119 config.api_key_env_for(vendor),
120 config.inline_api_key(vendor),
121 )
122 .is_some()
123 || config
124 .api_key_accounts(vendor)
125 .unwrap_or(&[])
126 .iter()
127 .any(|account| {
128 crate::config::optional_api_key(
129 account.api_key_env.as_deref().unwrap_or(""),
130 account.api_key.as_deref(),
131 )
132 .is_some()
133 })
134}
135
136fn anthropic_present(config: &Config) -> bool {
141 use crate::anthropic::creds::{CredsTarget, default_path, resolve};
142 let target = match config.anthropic.credentials_path.clone() {
143 Some(path) => CredsTarget::Explicit(path),
144 None => match default_path() {
145 Ok(path) => CredsTarget::Default(path),
146 Err(_) => return false,
147 },
148 };
149 resolve(&target).is_ok()
150 || config.anthropic.all_accounts().iter().any(|account| {
151 resolve(&CredsTarget::Named {
152 path: account.credentials_path.clone(),
153 config_dir: account.config_dir(),
154 })
155 .is_ok()
156 })
157}
158
159fn copilot_present() -> bool {
169 if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
170 return true;
171 }
172 crate::copilot::credentials::default_hosts_path()
173 .is_ok_and(|path| copilot_hosts_present_at(&path))
174}
175
176pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
179 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
180}
181
182fn antigravity_present() -> bool {
186 if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
187 return true;
188 }
189 if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
190 return true;
191 }
192 crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
199 |cache| {
200 crate::antigravity::cloud::has_persisted_session(
201 &crate::antigravity::cloud::oauth_cache_path(&cache),
202 )
203 },
204 )
205}
206
207fn cursor_present(config: &Config) -> bool {
208 let db_path = match config.cursor.db_path.clone() {
209 Some(path) => path,
210 None => match crate::cursor::db::default_db_path() {
211 Ok(path) => path,
212 Err(_) => return false,
213 },
214 };
215 let agent_auth_path = match config.cursor.agent_auth_path.clone() {
216 Some(path) => path,
217 None => match crate::cursor::db::default_agent_auth_path() {
218 Ok(path) => path,
219 Err(_) => return false,
220 },
221 };
222 crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
223}
224
225#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct DetectState {
230 #[serde(default)]
231 pub known: Vec<VendorId>,
232}
233
234impl DetectState {
235 pub fn load_at(path: &Path) -> DetectState {
239 std::fs::read(path)
240 .ok()
241 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
242 .unwrap_or_default()
243 }
244
245 pub fn save_at(&self, path: &Path) -> Result<()> {
247 let bytes = serde_json::to_vec_pretty(self)?;
248 crate::cache::atomic_write(path, &bytes)
249 }
250}
251
252pub fn default_state_path() -> Result<PathBuf> {
255 Ok(crate::cache::xdg_cache_dir()?
256 .join("ai-usagebar")
257 .join("detect.json"))
258}
259
260#[derive(Debug, Default, Clone, PartialEq, Eq)]
262pub struct DetectPlan {
263 pub enable: Vec<VendorId>,
265 pub known: Vec<VendorId>,
267 pub probed: usize,
270}
271
272#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
275pub struct DetectReport {
276 pub enabled: Vec<VendorId>,
278 pub known: Vec<VendorId>,
280 pub probed: usize,
282}
283
284pub fn plan(
290 config: &Config,
291 state: &DetectState,
292 all: &[VendorId],
293 force: bool,
294 probe: impl Fn(VendorId) -> bool,
295) -> DetectPlan {
296 let candidates: Vec<VendorId> = all
297 .iter()
298 .copied()
299 .filter(|vendor| force || !state.known.contains(vendor))
300 .collect();
301 let probed = candidates.len();
302 let enable = candidates
303 .into_iter()
304 .filter(|vendor| !config.is_enabled(*vendor))
305 .filter(|vendor| probe(*vendor))
306 .collect();
307 let known = VendorId::all()
308 .iter()
309 .copied()
310 .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
311 .collect();
312 DetectPlan {
313 enable,
314 known,
315 probed,
316 }
317}
318
319pub fn run_once(
328 config_path: Option<&Path>,
329 state_path: &Path,
330 force: bool,
331) -> Result<Vec<VendorId>> {
332 run_once_report(config_path, state_path, force).map(|report| report.enabled)
333}
334
335pub fn run_once_report(
338 config_path: Option<&Path>,
339 state_path: &Path,
340 force: bool,
341) -> Result<DetectReport> {
342 run_once_with(config_path, state_path, force, |vendor, config| {
343 catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
344 })
345}
346
347pub fn run_cli(all: bool, json: bool) -> i32 {
352 let report =
353 default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
354 match report {
355 Ok(report) if json => match serde_json::to_string(&report) {
356 Ok(text) => {
357 println!("{text}");
358 0
359 }
360 Err(error) => {
361 eprintln!("ai-usagebar detect: {error}");
362 1
363 }
364 },
365 Ok(report) => {
366 println!(
367 "{}",
368 format_report(&report, &crate::config::config_path_hint())
369 );
370 0
371 }
372 Err(error) => {
373 eprintln!("ai-usagebar detect: {}", error.user_message());
374 1
375 }
376 }
377}
378
379pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
382 if report.enabled.is_empty() {
383 let noun = if report.probed == 1 {
384 "vendor"
385 } else {
386 "vendors"
387 };
388 return format!("Nothing new detected ({} {noun} checked)", report.probed);
389 }
390 let names: Vec<&str> = report
391 .enabled
392 .iter()
393 .map(|vendor| vendor.display_name())
394 .collect();
395 format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
396}
397
398pub fn run_once_with(
402 config_path: Option<&Path>,
403 state_path: &Path,
404 force: bool,
405 probe: impl Fn(VendorId, &Config) -> bool,
406) -> Result<DetectReport> {
407 let resolved = match config_path {
408 Some(path) => Some(path.to_path_buf()),
409 None => crate::config::resolved_path(),
410 };
411 let config = match &resolved {
412 Some(path) => Config::load_from(path)?,
413 None => Config::default(),
414 };
415 let state = DetectState::load_at(state_path);
416 let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
417 probe(vendor, &config)
418 });
419 let enabled = if plan.enable.is_empty() {
423 Vec::new()
424 } else {
425 let path = resolved.ok_or_else(|| {
426 AppError::Other("could not resolve the config.toml path to enable vendors in".into())
427 })?;
428 crate::config::enable_vendors_in(&path, &plan.enable)?
429 };
430 DetectState {
431 known: plan.known.clone(),
432 }
433 .save_at(state_path)?;
434 Ok(DetectReport {
435 enabled,
436 known: plan.known,
437 probed: plan.probed,
438 })
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use tempfile::TempDir;
445
446 fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
447 move |vendor| present.contains(&vendor)
448 }
449
450 #[test]
451 fn state_round_trips_through_json_by_slug() {
452 let dir = TempDir::new().unwrap();
453 let path = dir.path().join("nested").join("detect.json");
454 let state = DetectState {
455 known: vec![
456 VendorId::Cursor,
457 VendorId::OpenCodeGo,
458 VendorId::NousResearch,
459 ],
460 };
461
462 state.save_at(&path).unwrap();
463
464 let text = std::fs::read_to_string(&path).unwrap();
465 assert!(text.contains("\"opencode-go\""), "{text}");
466 assert!(text.contains("\"nous\""), "{text}");
467 assert_eq!(DetectState::load_at(&path), state);
468 }
469
470 #[test]
471 fn missing_or_corrupt_state_is_the_default() {
472 let dir = TempDir::new().unwrap();
473 assert_eq!(
474 DetectState::load_at(&dir.path().join("absent.json")),
475 DetectState::default()
476 );
477
478 let corrupt = dir.path().join("corrupt.json");
479 std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
480 assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
481
482 let unknown_slug = dir.path().join("unknown.json");
483 std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
484 assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
485 }
486
487 #[test]
488 fn plan_enables_only_unknown_probed_vendors_that_are_off() {
489 let config = Config::default(); let state = DetectState {
491 known: vec![VendorId::Grok],
492 };
493 let all = [
494 VendorId::Anthropic, VendorId::Grok, VendorId::Cursor, VendorId::Kiro, ];
499 let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
500
501 let plan = plan(&config, &state, &all, false, probe_in(&present));
502
503 assert_eq!(plan.enable, vec![VendorId::Cursor]);
504 }
505
506 #[test]
507 fn force_reconsiders_known_vendors_but_never_enabled_ones() {
508 let config = Config::default();
509 let state = DetectState {
510 known: vec![VendorId::Grok, VendorId::Zai],
511 };
512 let all = [VendorId::Zai, VendorId::Grok];
513 let present = [VendorId::Zai, VendorId::Grok];
514
515 let plan = plan(&config, &state, &all, true, probe_in(&present));
516
517 assert_eq!(plan.enable, vec![VendorId::Grok]);
518 }
519
520 #[test]
521 fn plan_orders_enable_by_the_candidate_list() {
522 let config = Config::default();
523 let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
524 let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
525
526 let plan = plan(
527 &config,
528 &DetectState::default(),
529 &all,
530 false,
531 probe_in(&present),
532 );
533
534 assert_eq!(
535 plan.enable,
536 vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
537 );
538 }
539
540 #[test]
541 fn known_becomes_the_union_in_canonical_order_without_duplicates() {
542 let config = Config::default();
543 let state = DetectState {
544 known: vec![VendorId::Grok, VendorId::Cursor],
545 };
546 let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
547
548 let plan = plan(&config, &state, &all, false, |_| false);
549
550 assert_eq!(
551 plan.known,
552 vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
553 );
554 assert!(plan.enable.is_empty());
555 }
556
557 #[test]
558 fn probe_is_not_consulted_for_skipped_vendors() {
559 let config = Config::default();
560 let state = DetectState {
561 known: vec![VendorId::Grok],
562 };
563 let all = [VendorId::Grok, VendorId::Anthropic];
564
565 let plan = plan(&config, &state, &all, false, |vendor| {
566 panic!("probe called for {}", vendor.slug())
567 });
568
569 assert!(plan.enable.is_empty());
570 }
571
572 #[test]
573 fn a_named_api_key_account_alone_counts_as_a_credential() {
574 let mut config = Config::default();
576 config.deepseek.api_key_env.clear();
577 assert!(!key_present(&config, VendorId::Deepseek));
578
579 config.deepseek.accounts.push(crate::config::ApiKeyAccount {
580 label: "work".into(),
581 api_key_env: None,
582 api_key: Some("work-key".into()),
583 management_api_key_env: None,
584 });
585 assert!(key_present(&config, VendorId::Deepseek));
586 config.kilo.api_key_env.clear();
588 assert!(!key_present(&config, VendorId::Kilo));
589 }
590
591 #[test]
592 fn a_named_anthropic_account_alone_counts_as_a_credential() {
593 let dir = TempDir::new().unwrap();
594 let creds_file = dir.path().join("work.json");
595 std::fs::write(
596 &creds_file,
597 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":2000000000000}}"#,
598 )
599 .unwrap();
600
601 let mut config = Config::default();
602 config.anthropic.credentials_path = Some(dir.path().join("absent.json"));
603 assert!(!anthropic_present(&config));
604
605 config
606 .anthropic
607 .accounts
608 .push(crate::config::AnthropicAccount {
609 label: "work".into(),
610 credentials_path: creds_file,
611 });
612 assert!(anthropic_present(&config));
613 }
614
615 #[test]
616 fn a_named_openai_account_alone_counts_as_a_credential() {
617 let dir = TempDir::new().unwrap();
618 let auth_file = dir.path().join("work-auth.json");
619 std::fs::write(
620 &auth_file,
621 r#"{"tokens":{"access_token":"a","refresh_token":"r","id_token":"i"}}"#,
622 )
623 .unwrap();
624
625 let mut config = Config::default();
626 config.openai.codex_auth_path = Some(dir.path().join("absent.json"));
627 assert!(!has_local_credentials(VendorId::Openai, &config));
628
629 config.openai.accounts.push(crate::config::OpenAiAccount {
630 label: "work".into(),
631 codex_auth_path: auth_file,
632 });
633 assert!(has_local_credentials(VendorId::Openai, &config));
634 }
635
636 #[test]
637 fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
638 let dir = TempDir::new().unwrap();
639 let hosts = dir.path().join("hosts.yml");
640 assert!(!copilot_hosts_present_at(&hosts));
641
642 std::fs::write(&hosts, "").unwrap();
643 assert!(!copilot_hosts_present_at(&hosts));
644
645 std::fs::write(&hosts, "github.com:\n user: octocat\n").unwrap();
646 assert!(copilot_hosts_present_at(&hosts));
647
648 assert!(!copilot_hosts_present_at(dir.path()));
649 }
650
651 #[test]
655 fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
656 let dir = TempDir::new().unwrap();
657 let config_path = dir.path().join("config.toml");
658 let state_path = dir.path().join("detect.json");
659 std::fs::write(
660 &config_path,
661 "# mine
662[zai]
663enabled = false
664",
665 )
666 .unwrap();
667 let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
668 let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
669
670 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
671
672 assert_eq!(report.enabled, vec![VendorId::Cursor]);
676 assert_eq!(report.known, VendorId::all());
677 assert_eq!(report.probed, VendorId::all().len());
678 let after = Config::load_from(&config_path).unwrap();
679 assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
680 assert!(after.is_enabled(VendorId::Cursor));
681 let text = std::fs::read_to_string(&config_path).unwrap();
682 assert!(
683 text.starts_with(
684 "# mine
685"
686 ),
687 "{text}"
688 );
689 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
690
691 let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
692 assert!(again.enabled.is_empty(), "{again:?}");
693 assert_eq!(again.probed, 0, "everything is known: nothing to check");
694
695 std::fs::write(
697 &config_path,
698 "[cursor]
699enabled = false
700",
701 )
702 .unwrap();
703 let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
704 assert!(third.enabled.is_empty(), "{third:?}");
705 assert!(
706 !Config::load_from(&config_path)
707 .unwrap()
708 .is_enabled(VendorId::Cursor)
709 );
710
711 let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
716 assert!(forced.enabled.is_empty(), "{forced:?}");
717 assert_eq!(forced.probed, VendorId::all().len());
718 assert!(
719 !Config::load_from(&config_path)
720 .unwrap()
721 .is_enabled(VendorId::Cursor)
722 );
723 }
724
725 #[test]
726 fn plan_counts_candidates_not_enables() {
727 let config = Config::default();
728 let state = DetectState {
729 known: vec![VendorId::Grok],
730 };
731 let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
732
733 let unforced = plan(&config, &state, &all, false, |_| false);
734 assert_eq!(unforced.probed, 2, "Grok is known and skipped");
735
736 let forced = plan(&config, &state, &all, true, |_| false);
737 assert_eq!(forced.probed, 3);
738 }
739
740 #[test]
741 fn format_report_lists_display_names_and_where_they_were_written() {
742 let report = DetectReport {
743 enabled: vec![VendorId::Cursor, VendorId::Kiro],
744 known: VendorId::all().to_vec(),
745 probed: 3,
746 };
747
748 let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
749
750 assert_eq!(
751 text,
752 "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
753 );
754 }
755
756 #[test]
757 fn format_report_says_how_many_were_checked_when_nothing_changed() {
758 let none = DetectReport {
759 enabled: vec![],
760 known: VendorId::all().to_vec(),
761 probed: 3,
762 };
763 assert_eq!(
764 format_report(&none, "unused"),
765 "Nothing new detected (3 vendors checked)"
766 );
767
768 let one = DetectReport {
769 probed: 1,
770 ..none.clone()
771 };
772 assert_eq!(
773 format_report(&one, "unused"),
774 "Nothing new detected (1 vendor checked)"
775 );
776 }
777
778 #[test]
780 fn report_serializes_slugs_and_the_probed_count() {
781 let dir = TempDir::new().unwrap();
782 let config_path = dir.path().join("config.toml");
783 let state_path = dir.path().join("detect.json");
784 std::fs::write(&config_path, "").unwrap();
785 DetectState {
786 known: vec![VendorId::Anthropic, VendorId::Grok],
787 }
788 .save_at(&state_path)
789 .unwrap();
790 let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
791
792 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
793 let json: serde_json::Value = serde_json::to_value(&report).unwrap();
794
795 assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
796 assert_eq!(
797 json["probed"],
798 serde_json::json!(VendorId::all().len() - 2),
799 "the two known vendors were not candidates"
800 );
801 let known = json["known"].as_array().unwrap();
802 assert_eq!(known.len(), VendorId::all().len());
803 assert_eq!(known[0], serde_json::json!("anthropic"));
804 assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
805 }
806
807 #[test]
808 fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
809 let dir = TempDir::new().unwrap();
810 let config_path = dir.path().join("config.toml");
811 let state_path = dir.path().join("detect.json");
812 let probe = |vendor: VendorId, _: &Config| {
813 catch_unwind(AssertUnwindSafe(|| {
814 if vendor == VendorId::Kiro {
815 panic!("boom");
816 }
817 vendor == VendorId::Grok
818 }))
819 .unwrap_or(false)
820 };
821
822 let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
823
824 assert_eq!(enabled.enabled, vec![VendorId::Grok]);
825 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
826 }
827}