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