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