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::Synthetic => key_present(config, vendor),
63 VendorId::DeepRelay => key_present(config, vendor),
64 VendorId::Mistral => key_present(config, vendor),
67 VendorId::Moonshot => key_present(config, vendor),
68 VendorId::Grok => key_present(config, vendor),
69 VendorId::Supergrok => crate::supergrok::scope::ScopePaths::with_overrides(
70 config.supergrok.auth_path.as_deref(),
71 config.supergrok.config_path.as_deref(),
72 )
73 .is_ok_and(|paths| crate::supergrok::direct::read_billing_key(&paths.auth).is_ok()),
74 VendorId::Grokbot => crate::grokbot::secrets_path(&config.grokbot)
78 .map(|path| crate::grokbot::creds::secrets_present_at(&path))
79 .unwrap_or(false),
80 VendorId::Antigravity => antigravity_present(),
81 VendorId::Cursor => cursor_present(config),
82 VendorId::Minimax => key_present(config, vendor),
83 VendorId::Kiro => {
84 let path = match config.kiro.db_path.clone() {
85 Some(path) => path,
86 None => match crate::kiro::db::default_db_path() {
87 Ok(path) => path,
88 Err(_) => return false,
89 },
90 };
91 crate::kiro::db::read_credentials(&path).is_ok()
92 }
93 VendorId::NousResearch => {
94 let store = crate::nous::credentials::CredentialStore::at(
98 crate::nous::credentials::default_credentials_path(),
99 );
100 matches!(store.read_unlocked(), Ok(Some(_)))
101 }
102 VendorId::OpenCodeGo => {
106 key_present(config, vendor)
107 || crate::opencode_go::creds::probe(config.opencode_go.auth_paths.as_deref())
108 }
109 VendorId::CommandCode => {
110 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref()).is_ok()
111 }
112 VendorId::Cline => crate::cline::creds::resolve(
113 config.cline.auth_paths.as_deref(),
114 &config.cline.api_key_env,
115 config.cline.api_key.as_deref(),
116 )
117 .is_ok(),
118 VendorId::Ollama => key_present(config, vendor),
119 VendorId::OrcaRouter => key_present(config, vendor),
120 VendorId::Lyceum => key_present(config, vendor),
121 VendorId::ModelStudio => crate::modelstudio::config_path(&config.modelstudio)
124 .map(|path| crate::modelstudio::creds::config_present_at(&path))
125 .unwrap_or(false),
126 VendorId::MuseCode => crate::muse::creds::path(&config.muse)
127 .map(|path| path.is_file())
128 .unwrap_or(false),
129 VendorId::Devin => {
130 crate::devin::credentials_path(&config.devin)
131 .map(|path| path.is_file())
132 .unwrap_or(false)
133 || config
134 .devin
135 .accounts
136 .iter()
137 .any(|a| a.credentials_path.is_file())
138 }
139 VendorId::Droid => crate::droid::keyring_path()
143 .map(|path| path.is_file())
144 .unwrap_or(false),
145 }
146}
147
148fn key_present(config: &Config, vendor: VendorId) -> bool {
155 crate::config::optional_api_key(
156 config.api_key_env_for(vendor),
157 config.inline_api_key(vendor),
158 )
159 .is_some()
160 || config
161 .api_key_accounts(vendor)
162 .unwrap_or(&[])
163 .iter()
164 .any(|account| {
165 crate::config::optional_api_key(
166 account.api_key_env.as_deref().unwrap_or(""),
167 account.api_key.as_deref(),
168 )
169 .is_some()
170 })
171}
172
173fn anthropic_present(config: &Config) -> bool {
178 use crate::anthropic::creds::{CredsTarget, default_path, resolve};
179 let target = match config.anthropic.credentials_path.clone() {
180 Some(path) => CredsTarget::Explicit(path),
181 None => match default_path() {
182 Ok(path) => CredsTarget::Default(path),
183 Err(_) => return false,
184 },
185 };
186 resolve(&target).is_ok()
187 || config.anthropic.all_accounts().iter().any(|account| {
188 resolve(&CredsTarget::Named {
189 path: account.credentials_path.clone(),
190 config_dir: account.config_dir(),
191 })
192 .is_ok()
193 })
194}
195
196fn copilot_present() -> bool {
206 if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
207 return true;
208 }
209 crate::copilot::credentials::default_hosts_path()
210 .is_ok_and(|path| copilot_hosts_present_at(&path))
211}
212
213pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
216 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
217}
218
219fn antigravity_present() -> bool {
223 if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
224 return true;
225 }
226 if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
227 return true;
228 }
229 crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
236 |cache| {
237 crate::antigravity::cloud::has_persisted_session(
238 &crate::antigravity::cloud::oauth_cache_path(&cache),
239 )
240 },
241 )
242}
243
244fn cursor_present(config: &Config) -> bool {
245 let db_path = match config.cursor.db_path.clone() {
246 Some(path) => path,
247 None => match crate::cursor::db::default_db_path() {
248 Ok(path) => path,
249 Err(_) => return false,
250 },
251 };
252 let agent_auth_path = match config.cursor.agent_auth_path.clone() {
253 Some(path) => path,
254 None => match crate::cursor::db::default_agent_auth_path() {
255 Ok(path) => path,
256 Err(_) => return false,
257 },
258 };
259 crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
260}
261
262#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct DetectState {
267 #[serde(default)]
268 pub known: Vec<VendorId>,
269}
270
271impl DetectState {
272 pub fn load_at(path: &Path) -> DetectState {
276 std::fs::read(path)
277 .ok()
278 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
279 .unwrap_or_default()
280 }
281
282 pub fn save_at(&self, path: &Path) -> Result<()> {
284 let bytes = serde_json::to_vec_pretty(self)?;
285 crate::cache::atomic_write(path, &bytes)
286 }
287}
288
289pub fn default_state_path() -> Result<PathBuf> {
292 Ok(crate::cache::xdg_cache_dir()?
293 .join("ai-usagebar")
294 .join("detect.json"))
295}
296
297#[derive(Debug, Default, Clone, PartialEq, Eq)]
299pub struct DetectPlan {
300 pub enable: Vec<VendorId>,
302 pub known: Vec<VendorId>,
304 pub probed: usize,
307}
308
309#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
312pub struct DetectReport {
313 pub enabled: Vec<VendorId>,
315 pub known: Vec<VendorId>,
317 pub probed: usize,
319}
320
321pub fn plan(
330 config: &Config,
331 state: &DetectState,
332 all: &[VendorId],
333 force: bool,
334 probe: impl Fn(VendorId) -> bool,
335) -> DetectPlan {
336 let candidates: Vec<VendorId> = all
337 .iter()
338 .copied()
339 .filter(|vendor| vendor.auto_detectable())
340 .filter(|vendor| force || !state.known.contains(vendor))
341 .collect();
342 let probed = candidates.len();
343 let enable = candidates
344 .into_iter()
345 .filter(|vendor| !config.is_enabled(*vendor))
346 .filter(|vendor| probe(*vendor))
347 .collect();
348 let known = VendorId::all()
349 .iter()
350 .copied()
351 .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
352 .collect();
353 DetectPlan {
354 enable,
355 known,
356 probed,
357 }
358}
359
360pub fn run_once(
369 config_path: Option<&Path>,
370 state_path: &Path,
371 force: bool,
372) -> Result<Vec<VendorId>> {
373 run_once_report(config_path, state_path, force).map(|report| report.enabled)
374}
375
376pub fn run_once_report(
379 config_path: Option<&Path>,
380 state_path: &Path,
381 force: bool,
382) -> Result<DetectReport> {
383 run_once_with(config_path, state_path, force, |vendor, config| {
384 catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
385 })
386}
387
388pub fn run_cli(all: bool, json: bool) -> i32 {
393 let report =
394 default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
395 match report {
396 Ok(report) if json => match serde_json::to_string(&report) {
397 Ok(text) => {
398 println!("{text}");
399 0
400 }
401 Err(error) => {
402 eprintln!("ai-usagebar detect: {error}");
403 1
404 }
405 },
406 Ok(report) => {
407 println!(
408 "{}",
409 format_report(&report, &crate::config::config_path_hint())
410 );
411 0
412 }
413 Err(error) => {
414 eprintln!("ai-usagebar detect: {}", error.user_message());
415 1
416 }
417 }
418}
419
420pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
423 if report.enabled.is_empty() {
424 let noun = if report.probed == 1 {
425 "vendor"
426 } else {
427 "vendors"
428 };
429 return format!("Nothing new detected ({} {noun} checked)", report.probed);
430 }
431 let names: Vec<&str> = report
432 .enabled
433 .iter()
434 .map(|vendor| vendor.display_name())
435 .collect();
436 format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
437}
438
439pub fn run_once_with(
443 config_path: Option<&Path>,
444 state_path: &Path,
445 force: bool,
446 probe: impl Fn(VendorId, &Config) -> bool,
447) -> Result<DetectReport> {
448 let resolved = match config_path {
449 Some(path) => Some(path.to_path_buf()),
450 None => crate::config::resolved_path(),
451 };
452 let config = match &resolved {
453 Some(path) => Config::load_from(path)?,
454 None => Config::default(),
455 };
456 let state = DetectState::load_at(state_path);
457 let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
458 probe(vendor, &config)
459 });
460 let enabled = if plan.enable.is_empty() {
464 Vec::new()
465 } else {
466 let path = resolved.ok_or_else(|| {
467 AppError::Other("could not resolve the config.toml path to enable vendors in".into())
468 })?;
469 crate::config::enable_vendors_in(&path, &plan.enable)?
470 };
471 DetectState {
472 known: plan.known.clone(),
473 }
474 .save_at(state_path)?;
475 Ok(DetectReport {
476 enabled,
477 known: plan.known,
478 probed: plan.probed,
479 })
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use tempfile::TempDir;
486
487 #[test]
488 fn lyceum_credential_detection_uses_inline_key_without_home_or_network() {
489 let mut config = Config::default();
490 config.lyceum.api_key_env.clear(); config.lyceum.api_key = Some("synthetic-key".into());
492 assert!(has_local_credentials(VendorId::Lyceum, &config));
493 config.lyceum.api_key = None;
494 assert!(!has_local_credentials(VendorId::Lyceum, &config));
495 }
496
497 #[test]
498 fn mistral_credential_detection_uses_only_an_explicit_key() {
499 let mut config = Config::default();
500 config.mistral.api_key_env.clear(); assert!(!has_local_credentials(VendorId::Mistral, &config));
502 config.mistral.api_key = Some("mst-test".into());
503 assert!(has_local_credentials(VendorId::Mistral, &config));
504 }
505
506 fn detectable_count() -> usize {
508 VendorId::all()
509 .iter()
510 .filter(|vendor| vendor.auto_detectable())
511 .count()
512 }
513
514 fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
515 move |vendor| present.contains(&vendor)
516 }
517
518 #[test]
519 fn state_round_trips_through_json_by_slug() {
520 let dir = TempDir::new().unwrap();
521 let path = dir.path().join("nested").join("detect.json");
522 let state = DetectState {
523 known: vec![
524 VendorId::Cursor,
525 VendorId::OpenCodeGo,
526 VendorId::NousResearch,
527 ],
528 };
529
530 state.save_at(&path).unwrap();
531
532 let text = std::fs::read_to_string(&path).unwrap();
533 assert!(text.contains("\"opencode-go\""), "{text}");
534 assert!(text.contains("\"nous\""), "{text}");
535 assert_eq!(DetectState::load_at(&path), state);
536 }
537
538 #[test]
539 fn missing_or_corrupt_state_is_the_default() {
540 let dir = TempDir::new().unwrap();
541 assert_eq!(
542 DetectState::load_at(&dir.path().join("absent.json")),
543 DetectState::default()
544 );
545
546 let corrupt = dir.path().join("corrupt.json");
547 std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
548 assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
549
550 let unknown_slug = dir.path().join("unknown.json");
551 std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
552 assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
553 }
554
555 #[test]
556 fn plan_enables_only_unknown_probed_vendors_that_are_off() {
557 let config = Config::default(); let state = DetectState {
559 known: vec![VendorId::Grok],
560 };
561 let all = [
562 VendorId::Anthropic, VendorId::Grok, VendorId::Cursor, VendorId::Kiro, ];
567 let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
568
569 let plan = plan(&config, &state, &all, false, probe_in(&present));
570
571 assert_eq!(plan.enable, vec![VendorId::Cursor]);
572 }
573
574 #[test]
575 fn an_opt_in_vendor_is_neither_probed_nor_counted() {
576 let config = Config::default();
577 let state = DetectState::default();
578 let all = [VendorId::Cursor, VendorId::Devin, VendorId::Kiro];
579 for force in [false, true] {
580 let probed_vendors = std::cell::RefCell::new(Vec::new());
581 let plan = plan(&config, &state, &all, force, |vendor| {
582 probed_vendors.borrow_mut().push(vendor);
583 true
584 });
585 assert_eq!(plan.enable, vec![VendorId::Cursor, VendorId::Kiro]);
586 assert_eq!(plan.probed, 2, "force={force}");
587 assert!(!probed_vendors.borrow().contains(&VendorId::Devin));
588 assert!(plan.known.contains(&VendorId::Devin));
589 }
590 assert!(VendorId::all().iter().all(|vendor| vendor.auto_detectable()
591 != matches!(
592 vendor,
593 VendorId::Devin | VendorId::MuseCode | VendorId::Droid | VendorId::Mistral
594 )));
595 }
596
597 #[test]
598 fn local_devin_credentials_never_auto_enable_an_opt_in_vendor() {
599 let dir = TempDir::new().unwrap();
600 let config_path = dir.path().join("config.toml");
601 let state_path = dir.path().join("detect.json");
602 let credential_path = dir.path().join("credentials.toml");
603 std::fs::write(&credential_path, "present").unwrap();
604
605 for (contents, expected_enabled) in [
606 ("", false),
607 ("[devin]\nenabled = false\n", false),
608 ("[devin]\nenabled = true\n", true),
609 ] {
610 std::fs::write(&config_path, contents).unwrap();
611 for force in [false, true] {
612 let report = run_once_with(Some(&config_path), &state_path, force, |vendor, _| {
613 vendor == VendorId::Devin && credential_path.is_file()
614 })
615 .unwrap();
616
617 assert!(
618 report.enabled.is_empty(),
619 "{contents:?}, force={force}: {report:?}"
620 );
621 assert!(credential_path.is_file());
622 assert!(report.known.contains(&VendorId::Devin));
623 assert_eq!(
624 Config::load_from(&config_path).unwrap().devin.enabled,
625 expected_enabled
626 );
627 }
628 }
629 }
630
631 #[test]
632 fn force_reconsiders_known_vendors_but_never_enabled_ones() {
633 let config = Config::default();
634 let state = DetectState {
635 known: vec![VendorId::Grok, VendorId::Zai],
636 };
637 let all = [VendorId::Zai, VendorId::Grok];
638 let present = [VendorId::Zai, VendorId::Grok];
639
640 let plan = plan(&config, &state, &all, true, probe_in(&present));
641
642 assert_eq!(plan.enable, vec![VendorId::Grok]);
643 }
644
645 #[test]
646 fn plan_orders_enable_by_the_candidate_list() {
647 let config = Config::default();
648 let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
649 let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
650
651 let plan = plan(
652 &config,
653 &DetectState::default(),
654 &all,
655 false,
656 probe_in(&present),
657 );
658
659 assert_eq!(
660 plan.enable,
661 vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
662 );
663 }
664
665 #[test]
666 fn known_becomes_the_union_in_canonical_order_without_duplicates() {
667 let config = Config::default();
668 let state = DetectState {
669 known: vec![VendorId::Grok, VendorId::Cursor],
670 };
671 let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
672
673 let plan = plan(&config, &state, &all, false, |_| false);
674
675 assert_eq!(
676 plan.known,
677 vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
678 );
679 assert!(plan.enable.is_empty());
680 }
681
682 #[test]
683 fn probe_is_not_consulted_for_skipped_vendors() {
684 let config = Config::default();
685 let state = DetectState {
686 known: vec![VendorId::Grok],
687 };
688 let all = [VendorId::Grok, VendorId::Anthropic];
689
690 let plan = plan(&config, &state, &all, false, |vendor| {
691 panic!("probe called for {}", vendor.slug())
692 });
693
694 assert!(plan.enable.is_empty());
695 }
696
697 #[test]
698 fn a_named_api_key_account_alone_counts_as_a_credential() {
699 let mut config = Config::default();
701 config.deepseek.api_key_env.clear();
702 assert!(!key_present(&config, VendorId::Deepseek));
703
704 config.deepseek.accounts.push(crate::config::ApiKeyAccount {
705 label: "work".into(),
706 api_key_env: None,
707 api_key: Some("work-key".into()),
708 management_api_key_env: None,
709 });
710 assert!(key_present(&config, VendorId::Deepseek));
711 config.kilo.api_key_env.clear();
713 assert!(!key_present(&config, VendorId::Kilo));
714 }
715
716 #[test]
717 fn a_named_anthropic_account_alone_counts_as_a_credential() {
718 let dir = TempDir::new().unwrap();
719 let creds_file = dir.path().join("work.json");
720 std::fs::write(
721 &creds_file,
722 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":2000000000000}}"#,
723 )
724 .unwrap();
725
726 let mut config = Config::default();
727 config.anthropic.credentials_path = Some(dir.path().join("absent.json"));
728 assert!(!anthropic_present(&config));
729
730 config
731 .anthropic
732 .accounts
733 .push(crate::config::AnthropicAccount {
734 label: "work".into(),
735 credentials_path: creds_file,
736 });
737 assert!(anthropic_present(&config));
738 }
739
740 #[test]
741 fn a_named_openai_account_alone_counts_as_a_credential() {
742 let dir = TempDir::new().unwrap();
743 let auth_file = dir.path().join("work-auth.json");
744 std::fs::write(
745 &auth_file,
746 r#"{"tokens":{"access_token":"a","refresh_token":"r","id_token":"i"}}"#,
747 )
748 .unwrap();
749
750 let mut config = Config::default();
751 config.openai.codex_auth_path = Some(dir.path().join("absent.json"));
752 assert!(!has_local_credentials(VendorId::Openai, &config));
753
754 config.openai.accounts.push(crate::config::OpenAiAccount {
755 label: "work".into(),
756 codex_auth_path: auth_file,
757 });
758 assert!(has_local_credentials(VendorId::Openai, &config));
759 }
760
761 #[test]
762 fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
763 let dir = TempDir::new().unwrap();
764 let hosts = dir.path().join("hosts.yml");
765 assert!(!copilot_hosts_present_at(&hosts));
766
767 std::fs::write(&hosts, "").unwrap();
768 assert!(!copilot_hosts_present_at(&hosts));
769
770 std::fs::write(&hosts, "github.com:\n user: octocat\n").unwrap();
771 assert!(copilot_hosts_present_at(&hosts));
772
773 assert!(!copilot_hosts_present_at(dir.path()));
774 }
775
776 #[test]
777 fn devin_local_credential_probe_checks_only_the_configured_file() {
778 let dir = TempDir::new().unwrap();
779 let credential_path = dir.path().join("credentials.toml");
780 let mut config = Config::default();
781 config.devin.credentials_path = Some(credential_path.clone());
782 assert!(!has_local_credentials(VendorId::Devin, &config));
783
784 std::fs::write(&credential_path, "not parsed during detection").unwrap();
785 assert!(has_local_credentials(VendorId::Devin, &config));
786
787 let directory = dir.path().join("directory");
788 std::fs::create_dir(&directory).unwrap();
789 config.devin.credentials_path = Some(directory);
790 assert!(!has_local_credentials(VendorId::Devin, &config));
791 }
792
793 #[test]
797 fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
798 let dir = TempDir::new().unwrap();
799 let config_path = dir.path().join("config.toml");
800 let state_path = dir.path().join("detect.json");
801 std::fs::write(
802 &config_path,
803 "# mine
804[zai]
805enabled = false
806",
807 )
808 .unwrap();
809 let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
810 let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
811
812 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
813
814 assert_eq!(report.enabled, vec![VendorId::Cursor]);
818 assert_eq!(report.known, VendorId::all());
819 assert_eq!(report.probed, detectable_count());
820 let after = Config::load_from(&config_path).unwrap();
821 assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
822 assert!(after.is_enabled(VendorId::Cursor));
823 let text = std::fs::read_to_string(&config_path).unwrap();
824 assert!(
825 text.starts_with(
826 "# mine
827"
828 ),
829 "{text}"
830 );
831 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
832
833 let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
834 assert!(again.enabled.is_empty(), "{again:?}");
835 assert_eq!(again.probed, 0, "everything is known: nothing to check");
836
837 std::fs::write(
839 &config_path,
840 "[cursor]
841enabled = false
842",
843 )
844 .unwrap();
845 let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
846 assert!(third.enabled.is_empty(), "{third:?}");
847 assert!(
848 !Config::load_from(&config_path)
849 .unwrap()
850 .is_enabled(VendorId::Cursor)
851 );
852
853 let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
858 assert!(forced.enabled.is_empty(), "{forced:?}");
859 assert_eq!(forced.probed, detectable_count());
860 assert!(
861 !Config::load_from(&config_path)
862 .unwrap()
863 .is_enabled(VendorId::Cursor)
864 );
865 }
866
867 #[test]
868 fn plan_counts_candidates_not_enables() {
869 let config = Config::default();
870 let state = DetectState {
871 known: vec![VendorId::Grok],
872 };
873 let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
874
875 let unforced = plan(&config, &state, &all, false, |_| false);
876 assert_eq!(unforced.probed, 2, "Grok is known and skipped");
877
878 let forced = plan(&config, &state, &all, true, |_| false);
879 assert_eq!(forced.probed, 3);
880 }
881
882 #[test]
883 fn format_report_lists_display_names_and_where_they_were_written() {
884 let report = DetectReport {
885 enabled: vec![VendorId::Cursor, VendorId::Kiro],
886 known: VendorId::all().to_vec(),
887 probed: 3,
888 };
889
890 let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
891
892 assert_eq!(
893 text,
894 "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
895 );
896 }
897
898 #[test]
899 fn format_report_says_how_many_were_checked_when_nothing_changed() {
900 let none = DetectReport {
901 enabled: vec![],
902 known: VendorId::all().to_vec(),
903 probed: 3,
904 };
905 assert_eq!(
906 format_report(&none, "unused"),
907 "Nothing new detected (3 vendors checked)"
908 );
909
910 let one = DetectReport {
911 probed: 1,
912 ..none.clone()
913 };
914 assert_eq!(
915 format_report(&one, "unused"),
916 "Nothing new detected (1 vendor checked)"
917 );
918 }
919
920 #[test]
922 fn report_serializes_slugs_and_the_probed_count() {
923 let dir = TempDir::new().unwrap();
924 let config_path = dir.path().join("config.toml");
925 let state_path = dir.path().join("detect.json");
926 std::fs::write(&config_path, "").unwrap();
927 DetectState {
928 known: vec![VendorId::Anthropic, VendorId::Grok],
929 }
930 .save_at(&state_path)
931 .unwrap();
932 let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
933
934 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
935 let json: serde_json::Value = serde_json::to_value(&report).unwrap();
936
937 assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
938 assert_eq!(
939 json["probed"],
940 serde_json::json!(detectable_count() - 2),
941 "the two known vendors were not candidates"
942 );
943 let known = json["known"].as_array().unwrap();
944 assert_eq!(known.len(), VendorId::all().len());
945 assert_eq!(known[0], serde_json::json!("anthropic"));
946 assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
947 }
948
949 #[test]
950 fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
951 let dir = TempDir::new().unwrap();
952 let config_path = dir.path().join("config.toml");
953 let state_path = dir.path().join("detect.json");
954 let probe = |vendor: VendorId, _: &Config| {
955 catch_unwind(AssertUnwindSafe(|| {
956 if vendor == VendorId::Kiro {
957 panic!("boom");
958 }
959 vendor == VendorId::Grok
960 }))
961 .unwrap_or(false)
962 };
963
964 let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
965
966 assert_eq!(enabled.enabled, vec![VendorId::Grok]);
967 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
968 }
969}