Skip to main content

ai_usagebar/
detect.rs

1//! Local credential detection — the seed for auto-enabling vendors.
2//!
3//! A fresh install shows the four default vendors and nothing else, even when
4//! the machine already carries a Cursor login, a Kiro database, a `gh` OAuth
5//! session, or a `KILO_API_KEY`. This module answers "which vendors could
6//! fetch right now with what is already on disk?" cheaply enough to run at
7//! every frontend start, and turns the answer into a minimal edit of
8//! `config.toml`: `enabled = true` for the vendors that have credentials and
9//! are not enabled yet. It never writes `false` and never removes anything —
10//! the config stays the user's own.
11//!
12//! Two pieces, mirroring OpenUsage's `hasLocalCredentials` +
13//! FirstRunSeeder/NewProviderSeeder:
14//!
15//! - [`has_local_credentials`] is the per-vendor probe. Files, sqlite, saved
16//!   keys, env vars, local port discovery — **never the network**, never a
17//!   token refresh, never a cache directory or lock file. It reuses the same
18//!   resolvers `build_outcome` reads through, so "detected" means "the fetch
19//!   would at least find its credential".
20//! - [`DetectState`] remembers which vendors have already been considered, so
21//!   a vendor the user deliberately disabled after it was auto-enabled stays
22//!   disabled: it is only ever auto-enabled the *first* time it is seen. New
23//!   vendors added by an upgrade are not in `known` and get their one chance.
24//!   [`plan`] is the pure decision; [`run_once`] is the whole cycle.
25
26use 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
35/// Cheap, local-only probe: files, sqlite, saved keys, env vars. Never the
36/// network. `true` means the vendor's fetch would find *a* credential — not
37/// that the credential is still valid, which only the wire can tell.
38///
39/// Exhaustive over [`VendorId`] on purpose: a new vendor fails to compile
40/// until it says how it is detected.
41pub fn has_local_credentials(vendor: VendorId, config: &Config) -> bool {
42    match vendor {
43        VendorId::Anthropic => anthropic_present(config),
44        VendorId::AnthropicApi => key_present(config, vendor),
45        VendorId::Openai => config
46            .openai
47            .resolve_auth_path(None)
48            .is_ok_and(|path| crate::openai::creds::read_from(&path).is_ok()),
49        VendorId::Copilot => copilot_present(),
50        VendorId::Zai => key_present(config, vendor),
51        VendorId::Openrouter => key_present(config, vendor),
52        VendorId::Deepseek => key_present(config, vendor),
53        VendorId::Deepinfra => key_present(config, vendor),
54        VendorId::Kimi => crate::kimi::resolve_auth(&config.kimi).is_ok(),
55        VendorId::Kilo => key_present(config, vendor),
56        VendorId::Novita => key_present(config, vendor),
57        VendorId::Moonshot => key_present(config, vendor),
58        VendorId::Grok => key_present(config, vendor),
59        VendorId::Supergrok => crate::supergrok::scope::ScopePaths::with_overrides(
60            config.supergrok.auth_path.as_deref(),
61            config.supergrok.config_path.as_deref(),
62        )
63        .is_ok_and(|paths| crate::supergrok::direct::read_billing_key(&paths.auth).is_ok()),
64        // File-exists only: decrypting would mean a `secret-tool` / Keychain
65        // subprocess, and a probe that runs at every frontend start must not
66        // spawn one.
67        VendorId::Grokbot => crate::grokbot::secrets_path(&config.grokbot)
68            .map(|path| crate::grokbot::creds::secrets_present_at(&path))
69            .unwrap_or(false),
70        VendorId::Antigravity => antigravity_present(),
71        VendorId::Cursor => cursor_present(config),
72        VendorId::Minimax => key_present(config, vendor),
73        VendorId::Kiro => {
74            let path = match config.kiro.db_path.clone() {
75                Some(path) => path,
76                None => match crate::kiro::db::default_db_path() {
77                    Ok(path) => path,
78                    Err(_) => return false,
79                },
80            };
81            crate::kiro::db::read_credentials(&path).is_ok()
82        }
83        VendorId::NousResearch => {
84            // `read_unlocked`, not `read`: the latter takes the store lock,
85            // which creates the lock file and a 0700 config dir as a side
86            // effect. A probe must leave no trace.
87            let store = crate::nous::credentials::CredentialStore::at(
88                crate::nous::credentials::default_credentials_path(),
89            );
90            matches!(store.read_unlocked(), Ok(Some(_)))
91        }
92        VendorId::OpenCodeGo => key_present(config, vendor),
93        VendorId::CommandCode => {
94            crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref()).is_ok()
95        }
96        VendorId::Ollama => key_present(config, vendor),
97        VendorId::OrcaRouter => key_present(config, vendor),
98        // File-exists only, like Grok Bot: parsing the JSON here would be
99        // wasted work — the fetch reads the same file and reports honestly.
100        VendorId::ModelStudio => crate::modelstudio::config_path(&config.modelstudio)
101            .map(|path| crate::modelstudio::creds::config_present_at(&path))
102            .unwrap_or(false),
103    }
104}
105
106/// A key vendor: the configured env var (`api_key_env`, defaulting to
107/// `VendorId::api_key_env`) or the inline `api_key`, exactly as the fetch
108/// resolves them. `Config::api_key_env_for` / `inline_api_key` are the shared
109/// per-vendor lookup, so a new key vendor needs no arm of its own here. A
110/// named `[[<vendor>.accounts]]` key counts too: those accounts get tabs of
111/// their own, so a config with only named keys can still fetch.
112fn key_present(config: &Config, vendor: VendorId) -> bool {
113    crate::config::optional_api_key(
114        config.api_key_env_for(vendor),
115        config.inline_api_key(vendor),
116    )
117    .is_some()
118        || config
119            .api_key_accounts(vendor)
120            .unwrap_or(&[])
121            .iter()
122            .any(|account| {
123                crate::config::optional_api_key(
124                    account.api_key_env.as_deref().unwrap_or(""),
125                    account.api_key.as_deref(),
126                )
127                .is_some()
128            })
129}
130
131/// The default Claude account exactly as the fetch resolves it: an explicit
132/// `credentials_path` is a strict file read; the platform default adds the
133/// macOS Keychain fallback inside `creds::resolve` (gated there, not here).
134fn anthropic_present(config: &Config) -> bool {
135    use crate::anthropic::creds::{CredsTarget, default_path, resolve};
136    let target = match config.anthropic.credentials_path.clone() {
137        Some(path) => CredsTarget::Explicit(path),
138        None => match default_path() {
139            Ok(path) => CredsTarget::Default(path),
140            Err(_) => return false,
141        },
142    };
143    resolve(&target).is_ok()
144}
145
146/// GitHub Copilot detection is a **new** heuristic, deliberately different
147/// from the fetch: the fetch spawns `gh auth token` and lets the GitHub CLI
148/// decide, but a probe that runs at every frontend start must not fork a
149/// subprocess per vendor. So this asks the two questions `gh auth token`
150/// would answer from: an explicit `GITHUB_COPILOT_TOKEN`, or the `hosts.yml`
151/// that `gh auth login` has written, at the path `gh` itself would read
152/// (`copilot::credentials::default_hosts_path`). A present `hosts.yml` is
153/// treated as a login; if the session inside it has been revoked, the fetch
154/// reports that, as it would for any stale credential.
155fn copilot_present() -> bool {
156    if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
157        return true;
158    }
159    crate::copilot::credentials::default_hosts_path()
160        .is_ok_and(|path| copilot_hosts_present_at(&path))
161}
162
163/// A `hosts.yml` counts when it is a regular, non-empty file: `gh auth logout`
164/// of the last host leaves an empty document behind, which is not a login.
165pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
166    std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
167}
168
169/// Antigravity has no API key: it is "present" when a product is running and
170/// reachable, which is a local port question. The env override counts on its
171/// own, as it does for the fetch's candidate list. Cheapest check first.
172fn antigravity_present() -> bool {
173    if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
174        return true;
175    }
176    if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
177        return true;
178    }
179    // Antigravity also reports with every product closed, from the Google
180    // session it saved. Detecting only a *running* server would skip a
181    // provider that works — and because a vendor is looked at once, the miss
182    // would stick until `--all`. Our own cached token is the prompt-free
183    // signal that the remote path is live; the keyring is deliberately not
184    // consulted here.
185    crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
186        |cache| {
187            crate::antigravity::cloud::has_persisted_session(
188                &crate::antigravity::cloud::oauth_cache_path(&cache),
189            )
190        },
191    )
192}
193
194fn cursor_present(config: &Config) -> bool {
195    let db_path = match config.cursor.db_path.clone() {
196        Some(path) => path,
197        None => match crate::cursor::db::default_db_path() {
198            Ok(path) => path,
199            Err(_) => return false,
200        },
201    };
202    let agent_auth_path = match config.cursor.agent_auth_path.clone() {
203        Some(path) => path,
204        None => match crate::cursor::db::default_agent_auth_path() {
205            Ok(path) => path,
206            Err(_) => return false,
207        },
208    };
209    crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
210}
211
212/// Which vendors detection has already had its one look at. Persisted as
213/// JSON next to the vendor caches; a vendor in `known` is never auto-enabled
214/// again, so a user's later `enabled = false` sticks.
215#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct DetectState {
217    #[serde(default)]
218    pub known: Vec<VendorId>,
219}
220
221impl DetectState {
222    /// Missing or unreadable state means "nothing has been considered yet",
223    /// which only ever widens the config — the safe direction for a corrupt
224    /// sidecar. A slug the current build doesn't know parses as corrupt too.
225    pub fn load_at(path: &Path) -> DetectState {
226        std::fs::read(path)
227            .ok()
228            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
229            .unwrap_or_default()
230    }
231
232    /// Atomic write (tempfile + rename), creating the parent directory.
233    pub fn save_at(&self, path: &Path) -> Result<()> {
234        let bytes = serde_json::to_vec_pretty(self)?;
235        crate::cache::atomic_write(path, &bytes)
236    }
237}
238
239/// `<cache dir>/ai-usagebar/detect.json` — beside the per-vendor caches,
240/// because it is derived state that can be deleted to re-run detection.
241pub fn default_state_path() -> Result<PathBuf> {
242    Ok(crate::cache::xdg_cache_dir()?
243        .join("ai-usagebar")
244        .join("detect.json"))
245}
246
247/// What one detection pass decided.
248#[derive(Debug, Default, Clone, PartialEq, Eq)]
249pub struct DetectPlan {
250    /// Vendors to flip to `enabled = true`, in `all` order.
251    pub enable: Vec<VendorId>,
252    /// The next `DetectState::known`: the old set plus everything in `all`.
253    pub known: Vec<VendorId>,
254    /// How many candidates this pass considered: every vendor in `all` when
255    /// forced, otherwise only the ones not yet in `state.known`.
256    pub probed: usize,
257}
258
259/// What one full [`run_once_with`] cycle did — the CLI's report and the
260/// serialized shape of `detect --json` (vendors as slugs, no secrets).
261#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
262pub struct DetectReport {
263    /// Vendors flipped to `enabled = true` in the config this run.
264    pub enabled: Vec<VendorId>,
265    /// The persisted `DetectState::known` after this run.
266    pub known: Vec<VendorId>,
267    /// How many vendors were candidates this run (see [`DetectPlan::probed`]).
268    pub probed: usize,
269}
270
271/// The pure decision. Candidates are `all` minus `state.known`, or every
272/// vendor in `all` when `force`. A candidate is enabled when `probe` says it
273/// has credentials and the config doesn't already enable it. `known` becomes
274/// the union of the old set and `all`, in [`VendorId::all`] order, deduped —
275/// so a vendor is considered once per install, and once more per `force`.
276pub fn plan(
277    config: &Config,
278    state: &DetectState,
279    all: &[VendorId],
280    force: bool,
281    probe: impl Fn(VendorId) -> bool,
282) -> DetectPlan {
283    let candidates: Vec<VendorId> = all
284        .iter()
285        .copied()
286        .filter(|vendor| force || !state.known.contains(vendor))
287        .collect();
288    let probed = candidates.len();
289    let enable = candidates
290        .into_iter()
291        .filter(|vendor| !config.is_enabled(*vendor))
292        .filter(|vendor| probe(*vendor))
293        .collect();
294    let known = VendorId::all()
295        .iter()
296        .copied()
297        .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
298        .collect();
299    DetectPlan {
300        enable,
301        known,
302        probed,
303    }
304}
305
306/// One full detection cycle: load the config (`config_path`, or the resolved
307/// default), load the state at `state_path`, plan with
308/// [`has_local_credentials`], write the enables into the config, save the
309/// state, and return what was enabled.
310///
311/// Best-effort by design: every probe runs under `catch_unwind`, so a vendor
312/// resolver that panics on an unexpected local file counts as "not present"
313/// rather than taking the host process down.
314pub fn run_once(
315    config_path: Option<&Path>,
316    state_path: &Path,
317    force: bool,
318) -> Result<Vec<VendorId>> {
319    run_once_report(config_path, state_path, force).map(|report| report.enabled)
320}
321
322/// [`run_once`] keeping the whole [`DetectReport`] — what the `detect`
323/// subcommand prints. Same real probe, same `catch_unwind` guard.
324pub fn run_once_report(
325    config_path: Option<&Path>,
326    state_path: &Path,
327    force: bool,
328) -> Result<DetectReport> {
329    run_once_with(config_path, state_path, force, |vendor, config| {
330        catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
331    })
332}
333
334/// `ai-usagebar detect [--all] [--json]`: one-shot local credential detection
335/// as a command, so any frontend (or the user) can run it at startup.
336/// Uses the real config and state paths — tests go through
337/// [`run_once_with`] and [`format_report`] instead.
338pub fn run_cli(all: bool, json: bool) -> i32 {
339    let report =
340        default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
341    match report {
342        Ok(report) if json => match serde_json::to_string(&report) {
343            Ok(text) => {
344                println!("{text}");
345                0
346            }
347            Err(error) => {
348                eprintln!("ai-usagebar detect: {error}");
349                1
350            }
351        },
352        Ok(report) => {
353            println!(
354                "{}",
355                format_report(&report, &crate::config::config_path_hint())
356            );
357            0
358        }
359        Err(error) => {
360            eprintln!("ai-usagebar detect: {}", error.user_message());
361            1
362        }
363    }
364}
365
366/// Human-readable `detect` output. `config_hint` is where the enables were
367/// written, shown only when something was enabled.
368pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
369    if report.enabled.is_empty() {
370        let noun = if report.probed == 1 {
371            "vendor"
372        } else {
373            "vendors"
374        };
375        return format!("Nothing new detected ({} {noun} checked)", report.probed);
376    }
377    let names: Vec<&str> = report
378        .enabled
379        .iter()
380        .map(|vendor| vendor.display_name())
381        .collect();
382    format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
383}
384
385/// [`run_once_report`] with the probe injected — the test seam, so the
386/// cycle's config write and state bookkeeping can be exercised without a
387/// probe that reads this machine's real credential files.
388pub fn run_once_with(
389    config_path: Option<&Path>,
390    state_path: &Path,
391    force: bool,
392    probe: impl Fn(VendorId, &Config) -> bool,
393) -> Result<DetectReport> {
394    let resolved = match config_path {
395        Some(path) => Some(path.to_path_buf()),
396        None => crate::config::resolved_path(),
397    };
398    let config = match &resolved {
399        Some(path) => Config::load_from(path)?,
400        None => Config::default(),
401    };
402    let state = DetectState::load_at(state_path);
403    let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
404        probe(vendor, &config)
405    });
406    // What actually got written, which is not always what was planned: a
407    // vendor the user explicitly set to `enabled = false` is left alone by
408    // `enable_vendors_in`, so it must not be reported as enabled either.
409    let enabled = if plan.enable.is_empty() {
410        Vec::new()
411    } else {
412        let path = resolved.ok_or_else(|| {
413            AppError::Other("could not resolve the config.toml path to enable vendors in".into())
414        })?;
415        crate::config::enable_vendors_in(&path, &plan.enable)?
416    };
417    DetectState {
418        known: plan.known.clone(),
419    }
420    .save_at(state_path)?;
421    Ok(DetectReport {
422        enabled,
423        known: plan.known,
424        probed: plan.probed,
425    })
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use tempfile::TempDir;
432
433    fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
434        move |vendor| present.contains(&vendor)
435    }
436
437    #[test]
438    fn state_round_trips_through_json_by_slug() {
439        let dir = TempDir::new().unwrap();
440        let path = dir.path().join("nested").join("detect.json");
441        let state = DetectState {
442            known: vec![
443                VendorId::Cursor,
444                VendorId::OpenCodeGo,
445                VendorId::NousResearch,
446            ],
447        };
448
449        state.save_at(&path).unwrap();
450
451        let text = std::fs::read_to_string(&path).unwrap();
452        assert!(text.contains("\"opencode-go\""), "{text}");
453        assert!(text.contains("\"nous\""), "{text}");
454        assert_eq!(DetectState::load_at(&path), state);
455    }
456
457    #[test]
458    fn missing_or_corrupt_state_is_the_default() {
459        let dir = TempDir::new().unwrap();
460        assert_eq!(
461            DetectState::load_at(&dir.path().join("absent.json")),
462            DetectState::default()
463        );
464
465        let corrupt = dir.path().join("corrupt.json");
466        std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
467        assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
468
469        let unknown_slug = dir.path().join("unknown.json");
470        std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
471        assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
472    }
473
474    #[test]
475    fn plan_enables_only_unknown_probed_vendors_that_are_off() {
476        let config = Config::default(); // anthropic/openai/zai/openrouter on
477        let state = DetectState {
478            known: vec![VendorId::Grok],
479        };
480        let all = [
481            VendorId::Anthropic, // enabled already → never listed
482            VendorId::Grok,      // known → skipped
483            VendorId::Cursor,    // present, off, new → enabled
484            VendorId::Kiro,      // absent → not enabled
485        ];
486        let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
487
488        let plan = plan(&config, &state, &all, false, probe_in(&present));
489
490        assert_eq!(plan.enable, vec![VendorId::Cursor]);
491    }
492
493    #[test]
494    fn force_reconsiders_known_vendors_but_never_enabled_ones() {
495        let config = Config::default();
496        let state = DetectState {
497            known: vec![VendorId::Grok, VendorId::Zai],
498        };
499        let all = [VendorId::Zai, VendorId::Grok];
500        let present = [VendorId::Zai, VendorId::Grok];
501
502        let plan = plan(&config, &state, &all, true, probe_in(&present));
503
504        assert_eq!(plan.enable, vec![VendorId::Grok]);
505    }
506
507    #[test]
508    fn plan_orders_enable_by_the_candidate_list() {
509        let config = Config::default();
510        let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
511        let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
512
513        let plan = plan(
514            &config,
515            &DetectState::default(),
516            &all,
517            false,
518            probe_in(&present),
519        );
520
521        assert_eq!(
522            plan.enable,
523            vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
524        );
525    }
526
527    #[test]
528    fn known_becomes_the_union_in_canonical_order_without_duplicates() {
529        let config = Config::default();
530        let state = DetectState {
531            known: vec![VendorId::Grok, VendorId::Cursor],
532        };
533        let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
534
535        let plan = plan(&config, &state, &all, false, |_| false);
536
537        assert_eq!(
538            plan.known,
539            vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
540        );
541        assert!(plan.enable.is_empty());
542    }
543
544    #[test]
545    fn probe_is_not_consulted_for_skipped_vendors() {
546        let config = Config::default();
547        let state = DetectState {
548            known: vec![VendorId::Grok],
549        };
550        let all = [VendorId::Grok, VendorId::Anthropic];
551
552        let plan = plan(&config, &state, &all, false, |vendor| {
553            panic!("probe called for {}", vendor.slug())
554        });
555
556        assert!(plan.enable.is_empty());
557    }
558
559    #[test]
560    fn a_named_api_key_account_alone_counts_as_a_credential() {
561        // Empty `api_key_env`s keep the probe off the real environment.
562        let mut config = Config::default();
563        config.deepseek.api_key_env.clear();
564        assert!(!key_present(&config, VendorId::Deepseek));
565
566        config.deepseek.accounts.push(crate::config::ApiKeyAccount {
567            label: "work".into(),
568            api_key_env: None,
569            api_key: Some("work-key".into()),
570        });
571        assert!(key_present(&config, VendorId::Deepseek));
572        // Another vendor's array is not this vendor's credential.
573        config.kilo.api_key_env.clear();
574        assert!(!key_present(&config, VendorId::Kilo));
575    }
576
577    #[test]
578    fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
579        let dir = TempDir::new().unwrap();
580        let hosts = dir.path().join("hosts.yml");
581        assert!(!copilot_hosts_present_at(&hosts));
582
583        std::fs::write(&hosts, "").unwrap();
584        assert!(!copilot_hosts_present_at(&hosts));
585
586        std::fs::write(&hosts, "github.com:\n    user: octocat\n").unwrap();
587        assert!(copilot_hosts_present_at(&hosts));
588
589        assert!(!copilot_hosts_present_at(dir.path()));
590    }
591
592    /// The whole cycle against a temp config and state, with the probe faked:
593    /// enables land in the config with the user's text intact, the state
594    /// records every vendor, and a second run has nothing left to do.
595    #[test]
596    fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
597        let dir = TempDir::new().unwrap();
598        let config_path = dir.path().join("config.toml");
599        let state_path = dir.path().join("detect.json");
600        std::fs::write(
601            &config_path,
602            "# mine
603[zai]
604enabled = false
605",
606        )
607        .unwrap();
608        let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
609        let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
610
611        let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
612
613        // Z.AI is detectable and was probed, but the config says `enabled =
614        // false`. That is the user's answer and it outranks detection, so it is
615        // neither written nor reported as enabled.
616        assert_eq!(report.enabled, vec![VendorId::Cursor]);
617        assert_eq!(report.known, VendorId::all());
618        assert_eq!(report.probed, VendorId::all().len());
619        let after = Config::load_from(&config_path).unwrap();
620        assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
621        assert!(after.is_enabled(VendorId::Cursor));
622        let text = std::fs::read_to_string(&config_path).unwrap();
623        assert!(
624            text.starts_with(
625                "# mine
626"
627            ),
628            "{text}"
629        );
630        assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
631
632        let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
633        assert!(again.enabled.is_empty(), "{again:?}");
634        assert_eq!(again.probed, 0, "everything is known: nothing to check");
635
636        // A user who turns Cursor back off is not overridden: it is known.
637        std::fs::write(
638            &config_path,
639            "[cursor]
640enabled = false
641",
642        )
643        .unwrap();
644        let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
645        assert!(third.enabled.is_empty(), "{third:?}");
646        assert!(
647            !Config::load_from(&config_path)
648                .unwrap()
649                .is_enabled(VendorId::Cursor)
650        );
651
652        // `force` re-probes every vendor, but it still cannot overrule an
653        // explicit `enabled = false`. That makes `detect --all` safe to run at
654        // any time: it can add providers, never silently undo a decision. A
655        // user who wants Cursor back turns it on in Settings or in the file.
656        let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
657        assert!(forced.enabled.is_empty(), "{forced:?}");
658        assert_eq!(forced.probed, VendorId::all().len());
659        assert!(
660            !Config::load_from(&config_path)
661                .unwrap()
662                .is_enabled(VendorId::Cursor)
663        );
664    }
665
666    #[test]
667    fn plan_counts_candidates_not_enables() {
668        let config = Config::default();
669        let state = DetectState {
670            known: vec![VendorId::Grok],
671        };
672        let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
673
674        let unforced = plan(&config, &state, &all, false, |_| false);
675        assert_eq!(unforced.probed, 2, "Grok is known and skipped");
676
677        let forced = plan(&config, &state, &all, true, |_| false);
678        assert_eq!(forced.probed, 3);
679    }
680
681    #[test]
682    fn format_report_lists_display_names_and_where_they_were_written() {
683        let report = DetectReport {
684            enabled: vec![VendorId::Cursor, VendorId::Kiro],
685            known: VendorId::all().to_vec(),
686            probed: 3,
687        };
688
689        let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
690
691        assert_eq!(
692            text,
693            "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
694        );
695    }
696
697    #[test]
698    fn format_report_says_how_many_were_checked_when_nothing_changed() {
699        let none = DetectReport {
700            enabled: vec![],
701            known: VendorId::all().to_vec(),
702            probed: 3,
703        };
704        assert_eq!(
705            format_report(&none, "unused"),
706            "Nothing new detected (3 vendors checked)"
707        );
708
709        let one = DetectReport {
710            probed: 1,
711            ..none.clone()
712        };
713        assert_eq!(
714            format_report(&one, "unused"),
715            "Nothing new detected (1 vendor checked)"
716        );
717    }
718
719    /// The `--json` contract: slugs, three fields, no paths and no secrets.
720    #[test]
721    fn report_serializes_slugs_and_the_probed_count() {
722        let dir = TempDir::new().unwrap();
723        let config_path = dir.path().join("config.toml");
724        let state_path = dir.path().join("detect.json");
725        std::fs::write(&config_path, "").unwrap();
726        DetectState {
727            known: vec![VendorId::Anthropic, VendorId::Grok],
728        }
729        .save_at(&state_path)
730        .unwrap();
731        let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
732
733        let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
734        let json: serde_json::Value = serde_json::to_value(&report).unwrap();
735
736        assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
737        assert_eq!(
738            json["probed"],
739            serde_json::json!(VendorId::all().len() - 2),
740            "the two known vendors were not candidates"
741        );
742        let known = json["known"].as_array().unwrap();
743        assert_eq!(known.len(), VendorId::all().len());
744        assert_eq!(known[0], serde_json::json!("anthropic"));
745        assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
746    }
747
748    #[test]
749    fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
750        let dir = TempDir::new().unwrap();
751        let config_path = dir.path().join("config.toml");
752        let state_path = dir.path().join("detect.json");
753        let probe = |vendor: VendorId, _: &Config| {
754            catch_unwind(AssertUnwindSafe(|| {
755                if vendor == VendorId::Kiro {
756                    panic!("boom");
757                }
758                vendor == VendorId::Grok
759            }))
760            .unwrap_or(false)
761        };
762
763        let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
764
765        assert_eq!(enabled.enabled, vec![VendorId::Grok]);
766        assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
767    }
768}