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