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 => config
46 .openai
47 .resolve_auth_path(None)
48 .is_ok_and(|path| crate::openai::creds::read_from(&path).is_ok()),
49 VendorId::Copilot => copilot_present(),
50 VendorId::Zai => key_present(config, vendor),
51 VendorId::Openrouter => key_present(config, vendor),
52 VendorId::Deepseek => key_present(config, vendor),
53 VendorId::Deepinfra => key_present(config, vendor),
54 VendorId::Kimi => crate::kimi::resolve_auth(&config.kimi).is_ok(),
55 VendorId::Kilo => key_present(config, vendor),
56 VendorId::Novita => key_present(config, vendor),
57 VendorId::Moonshot => key_present(config, vendor),
58 VendorId::Grok => key_present(config, vendor),
59 VendorId::Supergrok => crate::supergrok::scope::ScopePaths::with_overrides(
60 config.supergrok.auth_path.as_deref(),
61 config.supergrok.config_path.as_deref(),
62 )
63 .is_ok_and(|paths| crate::supergrok::direct::read_billing_key(&paths.auth).is_ok()),
64 VendorId::Grokbot => crate::grokbot::secrets_path(&config.grokbot)
68 .map(|path| crate::grokbot::creds::secrets_present_at(&path))
69 .unwrap_or(false),
70 VendorId::Antigravity => antigravity_present(),
71 VendorId::Cursor => cursor_present(config),
72 VendorId::Minimax => key_present(config, vendor),
73 VendorId::Kiro => {
74 let path = match config.kiro.db_path.clone() {
75 Some(path) => path,
76 None => match crate::kiro::db::default_db_path() {
77 Ok(path) => path,
78 Err(_) => return false,
79 },
80 };
81 crate::kiro::db::read_credentials(&path).is_ok()
82 }
83 VendorId::NousResearch => {
84 let store = crate::nous::credentials::CredentialStore::at(
88 crate::nous::credentials::default_credentials_path(),
89 );
90 matches!(store.read_unlocked(), Ok(Some(_)))
91 }
92 VendorId::OpenCodeGo => key_present(config, vendor),
93 VendorId::CommandCode => {
94 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref()).is_ok()
95 }
96 VendorId::Ollama => key_present(config, vendor),
97 VendorId::OrcaRouter => key_present(config, vendor),
98 VendorId::ModelStudio => crate::modelstudio::config_path(&config.modelstudio)
101 .map(|path| crate::modelstudio::creds::config_present_at(&path))
102 .unwrap_or(false),
103 }
104}
105
106fn key_present(config: &Config, vendor: VendorId) -> bool {
113 crate::config::optional_api_key(
114 config.api_key_env_for(vendor),
115 config.inline_api_key(vendor),
116 )
117 .is_some()
118 || config
119 .api_key_accounts(vendor)
120 .unwrap_or(&[])
121 .iter()
122 .any(|account| {
123 crate::config::optional_api_key(
124 account.api_key_env.as_deref().unwrap_or(""),
125 account.api_key.as_deref(),
126 )
127 .is_some()
128 })
129}
130
131fn anthropic_present(config: &Config) -> bool {
135 use crate::anthropic::creds::{CredsTarget, default_path, resolve};
136 let target = match config.anthropic.credentials_path.clone() {
137 Some(path) => CredsTarget::Explicit(path),
138 None => match default_path() {
139 Ok(path) => CredsTarget::Default(path),
140 Err(_) => return false,
141 },
142 };
143 resolve(&target).is_ok()
144}
145
146fn copilot_present() -> bool {
156 if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
157 return true;
158 }
159 crate::copilot::credentials::default_hosts_path()
160 .is_ok_and(|path| copilot_hosts_present_at(&path))
161}
162
163pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
166 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
167}
168
169fn antigravity_present() -> bool {
173 if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
174 return true;
175 }
176 if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
177 return true;
178 }
179 crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
186 |cache| {
187 crate::antigravity::cloud::has_persisted_session(
188 &crate::antigravity::cloud::oauth_cache_path(&cache),
189 )
190 },
191 )
192}
193
194fn cursor_present(config: &Config) -> bool {
195 let db_path = match config.cursor.db_path.clone() {
196 Some(path) => path,
197 None => match crate::cursor::db::default_db_path() {
198 Ok(path) => path,
199 Err(_) => return false,
200 },
201 };
202 let agent_auth_path = match config.cursor.agent_auth_path.clone() {
203 Some(path) => path,
204 None => match crate::cursor::db::default_agent_auth_path() {
205 Ok(path) => path,
206 Err(_) => return false,
207 },
208 };
209 crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
210}
211
212#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct DetectState {
217 #[serde(default)]
218 pub known: Vec<VendorId>,
219}
220
221impl DetectState {
222 pub fn load_at(path: &Path) -> DetectState {
226 std::fs::read(path)
227 .ok()
228 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
229 .unwrap_or_default()
230 }
231
232 pub fn save_at(&self, path: &Path) -> Result<()> {
234 let bytes = serde_json::to_vec_pretty(self)?;
235 crate::cache::atomic_write(path, &bytes)
236 }
237}
238
239pub fn default_state_path() -> Result<PathBuf> {
242 Ok(crate::cache::xdg_cache_dir()?
243 .join("ai-usagebar")
244 .join("detect.json"))
245}
246
247#[derive(Debug, Default, Clone, PartialEq, Eq)]
249pub struct DetectPlan {
250 pub enable: Vec<VendorId>,
252 pub known: Vec<VendorId>,
254 pub probed: usize,
257}
258
259#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
262pub struct DetectReport {
263 pub enabled: Vec<VendorId>,
265 pub known: Vec<VendorId>,
267 pub probed: usize,
269}
270
271pub fn plan(
277 config: &Config,
278 state: &DetectState,
279 all: &[VendorId],
280 force: bool,
281 probe: impl Fn(VendorId) -> bool,
282) -> DetectPlan {
283 let candidates: Vec<VendorId> = all
284 .iter()
285 .copied()
286 .filter(|vendor| force || !state.known.contains(vendor))
287 .collect();
288 let probed = candidates.len();
289 let enable = candidates
290 .into_iter()
291 .filter(|vendor| !config.is_enabled(*vendor))
292 .filter(|vendor| probe(*vendor))
293 .collect();
294 let known = VendorId::all()
295 .iter()
296 .copied()
297 .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
298 .collect();
299 DetectPlan {
300 enable,
301 known,
302 probed,
303 }
304}
305
306pub fn run_once(
315 config_path: Option<&Path>,
316 state_path: &Path,
317 force: bool,
318) -> Result<Vec<VendorId>> {
319 run_once_report(config_path, state_path, force).map(|report| report.enabled)
320}
321
322pub fn run_once_report(
325 config_path: Option<&Path>,
326 state_path: &Path,
327 force: bool,
328) -> Result<DetectReport> {
329 run_once_with(config_path, state_path, force, |vendor, config| {
330 catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
331 })
332}
333
334pub fn run_cli(all: bool, json: bool) -> i32 {
339 let report =
340 default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
341 match report {
342 Ok(report) if json => match serde_json::to_string(&report) {
343 Ok(text) => {
344 println!("{text}");
345 0
346 }
347 Err(error) => {
348 eprintln!("ai-usagebar detect: {error}");
349 1
350 }
351 },
352 Ok(report) => {
353 println!(
354 "{}",
355 format_report(&report, &crate::config::config_path_hint())
356 );
357 0
358 }
359 Err(error) => {
360 eprintln!("ai-usagebar detect: {}", error.user_message());
361 1
362 }
363 }
364}
365
366pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
369 if report.enabled.is_empty() {
370 let noun = if report.probed == 1 {
371 "vendor"
372 } else {
373 "vendors"
374 };
375 return format!("Nothing new detected ({} {noun} checked)", report.probed);
376 }
377 let names: Vec<&str> = report
378 .enabled
379 .iter()
380 .map(|vendor| vendor.display_name())
381 .collect();
382 format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
383}
384
385pub fn run_once_with(
389 config_path: Option<&Path>,
390 state_path: &Path,
391 force: bool,
392 probe: impl Fn(VendorId, &Config) -> bool,
393) -> Result<DetectReport> {
394 let resolved = match config_path {
395 Some(path) => Some(path.to_path_buf()),
396 None => crate::config::resolved_path(),
397 };
398 let config = match &resolved {
399 Some(path) => Config::load_from(path)?,
400 None => Config::default(),
401 };
402 let state = DetectState::load_at(state_path);
403 let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
404 probe(vendor, &config)
405 });
406 let enabled = if plan.enable.is_empty() {
410 Vec::new()
411 } else {
412 let path = resolved.ok_or_else(|| {
413 AppError::Other("could not resolve the config.toml path to enable vendors in".into())
414 })?;
415 crate::config::enable_vendors_in(&path, &plan.enable)?
416 };
417 DetectState {
418 known: plan.known.clone(),
419 }
420 .save_at(state_path)?;
421 Ok(DetectReport {
422 enabled,
423 known: plan.known,
424 probed: plan.probed,
425 })
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use tempfile::TempDir;
432
433 fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
434 move |vendor| present.contains(&vendor)
435 }
436
437 #[test]
438 fn state_round_trips_through_json_by_slug() {
439 let dir = TempDir::new().unwrap();
440 let path = dir.path().join("nested").join("detect.json");
441 let state = DetectState {
442 known: vec![
443 VendorId::Cursor,
444 VendorId::OpenCodeGo,
445 VendorId::NousResearch,
446 ],
447 };
448
449 state.save_at(&path).unwrap();
450
451 let text = std::fs::read_to_string(&path).unwrap();
452 assert!(text.contains("\"opencode-go\""), "{text}");
453 assert!(text.contains("\"nous\""), "{text}");
454 assert_eq!(DetectState::load_at(&path), state);
455 }
456
457 #[test]
458 fn missing_or_corrupt_state_is_the_default() {
459 let dir = TempDir::new().unwrap();
460 assert_eq!(
461 DetectState::load_at(&dir.path().join("absent.json")),
462 DetectState::default()
463 );
464
465 let corrupt = dir.path().join("corrupt.json");
466 std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
467 assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
468
469 let unknown_slug = dir.path().join("unknown.json");
470 std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
471 assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
472 }
473
474 #[test]
475 fn plan_enables_only_unknown_probed_vendors_that_are_off() {
476 let config = Config::default(); let state = DetectState {
478 known: vec![VendorId::Grok],
479 };
480 let all = [
481 VendorId::Anthropic, VendorId::Grok, VendorId::Cursor, VendorId::Kiro, ];
486 let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
487
488 let plan = plan(&config, &state, &all, false, probe_in(&present));
489
490 assert_eq!(plan.enable, vec![VendorId::Cursor]);
491 }
492
493 #[test]
494 fn force_reconsiders_known_vendors_but_never_enabled_ones() {
495 let config = Config::default();
496 let state = DetectState {
497 known: vec![VendorId::Grok, VendorId::Zai],
498 };
499 let all = [VendorId::Zai, VendorId::Grok];
500 let present = [VendorId::Zai, VendorId::Grok];
501
502 let plan = plan(&config, &state, &all, true, probe_in(&present));
503
504 assert_eq!(plan.enable, vec![VendorId::Grok]);
505 }
506
507 #[test]
508 fn plan_orders_enable_by_the_candidate_list() {
509 let config = Config::default();
510 let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
511 let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
512
513 let plan = plan(
514 &config,
515 &DetectState::default(),
516 &all,
517 false,
518 probe_in(&present),
519 );
520
521 assert_eq!(
522 plan.enable,
523 vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
524 );
525 }
526
527 #[test]
528 fn known_becomes_the_union_in_canonical_order_without_duplicates() {
529 let config = Config::default();
530 let state = DetectState {
531 known: vec![VendorId::Grok, VendorId::Cursor],
532 };
533 let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
534
535 let plan = plan(&config, &state, &all, false, |_| false);
536
537 assert_eq!(
538 plan.known,
539 vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
540 );
541 assert!(plan.enable.is_empty());
542 }
543
544 #[test]
545 fn probe_is_not_consulted_for_skipped_vendors() {
546 let config = Config::default();
547 let state = DetectState {
548 known: vec![VendorId::Grok],
549 };
550 let all = [VendorId::Grok, VendorId::Anthropic];
551
552 let plan = plan(&config, &state, &all, false, |vendor| {
553 panic!("probe called for {}", vendor.slug())
554 });
555
556 assert!(plan.enable.is_empty());
557 }
558
559 #[test]
560 fn a_named_api_key_account_alone_counts_as_a_credential() {
561 let mut config = Config::default();
563 config.deepseek.api_key_env.clear();
564 assert!(!key_present(&config, VendorId::Deepseek));
565
566 config.deepseek.accounts.push(crate::config::ApiKeyAccount {
567 label: "work".into(),
568 api_key_env: None,
569 api_key: Some("work-key".into()),
570 });
571 assert!(key_present(&config, VendorId::Deepseek));
572 config.kilo.api_key_env.clear();
574 assert!(!key_present(&config, VendorId::Kilo));
575 }
576
577 #[test]
578 fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
579 let dir = TempDir::new().unwrap();
580 let hosts = dir.path().join("hosts.yml");
581 assert!(!copilot_hosts_present_at(&hosts));
582
583 std::fs::write(&hosts, "").unwrap();
584 assert!(!copilot_hosts_present_at(&hosts));
585
586 std::fs::write(&hosts, "github.com:\n user: octocat\n").unwrap();
587 assert!(copilot_hosts_present_at(&hosts));
588
589 assert!(!copilot_hosts_present_at(dir.path()));
590 }
591
592 #[test]
596 fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
597 let dir = TempDir::new().unwrap();
598 let config_path = dir.path().join("config.toml");
599 let state_path = dir.path().join("detect.json");
600 std::fs::write(
601 &config_path,
602 "# mine
603[zai]
604enabled = false
605",
606 )
607 .unwrap();
608 let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
609 let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
610
611 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
612
613 assert_eq!(report.enabled, vec![VendorId::Cursor]);
617 assert_eq!(report.known, VendorId::all());
618 assert_eq!(report.probed, VendorId::all().len());
619 let after = Config::load_from(&config_path).unwrap();
620 assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
621 assert!(after.is_enabled(VendorId::Cursor));
622 let text = std::fs::read_to_string(&config_path).unwrap();
623 assert!(
624 text.starts_with(
625 "# mine
626"
627 ),
628 "{text}"
629 );
630 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
631
632 let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
633 assert!(again.enabled.is_empty(), "{again:?}");
634 assert_eq!(again.probed, 0, "everything is known: nothing to check");
635
636 std::fs::write(
638 &config_path,
639 "[cursor]
640enabled = false
641",
642 )
643 .unwrap();
644 let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
645 assert!(third.enabled.is_empty(), "{third:?}");
646 assert!(
647 !Config::load_from(&config_path)
648 .unwrap()
649 .is_enabled(VendorId::Cursor)
650 );
651
652 let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
657 assert!(forced.enabled.is_empty(), "{forced:?}");
658 assert_eq!(forced.probed, VendorId::all().len());
659 assert!(
660 !Config::load_from(&config_path)
661 .unwrap()
662 .is_enabled(VendorId::Cursor)
663 );
664 }
665
666 #[test]
667 fn plan_counts_candidates_not_enables() {
668 let config = Config::default();
669 let state = DetectState {
670 known: vec![VendorId::Grok],
671 };
672 let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
673
674 let unforced = plan(&config, &state, &all, false, |_| false);
675 assert_eq!(unforced.probed, 2, "Grok is known and skipped");
676
677 let forced = plan(&config, &state, &all, true, |_| false);
678 assert_eq!(forced.probed, 3);
679 }
680
681 #[test]
682 fn format_report_lists_display_names_and_where_they_were_written() {
683 let report = DetectReport {
684 enabled: vec![VendorId::Cursor, VendorId::Kiro],
685 known: VendorId::all().to_vec(),
686 probed: 3,
687 };
688
689 let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
690
691 assert_eq!(
692 text,
693 "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
694 );
695 }
696
697 #[test]
698 fn format_report_says_how_many_were_checked_when_nothing_changed() {
699 let none = DetectReport {
700 enabled: vec![],
701 known: VendorId::all().to_vec(),
702 probed: 3,
703 };
704 assert_eq!(
705 format_report(&none, "unused"),
706 "Nothing new detected (3 vendors checked)"
707 );
708
709 let one = DetectReport {
710 probed: 1,
711 ..none.clone()
712 };
713 assert_eq!(
714 format_report(&one, "unused"),
715 "Nothing new detected (1 vendor checked)"
716 );
717 }
718
719 #[test]
721 fn report_serializes_slugs_and_the_probed_count() {
722 let dir = TempDir::new().unwrap();
723 let config_path = dir.path().join("config.toml");
724 let state_path = dir.path().join("detect.json");
725 std::fs::write(&config_path, "").unwrap();
726 DetectState {
727 known: vec![VendorId::Anthropic, VendorId::Grok],
728 }
729 .save_at(&state_path)
730 .unwrap();
731 let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
732
733 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
734 let json: serde_json::Value = serde_json::to_value(&report).unwrap();
735
736 assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
737 assert_eq!(
738 json["probed"],
739 serde_json::json!(VendorId::all().len() - 2),
740 "the two known vendors were not candidates"
741 );
742 let known = json["known"].as_array().unwrap();
743 assert_eq!(known.len(), VendorId::all().len());
744 assert_eq!(known[0], serde_json::json!("anthropic"));
745 assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
746 }
747
748 #[test]
749 fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
750 let dir = TempDir::new().unwrap();
751 let config_path = dir.path().join("config.toml");
752 let state_path = dir.path().join("detect.json");
753 let probe = |vendor: VendorId, _: &Config| {
754 catch_unwind(AssertUnwindSafe(|| {
755 if vendor == VendorId::Kiro {
756 panic!("boom");
757 }
758 vendor == VendorId::Grok
759 }))
760 .unwrap_or(false)
761 };
762
763 let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
764
765 assert_eq!(enabled.enabled, vec![VendorId::Grok]);
766 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
767 }
768}