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 VendorId::Devin => crate::devin::credentials_path(&config.devin)
110 .map(|path| path.is_file())
111 .unwrap_or(false),
112 }
113}
114
115fn key_present(config: &Config, vendor: VendorId) -> bool {
122 crate::config::optional_api_key(
123 config.api_key_env_for(vendor),
124 config.inline_api_key(vendor),
125 )
126 .is_some()
127 || config
128 .api_key_accounts(vendor)
129 .unwrap_or(&[])
130 .iter()
131 .any(|account| {
132 crate::config::optional_api_key(
133 account.api_key_env.as_deref().unwrap_or(""),
134 account.api_key.as_deref(),
135 )
136 .is_some()
137 })
138}
139
140fn anthropic_present(config: &Config) -> bool {
145 use crate::anthropic::creds::{CredsTarget, default_path, resolve};
146 let target = match config.anthropic.credentials_path.clone() {
147 Some(path) => CredsTarget::Explicit(path),
148 None => match default_path() {
149 Ok(path) => CredsTarget::Default(path),
150 Err(_) => return false,
151 },
152 };
153 resolve(&target).is_ok()
154 || config.anthropic.all_accounts().iter().any(|account| {
155 resolve(&CredsTarget::Named {
156 path: account.credentials_path.clone(),
157 config_dir: account.config_dir(),
158 })
159 .is_ok()
160 })
161}
162
163fn copilot_present() -> bool {
173 if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
174 return true;
175 }
176 crate::copilot::credentials::default_hosts_path()
177 .is_ok_and(|path| copilot_hosts_present_at(&path))
178}
179
180pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
183 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
184}
185
186fn antigravity_present() -> bool {
190 if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
191 return true;
192 }
193 if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
194 return true;
195 }
196 crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
203 |cache| {
204 crate::antigravity::cloud::has_persisted_session(
205 &crate::antigravity::cloud::oauth_cache_path(&cache),
206 )
207 },
208 )
209}
210
211fn cursor_present(config: &Config) -> bool {
212 let db_path = match config.cursor.db_path.clone() {
213 Some(path) => path,
214 None => match crate::cursor::db::default_db_path() {
215 Ok(path) => path,
216 Err(_) => return false,
217 },
218 };
219 let agent_auth_path = match config.cursor.agent_auth_path.clone() {
220 Some(path) => path,
221 None => match crate::cursor::db::default_agent_auth_path() {
222 Ok(path) => path,
223 Err(_) => return false,
224 },
225 };
226 crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
227}
228
229#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct DetectState {
234 #[serde(default)]
235 pub known: Vec<VendorId>,
236}
237
238impl DetectState {
239 pub fn load_at(path: &Path) -> DetectState {
243 std::fs::read(path)
244 .ok()
245 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
246 .unwrap_or_default()
247 }
248
249 pub fn save_at(&self, path: &Path) -> Result<()> {
251 let bytes = serde_json::to_vec_pretty(self)?;
252 crate::cache::atomic_write(path, &bytes)
253 }
254}
255
256pub fn default_state_path() -> Result<PathBuf> {
259 Ok(crate::cache::xdg_cache_dir()?
260 .join("ai-usagebar")
261 .join("detect.json"))
262}
263
264#[derive(Debug, Default, Clone, PartialEq, Eq)]
266pub struct DetectPlan {
267 pub enable: Vec<VendorId>,
269 pub known: Vec<VendorId>,
271 pub probed: usize,
274}
275
276#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
279pub struct DetectReport {
280 pub enabled: Vec<VendorId>,
282 pub known: Vec<VendorId>,
284 pub probed: usize,
286}
287
288pub fn plan(
297 config: &Config,
298 state: &DetectState,
299 all: &[VendorId],
300 force: bool,
301 probe: impl Fn(VendorId) -> bool,
302) -> DetectPlan {
303 let candidates: Vec<VendorId> = all
304 .iter()
305 .copied()
306 .filter(|vendor| vendor.auto_detectable())
307 .filter(|vendor| force || !state.known.contains(vendor))
308 .collect();
309 let probed = candidates.len();
310 let enable = candidates
311 .into_iter()
312 .filter(|vendor| !config.is_enabled(*vendor))
313 .filter(|vendor| probe(*vendor))
314 .collect();
315 let known = VendorId::all()
316 .iter()
317 .copied()
318 .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
319 .collect();
320 DetectPlan {
321 enable,
322 known,
323 probed,
324 }
325}
326
327pub fn run_once(
336 config_path: Option<&Path>,
337 state_path: &Path,
338 force: bool,
339) -> Result<Vec<VendorId>> {
340 run_once_report(config_path, state_path, force).map(|report| report.enabled)
341}
342
343pub fn run_once_report(
346 config_path: Option<&Path>,
347 state_path: &Path,
348 force: bool,
349) -> Result<DetectReport> {
350 run_once_with(config_path, state_path, force, |vendor, config| {
351 catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
352 })
353}
354
355pub fn run_cli(all: bool, json: bool) -> i32 {
360 let report =
361 default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
362 match report {
363 Ok(report) if json => match serde_json::to_string(&report) {
364 Ok(text) => {
365 println!("{text}");
366 0
367 }
368 Err(error) => {
369 eprintln!("ai-usagebar detect: {error}");
370 1
371 }
372 },
373 Ok(report) => {
374 println!(
375 "{}",
376 format_report(&report, &crate::config::config_path_hint())
377 );
378 0
379 }
380 Err(error) => {
381 eprintln!("ai-usagebar detect: {}", error.user_message());
382 1
383 }
384 }
385}
386
387pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
390 if report.enabled.is_empty() {
391 let noun = if report.probed == 1 {
392 "vendor"
393 } else {
394 "vendors"
395 };
396 return format!("Nothing new detected ({} {noun} checked)", report.probed);
397 }
398 let names: Vec<&str> = report
399 .enabled
400 .iter()
401 .map(|vendor| vendor.display_name())
402 .collect();
403 format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
404}
405
406pub fn run_once_with(
410 config_path: Option<&Path>,
411 state_path: &Path,
412 force: bool,
413 probe: impl Fn(VendorId, &Config) -> bool,
414) -> Result<DetectReport> {
415 let resolved = match config_path {
416 Some(path) => Some(path.to_path_buf()),
417 None => crate::config::resolved_path(),
418 };
419 let config = match &resolved {
420 Some(path) => Config::load_from(path)?,
421 None => Config::default(),
422 };
423 let state = DetectState::load_at(state_path);
424 let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
425 probe(vendor, &config)
426 });
427 let enabled = if plan.enable.is_empty() {
431 Vec::new()
432 } else {
433 let path = resolved.ok_or_else(|| {
434 AppError::Other("could not resolve the config.toml path to enable vendors in".into())
435 })?;
436 crate::config::enable_vendors_in(&path, &plan.enable)?
437 };
438 DetectState {
439 known: plan.known.clone(),
440 }
441 .save_at(state_path)?;
442 Ok(DetectReport {
443 enabled,
444 known: plan.known,
445 probed: plan.probed,
446 })
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use tempfile::TempDir;
453
454 #[test]
455 fn lyceum_credential_detection_uses_inline_key_without_home_or_network() {
456 let mut config = Config::default();
457 config.lyceum.api_key_env.clear(); config.lyceum.api_key = Some("synthetic-key".into());
459 assert!(has_local_credentials(VendorId::Lyceum, &config));
460 config.lyceum.api_key = None;
461 assert!(!has_local_credentials(VendorId::Lyceum, &config));
462 }
463
464 fn detectable_count() -> usize {
466 VendorId::all()
467 .iter()
468 .filter(|vendor| vendor.auto_detectable())
469 .count()
470 }
471
472 fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
473 move |vendor| present.contains(&vendor)
474 }
475
476 #[test]
477 fn state_round_trips_through_json_by_slug() {
478 let dir = TempDir::new().unwrap();
479 let path = dir.path().join("nested").join("detect.json");
480 let state = DetectState {
481 known: vec![
482 VendorId::Cursor,
483 VendorId::OpenCodeGo,
484 VendorId::NousResearch,
485 ],
486 };
487
488 state.save_at(&path).unwrap();
489
490 let text = std::fs::read_to_string(&path).unwrap();
491 assert!(text.contains("\"opencode-go\""), "{text}");
492 assert!(text.contains("\"nous\""), "{text}");
493 assert_eq!(DetectState::load_at(&path), state);
494 }
495
496 #[test]
497 fn missing_or_corrupt_state_is_the_default() {
498 let dir = TempDir::new().unwrap();
499 assert_eq!(
500 DetectState::load_at(&dir.path().join("absent.json")),
501 DetectState::default()
502 );
503
504 let corrupt = dir.path().join("corrupt.json");
505 std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
506 assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
507
508 let unknown_slug = dir.path().join("unknown.json");
509 std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
510 assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
511 }
512
513 #[test]
514 fn plan_enables_only_unknown_probed_vendors_that_are_off() {
515 let config = Config::default(); let state = DetectState {
517 known: vec![VendorId::Grok],
518 };
519 let all = [
520 VendorId::Anthropic, VendorId::Grok, VendorId::Cursor, VendorId::Kiro, ];
525 let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
526
527 let plan = plan(&config, &state, &all, false, probe_in(&present));
528
529 assert_eq!(plan.enable, vec![VendorId::Cursor]);
530 }
531
532 #[test]
533 fn an_opt_in_vendor_is_neither_probed_nor_counted() {
534 let config = Config::default();
535 let state = DetectState::default();
536 let all = [VendorId::Cursor, VendorId::Devin, VendorId::Kiro];
537 for force in [false, true] {
538 let probed_vendors = std::cell::RefCell::new(Vec::new());
539 let plan = plan(&config, &state, &all, force, |vendor| {
540 probed_vendors.borrow_mut().push(vendor);
541 true
542 });
543 assert_eq!(plan.enable, vec![VendorId::Cursor, VendorId::Kiro]);
544 assert_eq!(plan.probed, 2, "force={force}");
545 assert!(!probed_vendors.borrow().contains(&VendorId::Devin));
546 assert!(plan.known.contains(&VendorId::Devin));
547 }
548 assert!(
549 VendorId::all()
550 .iter()
551 .all(|vendor| vendor.auto_detectable() == (*vendor != VendorId::Devin))
552 );
553 }
554
555 #[test]
556 fn local_devin_credentials_never_auto_enable_an_opt_in_vendor() {
557 let dir = TempDir::new().unwrap();
558 let config_path = dir.path().join("config.toml");
559 let state_path = dir.path().join("detect.json");
560 let credential_path = dir.path().join("credentials.toml");
561 std::fs::write(&credential_path, "present").unwrap();
562
563 for (contents, expected_enabled) in [
564 ("", false),
565 ("[devin]\nenabled = false\n", false),
566 ("[devin]\nenabled = true\n", true),
567 ] {
568 std::fs::write(&config_path, contents).unwrap();
569 for force in [false, true] {
570 let report = run_once_with(Some(&config_path), &state_path, force, |vendor, _| {
571 vendor == VendorId::Devin && credential_path.is_file()
572 })
573 .unwrap();
574
575 assert!(
576 report.enabled.is_empty(),
577 "{contents:?}, force={force}: {report:?}"
578 );
579 assert!(credential_path.is_file());
580 assert!(report.known.contains(&VendorId::Devin));
581 assert_eq!(
582 Config::load_from(&config_path).unwrap().devin.enabled,
583 expected_enabled
584 );
585 }
586 }
587 }
588
589 #[test]
590 fn force_reconsiders_known_vendors_but_never_enabled_ones() {
591 let config = Config::default();
592 let state = DetectState {
593 known: vec![VendorId::Grok, VendorId::Zai],
594 };
595 let all = [VendorId::Zai, VendorId::Grok];
596 let present = [VendorId::Zai, VendorId::Grok];
597
598 let plan = plan(&config, &state, &all, true, probe_in(&present));
599
600 assert_eq!(plan.enable, vec![VendorId::Grok]);
601 }
602
603 #[test]
604 fn plan_orders_enable_by_the_candidate_list() {
605 let config = Config::default();
606 let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
607 let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
608
609 let plan = plan(
610 &config,
611 &DetectState::default(),
612 &all,
613 false,
614 probe_in(&present),
615 );
616
617 assert_eq!(
618 plan.enable,
619 vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
620 );
621 }
622
623 #[test]
624 fn known_becomes_the_union_in_canonical_order_without_duplicates() {
625 let config = Config::default();
626 let state = DetectState {
627 known: vec![VendorId::Grok, VendorId::Cursor],
628 };
629 let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
630
631 let plan = plan(&config, &state, &all, false, |_| false);
632
633 assert_eq!(
634 plan.known,
635 vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
636 );
637 assert!(plan.enable.is_empty());
638 }
639
640 #[test]
641 fn probe_is_not_consulted_for_skipped_vendors() {
642 let config = Config::default();
643 let state = DetectState {
644 known: vec![VendorId::Grok],
645 };
646 let all = [VendorId::Grok, VendorId::Anthropic];
647
648 let plan = plan(&config, &state, &all, false, |vendor| {
649 panic!("probe called for {}", vendor.slug())
650 });
651
652 assert!(plan.enable.is_empty());
653 }
654
655 #[test]
656 fn a_named_api_key_account_alone_counts_as_a_credential() {
657 let mut config = Config::default();
659 config.deepseek.api_key_env.clear();
660 assert!(!key_present(&config, VendorId::Deepseek));
661
662 config.deepseek.accounts.push(crate::config::ApiKeyAccount {
663 label: "work".into(),
664 api_key_env: None,
665 api_key: Some("work-key".into()),
666 management_api_key_env: None,
667 });
668 assert!(key_present(&config, VendorId::Deepseek));
669 config.kilo.api_key_env.clear();
671 assert!(!key_present(&config, VendorId::Kilo));
672 }
673
674 #[test]
675 fn a_named_anthropic_account_alone_counts_as_a_credential() {
676 let dir = TempDir::new().unwrap();
677 let creds_file = dir.path().join("work.json");
678 std::fs::write(
679 &creds_file,
680 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":2000000000000}}"#,
681 )
682 .unwrap();
683
684 let mut config = Config::default();
685 config.anthropic.credentials_path = Some(dir.path().join("absent.json"));
686 assert!(!anthropic_present(&config));
687
688 config
689 .anthropic
690 .accounts
691 .push(crate::config::AnthropicAccount {
692 label: "work".into(),
693 credentials_path: creds_file,
694 });
695 assert!(anthropic_present(&config));
696 }
697
698 #[test]
699 fn a_named_openai_account_alone_counts_as_a_credential() {
700 let dir = TempDir::new().unwrap();
701 let auth_file = dir.path().join("work-auth.json");
702 std::fs::write(
703 &auth_file,
704 r#"{"tokens":{"access_token":"a","refresh_token":"r","id_token":"i"}}"#,
705 )
706 .unwrap();
707
708 let mut config = Config::default();
709 config.openai.codex_auth_path = Some(dir.path().join("absent.json"));
710 assert!(!has_local_credentials(VendorId::Openai, &config));
711
712 config.openai.accounts.push(crate::config::OpenAiAccount {
713 label: "work".into(),
714 codex_auth_path: auth_file,
715 });
716 assert!(has_local_credentials(VendorId::Openai, &config));
717 }
718
719 #[test]
720 fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
721 let dir = TempDir::new().unwrap();
722 let hosts = dir.path().join("hosts.yml");
723 assert!(!copilot_hosts_present_at(&hosts));
724
725 std::fs::write(&hosts, "").unwrap();
726 assert!(!copilot_hosts_present_at(&hosts));
727
728 std::fs::write(&hosts, "github.com:\n user: octocat\n").unwrap();
729 assert!(copilot_hosts_present_at(&hosts));
730
731 assert!(!copilot_hosts_present_at(dir.path()));
732 }
733
734 #[test]
735 fn devin_local_credential_probe_checks_only_the_configured_file() {
736 let dir = TempDir::new().unwrap();
737 let credential_path = dir.path().join("credentials.toml");
738 let mut config = Config::default();
739 config.devin.credentials_path = Some(credential_path.clone());
740 assert!(!has_local_credentials(VendorId::Devin, &config));
741
742 std::fs::write(&credential_path, "not parsed during detection").unwrap();
743 assert!(has_local_credentials(VendorId::Devin, &config));
744
745 let directory = dir.path().join("directory");
746 std::fs::create_dir(&directory).unwrap();
747 config.devin.credentials_path = Some(directory);
748 assert!(!has_local_credentials(VendorId::Devin, &config));
749 }
750
751 #[test]
755 fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
756 let dir = TempDir::new().unwrap();
757 let config_path = dir.path().join("config.toml");
758 let state_path = dir.path().join("detect.json");
759 std::fs::write(
760 &config_path,
761 "# mine
762[zai]
763enabled = false
764",
765 )
766 .unwrap();
767 let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
768 let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
769
770 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
771
772 assert_eq!(report.enabled, vec![VendorId::Cursor]);
776 assert_eq!(report.known, VendorId::all());
777 assert_eq!(report.probed, detectable_count());
778 let after = Config::load_from(&config_path).unwrap();
779 assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
780 assert!(after.is_enabled(VendorId::Cursor));
781 let text = std::fs::read_to_string(&config_path).unwrap();
782 assert!(
783 text.starts_with(
784 "# mine
785"
786 ),
787 "{text}"
788 );
789 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
790
791 let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
792 assert!(again.enabled.is_empty(), "{again:?}");
793 assert_eq!(again.probed, 0, "everything is known: nothing to check");
794
795 std::fs::write(
797 &config_path,
798 "[cursor]
799enabled = false
800",
801 )
802 .unwrap();
803 let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
804 assert!(third.enabled.is_empty(), "{third:?}");
805 assert!(
806 !Config::load_from(&config_path)
807 .unwrap()
808 .is_enabled(VendorId::Cursor)
809 );
810
811 let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
816 assert!(forced.enabled.is_empty(), "{forced:?}");
817 assert_eq!(forced.probed, detectable_count());
818 assert!(
819 !Config::load_from(&config_path)
820 .unwrap()
821 .is_enabled(VendorId::Cursor)
822 );
823 }
824
825 #[test]
826 fn plan_counts_candidates_not_enables() {
827 let config = Config::default();
828 let state = DetectState {
829 known: vec![VendorId::Grok],
830 };
831 let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
832
833 let unforced = plan(&config, &state, &all, false, |_| false);
834 assert_eq!(unforced.probed, 2, "Grok is known and skipped");
835
836 let forced = plan(&config, &state, &all, true, |_| false);
837 assert_eq!(forced.probed, 3);
838 }
839
840 #[test]
841 fn format_report_lists_display_names_and_where_they_were_written() {
842 let report = DetectReport {
843 enabled: vec![VendorId::Cursor, VendorId::Kiro],
844 known: VendorId::all().to_vec(),
845 probed: 3,
846 };
847
848 let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
849
850 assert_eq!(
851 text,
852 "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
853 );
854 }
855
856 #[test]
857 fn format_report_says_how_many_were_checked_when_nothing_changed() {
858 let none = DetectReport {
859 enabled: vec![],
860 known: VendorId::all().to_vec(),
861 probed: 3,
862 };
863 assert_eq!(
864 format_report(&none, "unused"),
865 "Nothing new detected (3 vendors checked)"
866 );
867
868 let one = DetectReport {
869 probed: 1,
870 ..none.clone()
871 };
872 assert_eq!(
873 format_report(&one, "unused"),
874 "Nothing new detected (1 vendor checked)"
875 );
876 }
877
878 #[test]
880 fn report_serializes_slugs_and_the_probed_count() {
881 let dir = TempDir::new().unwrap();
882 let config_path = dir.path().join("config.toml");
883 let state_path = dir.path().join("detect.json");
884 std::fs::write(&config_path, "").unwrap();
885 DetectState {
886 known: vec![VendorId::Anthropic, VendorId::Grok],
887 }
888 .save_at(&state_path)
889 .unwrap();
890 let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
891
892 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
893 let json: serde_json::Value = serde_json::to_value(&report).unwrap();
894
895 assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
896 assert_eq!(
897 json["probed"],
898 serde_json::json!(detectable_count() - 2),
899 "the two known vendors were not candidates"
900 );
901 let known = json["known"].as_array().unwrap();
902 assert_eq!(known.len(), VendorId::all().len());
903 assert_eq!(known[0], serde_json::json!("anthropic"));
904 assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
905 }
906
907 #[test]
908 fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
909 let dir = TempDir::new().unwrap();
910 let config_path = dir.path().join("config.toml");
911 let state_path = dir.path().join("detect.json");
912 let probe = |vendor: VendorId, _: &Config| {
913 catch_unwind(AssertUnwindSafe(|| {
914 if vendor == VendorId::Kiro {
915 panic!("boom");
916 }
917 vendor == VendorId::Grok
918 }))
919 .unwrap_or(false)
920 };
921
922 let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
923
924 assert_eq!(enabled.enabled, vec![VendorId::Grok]);
925 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
926 }
927}