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