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