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 => {
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::Moonshot => key_present(config, vendor),
65        VendorId::Grok => key_present(config, vendor),
66        VendorId::Supergrok => crate::supergrok::scope::ScopePaths::with_overrides(
67            config.supergrok.auth_path.as_deref(),
68            config.supergrok.config_path.as_deref(),
69        )
70        .is_ok_and(|paths| crate::supergrok::direct::read_billing_key(&paths.auth).is_ok()),
71        // File-exists only: decrypting would mean a `secret-tool` / Keychain
72        // subprocess, and a probe that runs at every frontend start must not
73        // spawn one.
74        VendorId::Grokbot => crate::grokbot::secrets_path(&config.grokbot)
75            .map(|path| crate::grokbot::creds::secrets_present_at(&path))
76            .unwrap_or(false),
77        VendorId::Antigravity => antigravity_present(),
78        VendorId::Cursor => cursor_present(config),
79        VendorId::Minimax => key_present(config, vendor),
80        VendorId::Kiro => {
81            let path = match config.kiro.db_path.clone() {
82                Some(path) => path,
83                None => match crate::kiro::db::default_db_path() {
84                    Ok(path) => path,
85                    Err(_) => return false,
86                },
87            };
88            crate::kiro::db::read_credentials(&path).is_ok()
89        }
90        VendorId::NousResearch => {
91            // `read_unlocked`, not `read`: the latter takes the store lock,
92            // which creates the lock file and a 0700 config dir as a side
93            // effect. A probe must leave no trace.
94            let store = crate::nous::credentials::CredentialStore::at(
95                crate::nous::credentials::default_credentials_path(),
96            );
97            matches!(store.read_unlocked(), Ok(Some(_)))
98        }
99        // An explicit key wins; otherwise the read-only credential the
100        // official OpenCode CLI already wrote (a signed-in user appears even
101        // before configuring anything).
102        VendorId::OpenCodeGo => {
103            key_present(config, vendor)
104                || crate::opencode_go::creds::probe(config.opencode_go.auth_paths.as_deref())
105        }
106        VendorId::CommandCode => {
107            crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref()).is_ok()
108        }
109        VendorId::Cline => crate::cline::creds::resolve(
110            config.cline.auth_paths.as_deref(),
111            &config.cline.api_key_env,
112            config.cline.api_key.as_deref(),
113        )
114        .is_ok(),
115        VendorId::Ollama => key_present(config, vendor),
116        VendorId::OrcaRouter => key_present(config, vendor),
117        VendorId::Lyceum => key_present(config, vendor),
118        // File-exists only, like Grok Bot: parsing the JSON here would be
119        // wasted work — the fetch reads the same file and reports honestly.
120        VendorId::ModelStudio => crate::modelstudio::config_path(&config.modelstudio)
121            .map(|path| crate::modelstudio::creds::config_present_at(&path))
122            .unwrap_or(false),
123        VendorId::MuseCode => crate::muse::creds::path(&config.muse)
124            .map(|path| path.is_file())
125            .unwrap_or(false),
126        VendorId::Devin => {
127            crate::devin::credentials_path(&config.devin)
128                .map(|path| path.is_file())
129                .unwrap_or(false)
130                || config
131                    .devin
132                    .accounts
133                    .iter()
134                    .any(|a| a.credentials_path.is_file())
135        }
136        // File-exists only: the keyring decrypt happens at fetch time, which
137        // reports the real failure honestly. Droid is opt-in, so this probe
138        // only feeds `detect` reporting, never auto-enable.
139        VendorId::Droid => crate::droid::keyring_path()
140            .map(|path| path.is_file())
141            .unwrap_or(false),
142    }
143}
144
145/// A key vendor: the configured env var (`api_key_env`, defaulting to
146/// `VendorId::api_key_env`) or the inline `api_key`, exactly as the fetch
147/// resolves them. `Config::api_key_env_for` / `inline_api_key` are the shared
148/// per-vendor lookup, so a new key vendor needs no arm of its own here. A
149/// named `[[<vendor>.accounts]]` key counts too: those accounts get tabs of
150/// their own, so a config with only named keys can still fetch.
151fn key_present(config: &Config, vendor: VendorId) -> bool {
152    crate::config::optional_api_key(
153        config.api_key_env_for(vendor),
154        config.inline_api_key(vendor),
155    )
156    .is_some()
157        || config
158            .api_key_accounts(vendor)
159            .unwrap_or(&[])
160            .iter()
161            .any(|account| {
162                crate::config::optional_api_key(
163                    account.api_key_env.as_deref().unwrap_or(""),
164                    account.api_key.as_deref(),
165                )
166                .is_some()
167            })
168}
169
170/// The default Claude account exactly as the fetch resolves it: an explicit
171/// `credentials_path` is a strict file read; the platform default adds the
172/// macOS Keychain fallback inside `creds::resolve` (gated there, not here).
173/// Any named account with a resolvable credential counts as present too.
174fn anthropic_present(config: &Config) -> bool {
175    use crate::anthropic::creds::{CredsTarget, default_path, resolve};
176    let target = match config.anthropic.credentials_path.clone() {
177        Some(path) => CredsTarget::Explicit(path),
178        None => match default_path() {
179            Ok(path) => CredsTarget::Default(path),
180            Err(_) => return false,
181        },
182    };
183    resolve(&target).is_ok()
184        || config.anthropic.all_accounts().iter().any(|account| {
185            resolve(&CredsTarget::Named {
186                path: account.credentials_path.clone(),
187                config_dir: account.config_dir(),
188            })
189            .is_ok()
190        })
191}
192
193/// GitHub Copilot detection is a **new** heuristic, deliberately different
194/// from the fetch: the fetch spawns `gh auth token` and lets the GitHub CLI
195/// decide, but a probe that runs at every frontend start must not fork a
196/// subprocess per vendor. So this asks the two questions `gh auth token`
197/// would answer from: an explicit `GITHUB_COPILOT_TOKEN`, or the `hosts.yml`
198/// that `gh auth login` has written, at the path `gh` itself would read
199/// (`copilot::credentials::default_hosts_path`). A present `hosts.yml` is
200/// treated as a login; if the session inside it has been revoked, the fetch
201/// reports that, as it would for any stale credential.
202fn copilot_present() -> bool {
203    if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
204        return true;
205    }
206    crate::copilot::credentials::default_hosts_path()
207        .is_ok_and(|path| copilot_hosts_present_at(&path))
208}
209
210/// A `hosts.yml` counts when it is a regular, non-empty file: `gh auth logout`
211/// of the last host leaves an empty document behind, which is not a login.
212pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
213    std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
214}
215
216/// Antigravity has no API key: it is "present" when a product is running and
217/// reachable, which is a local port question. The env override counts on its
218/// own, as it does for the fetch's candidate list. Cheapest check first.
219fn antigravity_present() -> bool {
220    if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
221        return true;
222    }
223    if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
224        return true;
225    }
226    // Antigravity also reports with every product closed, from the Google
227    // session it saved. Detecting only a *running* server would skip a
228    // provider that works — and because a vendor is looked at once, the miss
229    // would stick until `--all`. Our own cached token is the prompt-free
230    // signal that the remote path is live; the keyring is deliberately not
231    // consulted here.
232    crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
233        |cache| {
234            crate::antigravity::cloud::has_persisted_session(
235                &crate::antigravity::cloud::oauth_cache_path(&cache),
236            )
237        },
238    )
239}
240
241fn cursor_present(config: &Config) -> bool {
242    let db_path = match config.cursor.db_path.clone() {
243        Some(path) => path,
244        None => match crate::cursor::db::default_db_path() {
245            Ok(path) => path,
246            Err(_) => return false,
247        },
248    };
249    let agent_auth_path = match config.cursor.agent_auth_path.clone() {
250        Some(path) => path,
251        None => match crate::cursor::db::default_agent_auth_path() {
252            Ok(path) => path,
253            Err(_) => return false,
254        },
255    };
256    crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
257}
258
259/// Which vendors detection has already had its one look at. Persisted as
260/// JSON next to the vendor caches; a vendor in `known` is never auto-enabled
261/// again, so a user's later `enabled = false` sticks.
262#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct DetectState {
264    #[serde(default)]
265    pub known: Vec<VendorId>,
266}
267
268impl DetectState {
269    /// Missing or unreadable state means "nothing has been considered yet",
270    /// which only ever widens the config — the safe direction for a corrupt
271    /// sidecar. A slug the current build doesn't know parses as corrupt too.
272    pub fn load_at(path: &Path) -> DetectState {
273        std::fs::read(path)
274            .ok()
275            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
276            .unwrap_or_default()
277    }
278
279    /// Atomic write (tempfile + rename), creating the parent directory.
280    pub fn save_at(&self, path: &Path) -> Result<()> {
281        let bytes = serde_json::to_vec_pretty(self)?;
282        crate::cache::atomic_write(path, &bytes)
283    }
284}
285
286/// `<cache dir>/ai-usagebar/detect.json` — beside the per-vendor caches,
287/// because it is derived state that can be deleted to re-run detection.
288pub fn default_state_path() -> Result<PathBuf> {
289    Ok(crate::cache::xdg_cache_dir()?
290        .join("ai-usagebar")
291        .join("detect.json"))
292}
293
294/// What one detection pass decided.
295#[derive(Debug, Default, Clone, PartialEq, Eq)]
296pub struct DetectPlan {
297    /// Vendors to flip to `enabled = true`, in `all` order.
298    pub enable: Vec<VendorId>,
299    /// The next `DetectState::known`: the old set plus everything in `all`.
300    pub known: Vec<VendorId>,
301    /// How many candidates this pass considered: every vendor in `all` when
302    /// forced, otherwise only the ones not yet in `state.known`.
303    pub probed: usize,
304}
305
306/// What one full [`run_once_with`] cycle did — the CLI's report and the
307/// serialized shape of `detect --json` (vendors as slugs, no secrets).
308#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
309pub struct DetectReport {
310    /// Vendors flipped to `enabled = true` in the config this run.
311    pub enabled: Vec<VendorId>,
312    /// The persisted `DetectState::known` after this run.
313    pub known: Vec<VendorId>,
314    /// How many vendors were candidates this run (see [`DetectPlan::probed`]).
315    pub probed: usize,
316}
317
318/// The pure decision. Candidates are `all` minus `state.known`, or every
319/// vendor in `all` when `force`. A candidate is enabled when `probe` says it
320/// has credentials and the config doesn't already enable it. A vendor that is
321/// not [`VendorId::auto_detectable`] (Devin, Muse Code) is neither probed nor counted:
322/// its credentials are discoverable, but it is opt-in and detection, including
323/// `--all`, must not activate it. `known` becomes the union of the old set and
324/// `all`, in [`VendorId::all`] order, deduped — so a vendor is considered once
325/// per install, and once more per `force`.
326pub fn plan(
327    config: &Config,
328    state: &DetectState,
329    all: &[VendorId],
330    force: bool,
331    probe: impl Fn(VendorId) -> bool,
332) -> DetectPlan {
333    let candidates: Vec<VendorId> = all
334        .iter()
335        .copied()
336        .filter(|vendor| vendor.auto_detectable())
337        .filter(|vendor| force || !state.known.contains(vendor))
338        .collect();
339    let probed = candidates.len();
340    let enable = candidates
341        .into_iter()
342        .filter(|vendor| !config.is_enabled(*vendor))
343        .filter(|vendor| probe(*vendor))
344        .collect();
345    let known = VendorId::all()
346        .iter()
347        .copied()
348        .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
349        .collect();
350    DetectPlan {
351        enable,
352        known,
353        probed,
354    }
355}
356
357/// One full detection cycle: load the config (`config_path`, or the resolved
358/// default), load the state at `state_path`, plan with
359/// [`has_local_credentials`], write the enables into the config, save the
360/// state, and return what was enabled.
361///
362/// Best-effort by design: every probe runs under `catch_unwind`, so a vendor
363/// resolver that panics on an unexpected local file counts as "not present"
364/// rather than taking the host process down.
365pub fn run_once(
366    config_path: Option<&Path>,
367    state_path: &Path,
368    force: bool,
369) -> Result<Vec<VendorId>> {
370    run_once_report(config_path, state_path, force).map(|report| report.enabled)
371}
372
373/// [`run_once`] keeping the whole [`DetectReport`] — what the `detect`
374/// subcommand prints. Same real probe, same `catch_unwind` guard.
375pub fn run_once_report(
376    config_path: Option<&Path>,
377    state_path: &Path,
378    force: bool,
379) -> Result<DetectReport> {
380    run_once_with(config_path, state_path, force, |vendor, config| {
381        catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
382    })
383}
384
385/// `ai-usagebar detect [--all] [--json]`: one-shot local credential detection
386/// as a command, so any frontend (or the user) can run it at startup.
387/// Uses the real config and state paths — tests go through
388/// [`run_once_with`] and [`format_report`] instead.
389pub fn run_cli(all: bool, json: bool) -> i32 {
390    let report =
391        default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
392    match report {
393        Ok(report) if json => match serde_json::to_string(&report) {
394            Ok(text) => {
395                println!("{text}");
396                0
397            }
398            Err(error) => {
399                eprintln!("ai-usagebar detect: {error}");
400                1
401            }
402        },
403        Ok(report) => {
404            println!(
405                "{}",
406                format_report(&report, &crate::config::config_path_hint())
407            );
408            0
409        }
410        Err(error) => {
411            eprintln!("ai-usagebar detect: {}", error.user_message());
412            1
413        }
414    }
415}
416
417/// Human-readable `detect` output. `config_hint` is where the enables were
418/// written, shown only when something was enabled.
419pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
420    if report.enabled.is_empty() {
421        let noun = if report.probed == 1 {
422            "vendor"
423        } else {
424            "vendors"
425        };
426        return format!("Nothing new detected ({} {noun} checked)", report.probed);
427    }
428    let names: Vec<&str> = report
429        .enabled
430        .iter()
431        .map(|vendor| vendor.display_name())
432        .collect();
433    format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
434}
435
436/// [`run_once_report`] with the probe injected — the test seam, so the
437/// cycle's config write and state bookkeeping can be exercised without a
438/// probe that reads this machine's real credential files.
439pub fn run_once_with(
440    config_path: Option<&Path>,
441    state_path: &Path,
442    force: bool,
443    probe: impl Fn(VendorId, &Config) -> bool,
444) -> Result<DetectReport> {
445    let resolved = match config_path {
446        Some(path) => Some(path.to_path_buf()),
447        None => crate::config::resolved_path(),
448    };
449    let config = match &resolved {
450        Some(path) => Config::load_from(path)?,
451        None => Config::default(),
452    };
453    let state = DetectState::load_at(state_path);
454    let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
455        probe(vendor, &config)
456    });
457    // What actually got written, which is not always what was planned: a
458    // vendor the user explicitly set to `enabled = false` is left alone by
459    // `enable_vendors_in`, so it must not be reported as enabled either.
460    let enabled = if plan.enable.is_empty() {
461        Vec::new()
462    } else {
463        let path = resolved.ok_or_else(|| {
464            AppError::Other("could not resolve the config.toml path to enable vendors in".into())
465        })?;
466        crate::config::enable_vendors_in(&path, &plan.enable)?
467    };
468    DetectState {
469        known: plan.known.clone(),
470    }
471    .save_at(state_path)?;
472    Ok(DetectReport {
473        enabled,
474        known: plan.known,
475        probed: plan.probed,
476    })
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use tempfile::TempDir;
483
484    #[test]
485    fn lyceum_credential_detection_uses_inline_key_without_home_or_network() {
486        let mut config = Config::default();
487        config.lyceum.api_key_env.clear(); // prevents reading any ambient variable
488        config.lyceum.api_key = Some("synthetic-key".into());
489        assert!(has_local_credentials(VendorId::Lyceum, &config));
490        config.lyceum.api_key = None;
491        assert!(!has_local_credentials(VendorId::Lyceum, &config));
492    }
493
494    /// Vendors detection may probe: everything except opt-in-only providers.
495    fn detectable_count() -> usize {
496        VendorId::all()
497            .iter()
498            .filter(|vendor| vendor.auto_detectable())
499            .count()
500    }
501
502    fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
503        move |vendor| present.contains(&vendor)
504    }
505
506    #[test]
507    fn state_round_trips_through_json_by_slug() {
508        let dir = TempDir::new().unwrap();
509        let path = dir.path().join("nested").join("detect.json");
510        let state = DetectState {
511            known: vec![
512                VendorId::Cursor,
513                VendorId::OpenCodeGo,
514                VendorId::NousResearch,
515            ],
516        };
517
518        state.save_at(&path).unwrap();
519
520        let text = std::fs::read_to_string(&path).unwrap();
521        assert!(text.contains("\"opencode-go\""), "{text}");
522        assert!(text.contains("\"nous\""), "{text}");
523        assert_eq!(DetectState::load_at(&path), state);
524    }
525
526    #[test]
527    fn missing_or_corrupt_state_is_the_default() {
528        let dir = TempDir::new().unwrap();
529        assert_eq!(
530            DetectState::load_at(&dir.path().join("absent.json")),
531            DetectState::default()
532        );
533
534        let corrupt = dir.path().join("corrupt.json");
535        std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
536        assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
537
538        let unknown_slug = dir.path().join("unknown.json");
539        std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
540        assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
541    }
542
543    #[test]
544    fn plan_enables_only_unknown_probed_vendors_that_are_off() {
545        let config = Config::default(); // anthropic/openai/zai/openrouter on
546        let state = DetectState {
547            known: vec![VendorId::Grok],
548        };
549        let all = [
550            VendorId::Anthropic, // enabled already → never listed
551            VendorId::Grok,      // known → skipped
552            VendorId::Cursor,    // present, off, new → enabled
553            VendorId::Kiro,      // absent → not enabled
554        ];
555        let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
556
557        let plan = plan(&config, &state, &all, false, probe_in(&present));
558
559        assert_eq!(plan.enable, vec![VendorId::Cursor]);
560    }
561
562    #[test]
563    fn an_opt_in_vendor_is_neither_probed_nor_counted() {
564        let config = Config::default();
565        let state = DetectState::default();
566        let all = [VendorId::Cursor, VendorId::Devin, VendorId::Kiro];
567        for force in [false, true] {
568            let probed_vendors = std::cell::RefCell::new(Vec::new());
569            let plan = plan(&config, &state, &all, force, |vendor| {
570                probed_vendors.borrow_mut().push(vendor);
571                true
572            });
573            assert_eq!(plan.enable, vec![VendorId::Cursor, VendorId::Kiro]);
574            assert_eq!(plan.probed, 2, "force={force}");
575            assert!(!probed_vendors.borrow().contains(&VendorId::Devin));
576            assert!(plan.known.contains(&VendorId::Devin));
577        }
578        assert!(VendorId::all().iter().all(|vendor| vendor.auto_detectable()
579            != matches!(
580                vendor,
581                VendorId::Devin | VendorId::MuseCode | VendorId::Droid
582            )));
583    }
584
585    #[test]
586    fn local_devin_credentials_never_auto_enable_an_opt_in_vendor() {
587        let dir = TempDir::new().unwrap();
588        let config_path = dir.path().join("config.toml");
589        let state_path = dir.path().join("detect.json");
590        let credential_path = dir.path().join("credentials.toml");
591        std::fs::write(&credential_path, "present").unwrap();
592
593        for (contents, expected_enabled) in [
594            ("", false),
595            ("[devin]\nenabled = false\n", false),
596            ("[devin]\nenabled = true\n", true),
597        ] {
598            std::fs::write(&config_path, contents).unwrap();
599            for force in [false, true] {
600                let report = run_once_with(Some(&config_path), &state_path, force, |vendor, _| {
601                    vendor == VendorId::Devin && credential_path.is_file()
602                })
603                .unwrap();
604
605                assert!(
606                    report.enabled.is_empty(),
607                    "{contents:?}, force={force}: {report:?}"
608                );
609                assert!(credential_path.is_file());
610                assert!(report.known.contains(&VendorId::Devin));
611                assert_eq!(
612                    Config::load_from(&config_path).unwrap().devin.enabled,
613                    expected_enabled
614                );
615            }
616        }
617    }
618
619    #[test]
620    fn force_reconsiders_known_vendors_but_never_enabled_ones() {
621        let config = Config::default();
622        let state = DetectState {
623            known: vec![VendorId::Grok, VendorId::Zai],
624        };
625        let all = [VendorId::Zai, VendorId::Grok];
626        let present = [VendorId::Zai, VendorId::Grok];
627
628        let plan = plan(&config, &state, &all, true, probe_in(&present));
629
630        assert_eq!(plan.enable, vec![VendorId::Grok]);
631    }
632
633    #[test]
634    fn plan_orders_enable_by_the_candidate_list() {
635        let config = Config::default();
636        let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
637        let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
638
639        let plan = plan(
640            &config,
641            &DetectState::default(),
642            &all,
643            false,
644            probe_in(&present),
645        );
646
647        assert_eq!(
648            plan.enable,
649            vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
650        );
651    }
652
653    #[test]
654    fn known_becomes_the_union_in_canonical_order_without_duplicates() {
655        let config = Config::default();
656        let state = DetectState {
657            known: vec![VendorId::Grok, VendorId::Cursor],
658        };
659        let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
660
661        let plan = plan(&config, &state, &all, false, |_| false);
662
663        assert_eq!(
664            plan.known,
665            vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
666        );
667        assert!(plan.enable.is_empty());
668    }
669
670    #[test]
671    fn probe_is_not_consulted_for_skipped_vendors() {
672        let config = Config::default();
673        let state = DetectState {
674            known: vec![VendorId::Grok],
675        };
676        let all = [VendorId::Grok, VendorId::Anthropic];
677
678        let plan = plan(&config, &state, &all, false, |vendor| {
679            panic!("probe called for {}", vendor.slug())
680        });
681
682        assert!(plan.enable.is_empty());
683    }
684
685    #[test]
686    fn a_named_api_key_account_alone_counts_as_a_credential() {
687        // Empty `api_key_env`s keep the probe off the real environment.
688        let mut config = Config::default();
689        config.deepseek.api_key_env.clear();
690        assert!(!key_present(&config, VendorId::Deepseek));
691
692        config.deepseek.accounts.push(crate::config::ApiKeyAccount {
693            label: "work".into(),
694            api_key_env: None,
695            api_key: Some("work-key".into()),
696            management_api_key_env: None,
697        });
698        assert!(key_present(&config, VendorId::Deepseek));
699        // Another vendor's array is not this vendor's credential.
700        config.kilo.api_key_env.clear();
701        assert!(!key_present(&config, VendorId::Kilo));
702    }
703
704    #[test]
705    fn a_named_anthropic_account_alone_counts_as_a_credential() {
706        let dir = TempDir::new().unwrap();
707        let creds_file = dir.path().join("work.json");
708        std::fs::write(
709            &creds_file,
710            r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":2000000000000}}"#,
711        )
712        .unwrap();
713
714        let mut config = Config::default();
715        config.anthropic.credentials_path = Some(dir.path().join("absent.json"));
716        assert!(!anthropic_present(&config));
717
718        config
719            .anthropic
720            .accounts
721            .push(crate::config::AnthropicAccount {
722                label: "work".into(),
723                credentials_path: creds_file,
724            });
725        assert!(anthropic_present(&config));
726    }
727
728    #[test]
729    fn a_named_openai_account_alone_counts_as_a_credential() {
730        let dir = TempDir::new().unwrap();
731        let auth_file = dir.path().join("work-auth.json");
732        std::fs::write(
733            &auth_file,
734            r#"{"tokens":{"access_token":"a","refresh_token":"r","id_token":"i"}}"#,
735        )
736        .unwrap();
737
738        let mut config = Config::default();
739        config.openai.codex_auth_path = Some(dir.path().join("absent.json"));
740        assert!(!has_local_credentials(VendorId::Openai, &config));
741
742        config.openai.accounts.push(crate::config::OpenAiAccount {
743            label: "work".into(),
744            codex_auth_path: auth_file,
745        });
746        assert!(has_local_credentials(VendorId::Openai, &config));
747    }
748
749    #[test]
750    fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
751        let dir = TempDir::new().unwrap();
752        let hosts = dir.path().join("hosts.yml");
753        assert!(!copilot_hosts_present_at(&hosts));
754
755        std::fs::write(&hosts, "").unwrap();
756        assert!(!copilot_hosts_present_at(&hosts));
757
758        std::fs::write(&hosts, "github.com:\n    user: octocat\n").unwrap();
759        assert!(copilot_hosts_present_at(&hosts));
760
761        assert!(!copilot_hosts_present_at(dir.path()));
762    }
763
764    #[test]
765    fn devin_local_credential_probe_checks_only_the_configured_file() {
766        let dir = TempDir::new().unwrap();
767        let credential_path = dir.path().join("credentials.toml");
768        let mut config = Config::default();
769        config.devin.credentials_path = Some(credential_path.clone());
770        assert!(!has_local_credentials(VendorId::Devin, &config));
771
772        std::fs::write(&credential_path, "not parsed during detection").unwrap();
773        assert!(has_local_credentials(VendorId::Devin, &config));
774
775        let directory = dir.path().join("directory");
776        std::fs::create_dir(&directory).unwrap();
777        config.devin.credentials_path = Some(directory);
778        assert!(!has_local_credentials(VendorId::Devin, &config));
779    }
780
781    /// The whole cycle against a temp config and state, with the probe faked:
782    /// enables land in the config with the user's text intact, the state
783    /// records every vendor, and a second run has nothing left to do.
784    #[test]
785    fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
786        let dir = TempDir::new().unwrap();
787        let config_path = dir.path().join("config.toml");
788        let state_path = dir.path().join("detect.json");
789        std::fs::write(
790            &config_path,
791            "# mine
792[zai]
793enabled = false
794",
795        )
796        .unwrap();
797        let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
798        let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
799
800        let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
801
802        // Z.AI is detectable and was probed, but the config says `enabled =
803        // false`. That is the user's answer and it outranks detection, so it is
804        // neither written nor reported as enabled.
805        assert_eq!(report.enabled, vec![VendorId::Cursor]);
806        assert_eq!(report.known, VendorId::all());
807        assert_eq!(report.probed, detectable_count());
808        let after = Config::load_from(&config_path).unwrap();
809        assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
810        assert!(after.is_enabled(VendorId::Cursor));
811        let text = std::fs::read_to_string(&config_path).unwrap();
812        assert!(
813            text.starts_with(
814                "# mine
815"
816            ),
817            "{text}"
818        );
819        assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
820
821        let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
822        assert!(again.enabled.is_empty(), "{again:?}");
823        assert_eq!(again.probed, 0, "everything is known: nothing to check");
824
825        // A user who turns Cursor back off is not overridden: it is known.
826        std::fs::write(
827            &config_path,
828            "[cursor]
829enabled = false
830",
831        )
832        .unwrap();
833        let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
834        assert!(third.enabled.is_empty(), "{third:?}");
835        assert!(
836            !Config::load_from(&config_path)
837                .unwrap()
838                .is_enabled(VendorId::Cursor)
839        );
840
841        // `force` re-probes every vendor, but it still cannot overrule an
842        // explicit `enabled = false`. That makes `detect --all` safe to run at
843        // any time: it can add providers, never silently undo a decision. A
844        // user who wants Cursor back turns it on in Settings or in the file.
845        let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
846        assert!(forced.enabled.is_empty(), "{forced:?}");
847        assert_eq!(forced.probed, detectable_count());
848        assert!(
849            !Config::load_from(&config_path)
850                .unwrap()
851                .is_enabled(VendorId::Cursor)
852        );
853    }
854
855    #[test]
856    fn plan_counts_candidates_not_enables() {
857        let config = Config::default();
858        let state = DetectState {
859            known: vec![VendorId::Grok],
860        };
861        let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
862
863        let unforced = plan(&config, &state, &all, false, |_| false);
864        assert_eq!(unforced.probed, 2, "Grok is known and skipped");
865
866        let forced = plan(&config, &state, &all, true, |_| false);
867        assert_eq!(forced.probed, 3);
868    }
869
870    #[test]
871    fn format_report_lists_display_names_and_where_they_were_written() {
872        let report = DetectReport {
873            enabled: vec![VendorId::Cursor, VendorId::Kiro],
874            known: VendorId::all().to_vec(),
875            probed: 3,
876        };
877
878        let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
879
880        assert_eq!(
881            text,
882            "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
883        );
884    }
885
886    #[test]
887    fn format_report_says_how_many_were_checked_when_nothing_changed() {
888        let none = DetectReport {
889            enabled: vec![],
890            known: VendorId::all().to_vec(),
891            probed: 3,
892        };
893        assert_eq!(
894            format_report(&none, "unused"),
895            "Nothing new detected (3 vendors checked)"
896        );
897
898        let one = DetectReport {
899            probed: 1,
900            ..none.clone()
901        };
902        assert_eq!(
903            format_report(&one, "unused"),
904            "Nothing new detected (1 vendor checked)"
905        );
906    }
907
908    /// The `--json` contract: slugs, three fields, no paths and no secrets.
909    #[test]
910    fn report_serializes_slugs_and_the_probed_count() {
911        let dir = TempDir::new().unwrap();
912        let config_path = dir.path().join("config.toml");
913        let state_path = dir.path().join("detect.json");
914        std::fs::write(&config_path, "").unwrap();
915        DetectState {
916            known: vec![VendorId::Anthropic, VendorId::Grok],
917        }
918        .save_at(&state_path)
919        .unwrap();
920        let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
921
922        let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
923        let json: serde_json::Value = serde_json::to_value(&report).unwrap();
924
925        assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
926        assert_eq!(
927            json["probed"],
928            serde_json::json!(detectable_count() - 2),
929            "the two known vendors were not candidates"
930        );
931        let known = json["known"].as_array().unwrap();
932        assert_eq!(known.len(), VendorId::all().len());
933        assert_eq!(known[0], serde_json::json!("anthropic"));
934        assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
935    }
936
937    #[test]
938    fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
939        let dir = TempDir::new().unwrap();
940        let config_path = dir.path().join("config.toml");
941        let state_path = dir.path().join("detect.json");
942        let probe = |vendor: VendorId, _: &Config| {
943            catch_unwind(AssertUnwindSafe(|| {
944                if vendor == VendorId::Kiro {
945                    panic!("boom");
946                }
947                vendor == VendorId::Grok
948            }))
949            .unwrap_or(false)
950        };
951
952        let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
953
954        assert_eq!(enabled.enabled, vec![VendorId::Grok]);
955        assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
956    }
957}