Skip to main content

atomcode_core/coding_plan/
setup.rs

1// crates/atomcode-core/src/coding_plan/setup.rs
2//
3// Orchestrator for the 4-step CodingPlan flow. Single `run` entrypoint
4// shared by the CLI subcommand and the TUI slash command; both render
5// the returned `SetupReport` their own way (stdout vs. body scrollback).
6//
7// Failure policy (matches product spec D5):
8//
9//   Step 1 Login  — if not logged in and OAuth fails → bail out (nothing
10//                   downstream works without a token).
11//   Step 2 Claim  — `duplicate=true` means "already claimed / in review"
12//                   — report it as a skip, NOT an error, and continue.
13//                   Transport/5xx errors → bail (server is in a bad state).
14//   Step 3 Models — empty list or request failure → bail. The whole point
15//                   of the flow is setting up providers; without models
16//                   we have nothing to install.
17//   Step 4 Status — warn-only. The plan is already set up; a failed
18//                   status fetch just means we can't show the quota
19//                   widget. User can retry with `/codingplan` later.
20//
21// Provider mutation (D2 + D4):
22//
23//   - All previously-created `AtomGit*` entries are wiped before inserts.
24//     Since CodingPlan is the authoritative source of truth for the
25//     model list, keeping stale names around would confuse `/model`.
26//   - Single model → one provider named `AtomGit`.
27//   - Multiple models → one provider per model, named
28//     `AtomGit-{display_model_name}` with `/` → `-` (keeps config.toml
29//     section names clean — `[providers.AtomGit-moonshotai-Kimi-K2]`).
30//   - `default_provider` is set to the first model in the API order.
31
32use anyhow::Result;
33use std::sync::Arc;
34
35use super::client::{is_auth_expired, Client};
36use super::types::{ModelEntry, PlanType, StatusResponse};
37use crate::auth;
38use crate::config::provider::ProviderConfig;
39use crate::config::Config;
40
41/// Default LLM gateway base URL for CodingPlan-managed providers when
42/// the `models-v2` payload doesn't carry a per-model `base_url`. Used
43/// only inside [`codingplan_llm_base_url`] — call that, not this.
44///
45/// The new signed gateway. `coding_plan::crypto::is_atomgit_gateway`
46/// **only** matches `llm-api.atomgit.com` (see the host whitelist at
47/// `crypto.rs:129`), so this is the URL where codingplan request
48/// signing actually engages. The previous default (the legacy
49/// `api-ai.gitcode.com` host) silently routed new installs to a
50/// plaintext path that bypassed signing — and surfaced in users'
51/// error logs as "my requests go to a URL I never configured."
52const DEFAULT_CODINGPLAN_LLM_BASE_URL: &str = "https://llm-api.atomgit.com/v1";
53
54/// Resolve the LLM gateway base URL for CodingPlan-managed providers.
55///
56/// Read order:
57///   1. `ATOMCODE_CODINGPLAN_LLM_BASE_URL` env var (trimmed, trailing
58///      `/` stripped, empty value treated as unset). Set this when
59///      pointing the client at a staging gateway.
60///   2. [`DEFAULT_CODINGPLAN_LLM_BASE_URL`].
61///
62/// Cached once at first call via `OnceLock` — same shape as
63/// [`auth::oauth::platform_base_url`] — so every provider registered
64/// by `step_models_and_register` lands on the same host even if the
65/// env var changes mid-flight, and the per-provider build cost is
66/// one atomic read after the first call.
67///
68/// Returns `String` rather than `&'static str` because the cached
69/// value's lifetime is tied to the `OnceLock`; callers that need an
70/// owned URL (e.g. `ProviderConfig::base_url: Option<String>`) get
71/// one without an extra clone.
72fn codingplan_llm_base_url() -> String {
73    use std::sync::OnceLock;
74    static URL: OnceLock<String> = OnceLock::new();
75    URL.get_or_init(|| {
76        std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
77            .ok()
78            .map(|v| v.trim().trim_end_matches('/').to_string())
79            .filter(|v| !v.is_empty())
80            .unwrap_or_else(|| DEFAULT_CODINGPLAN_LLM_BASE_URL.to_string())
81    })
82    .clone()
83}
84
85/// Provider type for the AtomGit LLM gateway (it's OpenAI-compatible).
86const PROVIDER_TYPE: &str = "openai";
87
88/// Context window for each coding-plan provider. The models endpoint
89/// doesn't currently return a per-model window, so we apply the same
90/// 64k value that the legacy `/login` flow hard-coded.
91const CONTEXT_WINDOW: usize = 64_000;
92
93/// Prefix used for every coding-plan-managed provider name.
94const PROVIDER_PREFIX: &str = "AtomGit";
95
96/// Result of one orchestrator step. Distinct from `Result` because
97/// "already done / idempotent skip" is a first-class outcome, not an
98/// error — the report needs to tell the user "you already claimed this
99/// last week" in the same place it'd tell them "just claimed".
100#[derive(Debug, Clone)]
101pub enum StepResult<T> {
102    /// Step ran and completed with the carried payload.
103    Ok(T),
104    /// Step was idempotent-skipped (already logged in, already claimed).
105    /// The string is a human-readable reason for display.
106    Skipped(String),
107    /// Step failed. The string is a human-readable error.
108    Err(String),
109}
110
111impl<T> StepResult<T> {
112    pub fn is_err(&self) -> bool {
113        matches!(self, StepResult::Err(_))
114    }
115    pub fn is_ok_or_skipped(&self) -> bool {
116        !self.is_err()
117    }
118}
119
120/// Describes how the auto-detected vision_preprocessor_provider was
121/// (or was not) updated by `step_models_and_register`. Surfaces in
122/// `SetupReport::render` so the user can see what happened to that
123/// config knob across the /codingplan flow.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum VisionPreprocessorOutcome {
126    /// Field was None and remains None (no VL/OCR in list).
127    UnchangedNone,
128    /// Field was a non-AtomGit user-supplied value; preserved.
129    /// Carries the value for display.
130    UserSupplied(String),
131    /// Field was None or a stale AtomGit-* key; auto-pointed at a
132    /// vision-capable provider in the freshly-installed list.
133    /// Carries the new key.
134    AutoSet(String),
135    /// Field was an AtomGit-* key but the new list has no VL/OCR
136    /// candidate, so the field was cleared to None to avoid pointing
137    /// at a wiped provider key.
138    Cleared,
139}
140
141impl SetupReport {
142    /// Render as a multi-line plain-text block for stdout / TUI body.
143    /// Shared by the CLI subcommand and the `/codingplan` slash command
144    /// so the visual contract stays consistent.
145    pub fn render(&self) -> String {
146        use crate::i18n::{t, Msg};
147
148        let mut out = String::new();
149        out.push_str(&t(Msg::CpSetupHeader));
150
151        // Step 1: login
152        match &self.login {
153            StepResult::Ok(info) => {
154                let who = info.display_name.as_deref().unwrap_or(&info.username);
155                let email = info.email.as_deref().unwrap_or("—");
156                out.push_str(&t(Msg::CpLoggedIn {
157                    who,
158                    username: &info.username,
159                    email,
160                }));
161            }
162            StepResult::Skipped(reason) => {
163                out.push_str(&t(Msg::CpStepSkipped { reason }));
164            }
165            StepResult::Err(msg) => {
166                out.push_str(&t(Msg::CpLoginFailed { error: msg }));
167            }
168        }
169
170        // Step 2: claim. When `claim_attempts` is populated (production
171        // path), emit one row per tier so refused / errored
172        // intermediates are visible alongside the winner. When empty
173        // (login-cascade suppression OR legacy test fixtures), fall
174        // back to a single summary row from `self.claim` — preserves
175        // pre-refactor test semantics without churning every fixture.
176        if !self.claim_attempts.is_empty() {
177            for attempt in &self.claim_attempts {
178                let tier = attempt.tier.as_str();
179                match &attempt.outcome {
180                    TierOutcome::Claimed { .. } => {
181                        out.push_str(&t(Msg::CpClaimTierSucceeded { tier }));
182                    }
183                    TierOutcome::AlreadyHeld { .. } => {
184                        out.push_str(&t(Msg::CpClaimTierAlreadyHeld { tier }));
185                    }
186                    TierOutcome::Refused { message } => {
187                        let reason = if message.is_empty() {
188                            // Server returned success=false +
189                            // duplicate=false with no message — surface
190                            // a placeholder so the row isn't a
191                            // confusing "claim failed — " with nothing
192                            // after the em-dash.
193                            "(no reason given)"
194                        } else {
195                            message.as_str()
196                        };
197                        out.push_str(&t(Msg::CpClaimTierFailed { tier, reason }));
198                    }
199                    TierOutcome::Errored { error } => {
200                        // 5xx / transport / parse — same "claim
201                        // failed" glyph as Refused; the message is
202                        // the error text (truncated so a stack
203                        // trace doesn't blow up the row).
204                        let reason = truncate_inline(error, 150);
205                        out.push_str(&t(Msg::CpClaimTierFailed {
206                            tier,
207                            reason: &reason,
208                        }));
209                    }
210                }
211            }
212        } else {
213            match &self.claim {
214                StepResult::Ok(info) => {
215                    let fallback = t(Msg::CpClaimSuccessFallback);
216                    let message = if info.message.is_empty() {
217                        fallback.as_ref()
218                    } else {
219                        info.message.as_str()
220                    };
221                    out.push_str(&t(Msg::CpClaimed {
222                        message,
223                        plan_type: info.plan_type.as_str(),
224                    }));
225                }
226                StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
227                    // Cascade from login failure — suppressed.
228                }
229                StepResult::Skipped(reason) => {
230                    out.push_str(&t(Msg::CpAlreadyClaimed { reason }));
231                }
232                StepResult::Err(msg) => {
233                    out.push_str(&t(Msg::CpClaimFailed { error: msg }));
234                }
235            }
236        }
237
238        // Step 3: models. When the cascade marker is present (claim
239        // failed upstream), skip the row entirely — printing
240        // "Models step skipped — claim failed" right after the claim
241        // failure line is just noise. Same for the status row below.
242        match &self.models {
243            StepResult::Ok(info) => {
244                let count = info.provider_names.len();
245                let plural_s = if count == 1 { "" } else { "s" };
246                out.push_str(&t(Msg::CpAddedProviders { count, plural_s }));
247                // Build a quick lookup of which display names made it
248                // into the registered provider list — anything in
249                // `all_models` but NOT in this set is locked behind
250                // the user's plan tier.
251                let registered: std::collections::HashSet<&str> =
252                    info.display_names.iter().map(|s| s.as_str()).collect();
253                // Locked models render FIRST so the upgrade prompt is the
254                // first thing the eye lands on under "Added N providers:".
255                // Visual cue is an `✗` prefix matching the existing
256                // failure rows (`✗ CodingPlan Max claim failed — …`)
257                // plus the explicit `(requires Pro plan or higher)` suffix —
258                // both plain text, so every renderer (alt-screen /
259                // retained / plain) and every terminal font carries
260                // the meaning. An earlier U+0336 combining strikethrough
261                // pass was dropped after a user report that fonts in
262                // the wild silently skip the overlay glyph; the SGR 9
263                // approach before that was eaten by the TUI's CSI
264                // sanitizer (`tuix::sanitize::scrub_controls`). The
265                // prefix-plus-suffix combo doesn't depend on either.
266                let locked: Vec<&ModelEntry> = info
267                    .all_models
268                    .iter()
269                    .filter(|m| !m.plan_available && !registered.contains(m.display_model_name.as_str()))
270                    .collect();
271                for m in &locked {
272                    out.push_str(&t(Msg::CpLocked {
273                        name: &m.display_model_name,
274                    }));
275                }
276                let default_suffix_cow = t(Msg::CpDefaultSuffix);
277                for (pname, model) in info.provider_names.iter().zip(info.display_names.iter()) {
278                    let suffix = if pname == &info.default_provider {
279                        default_suffix_cow.as_ref()
280                    } else {
281                        ""
282                    };
283                    out.push_str(&t(Msg::CpProviderRow {
284                        provider: pname,
285                        model,
286                        default_suffix: suffix,
287                    }));
288                }
289                // Vision-preprocessor outcome line.
290                match &info.vision_preprocessor {
291                    VisionPreprocessorOutcome::AutoSet(k) => {
292                        out.push_str(&t(Msg::CpVisionAuto { kind: k }));
293                    }
294                    VisionPreprocessorOutcome::UserSupplied(k) => {
295                        out.push_str(&t(Msg::CpVisionUserSupplied { kind: k }));
296                    }
297                    VisionPreprocessorOutcome::Cleared => {
298                        out.push_str(&t(Msg::CpVisionCleared));
299                    }
300                    VisionPreprocessorOutcome::UnchangedNone => {
301                        // No-op: nothing to say when both the previous and
302                        // new state are "no preprocessor configured".
303                    }
304                }
305            }
306            StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
307                // Suppress — claim failure line above is the explanation.
308            }
309            StepResult::Skipped(reason) => {
310                out.push_str(&t(Msg::CpModelsSkipped { reason }));
311            }
312            StepResult::Err(msg) => {
313                out.push_str(&t(Msg::CpModelsFailed { error: msg }));
314            }
315        }
316
317        // Step 4: status
318        match &self.status {
319            StepResult::Ok(s) => {
320                out.push_str(&t(Msg::CpStatusHeader));
321                if let Some(plan) = &s.codingplan_free {
322                    if plan.expires_at.is_empty() {
323                        // Backend sends null claimed_at/expires_at while a
324                        // fresh claim is still propagating. Don't render an
325                        // empty date with `(0d / 0d remaining)` zeros — say
326                        // "pending activation" so the user knows to wait.
327                        out.push_str(&t(Msg::CpPlanPending { plan: &plan.plan_name }));
328                    } else {
329                        out.push_str(&t(Msg::CpPlanActive {
330                            plan: &plan.plan_name,
331                            expires_at: &plan.expires_at,
332                            remaining_days: plan.remaining_days,
333                            total_days: plan.total_days,
334                        }));
335                    }
336                }
337                if let Some(u) = &s.current_usage {
338                    out.push_str(&t(Msg::CpUsageLine {
339                        usage: &u.display_desc(),
340                        reset_at: &u.reset_at_display,
341                        duration: &format_duration_secs(u.seconds_until_reset),
342                    }));
343                }
344                if s.window_quota_exhausted {
345                    if let Some(hint) = &s.window_quota_hint {
346                        out.push_str(&t(Msg::CpWindowQuotaHint { hint }));
347                    } else {
348                        out.push_str(&t(Msg::CpWindowQuotaExhausted));
349                    }
350                }
351            }
352            StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
353                // Suppress — cascade from claim failure.
354            }
355            StepResult::Skipped(reason) => {
356                out.push_str(&t(Msg::CpStatusFetchSkipped { reason }));
357            }
358            StepResult::Err(msg) => {
359                // Truncate the error chain so a server-side parse failure
360                // doesn't dump the entire response body inline. The cause
361                // chain commonly includes the raw JSON via anyhow's
362                // `with_context(format!("(body: {})", body))`, easily
363                // 200+ chars; the diagnostic value beyond ~150 is low.
364                out.push_str(&t(Msg::CpStatusFetchFailed {
365                    error: &truncate_inline(msg, 150),
366                }));
367            }
368        }
369
370        out
371    }
372
373    /// True iff every persist-relevant step (login + claim + models)
374    /// either succeeded outright or was skipped non-fatally (server
375    /// reported `duplicate=true`, model list already current, etc.).
376    /// Callers use this to decide whether to persist config changes
377    /// to disk.
378    ///
379    /// `claim` MUST be in the predicate: when claim returns `Err`
380    /// (e.g. backend 500 like the AtomGit `claim-v2` transaction-
381    /// rollback bug), `run()` short-circuits and parks `models` as
382    /// `Skipped(CASCADE_FROM_UPSTREAM_FAIL)` so the report stays
383    /// focused on the actual failure. Without the claim check the
384    /// gate flipped to `true` on every claim-failure path —
385    /// triggering `save_and_reload` to rewrite `config.toml`
386    /// unconditionally. That clobbered any manual edits the user
387    /// made between TUI startup and `/codingplan`, and read as
388    /// "claim failed but it still wrote models to my config".
389    pub fn should_persist_config(&self) -> bool {
390        self.login.is_ok_or_skipped()
391            && self.claim.is_ok_or_skipped()
392            && self.models.is_ok_or_skipped()
393    }
394}
395
396/// Display-friendly summary of each step's outcome. Returned by `run`
397/// so the caller can render however it wants (plain stdout, TUI body
398/// scrollback, future JSON output for scripting).
399#[derive(Debug, Clone)]
400pub struct SetupReport {
401    pub login: StepResult<LoginInfo>,
402    pub claim: StepResult<ClaimInfo>,
403    /// Per-tier cascade history. Populated by `step_claim` with one
404    /// entry per tier actually attempted (in cascade order Max → Pro
405    /// → Lite). Empty when the cascade never ran (e.g. login failed
406    /// upstream — claim is `Skipped(CASCADE_FROM_UPSTREAM_FAIL)`) or
407    /// when a legacy test fixture wants the old single-row claim
408    /// summary. `render` walks this to emit one row per tier so
409    /// refused / errored intermediate tiers are visible, not hidden
410    /// behind a single "claim failed" summary.
411    pub claim_attempts: Vec<TierAttempt>,
412    pub models: StepResult<ModelsInfo>,
413    pub status: StepResult<StatusResponse>,
414    /// True when any API call rejected the stored bearer token
415    /// (401/403). `is_logged_in()` only checks "does auth.toml exist"
416    /// and `get_valid_token` only refreshes when the recorded
417    /// `expires_in` says so — neither catches a server-side revocation
418    /// or a refresh-token that the broker no longer accepts. Shells
419    /// (TUI `/codingplan`, CLI `atomcode codingplan`) read this flag
420    /// to drive an inline re-OAuth + retry instead of leaving the user
421    /// staring at a "claim failed — run `atomcode login` again" line
422    /// when `/login` would have fixed it in one step.
423    pub auth_expired: bool,
424}
425
426#[derive(Debug, Clone)]
427pub struct LoginInfo {
428    pub username: String,
429    pub display_name: Option<String>,
430    pub email: Option<String>,
431}
432
433#[derive(Debug, Clone)]
434pub struct ClaimInfo {
435    pub message: String,
436    /// true when server reported `duplicate=true` — surfaces in the
437    /// rendered report as "(already claimed)" rather than "(just claimed)".
438    pub duplicate: bool,
439    /// The CodingPlan tier the cascade landed on. `Max` if the
440    /// highest-tier claim succeeded, `Pro` / `Lite` for fallbacks.
441    /// Threaded into `step_models_and_register` as the `?plan_type=`
442    /// argument so the model list comes back with availability gated
443    /// to the user's actual entitlement.
444    pub plan_type: PlanType,
445}
446
447/// Per-tier outcome captured while `step_claim` walks the cascade.
448/// Surfaces in `SetupReport::render` as one row per attempted tier so
449/// the user can see exactly why the cascade stopped where it did — a
450/// single "claim failed: Lite: 暂无开放" line hid the Max / Pro tier
451/// rejections users wanted to see.
452#[derive(Debug, Clone)]
453pub enum TierOutcome {
454    /// `success=true` on this tier — cascade winner.
455    Claimed { message: String },
456    /// `duplicate=true` — user already held this (or a higher) tier;
457    /// cascade treats this as winner and stops.
458    AlreadyHeld { message: String },
459    /// `2xx success=false duplicate=false` — per-tier refusal (e.g.
460    /// `额度已满` / `暂无开放`). Cascade walks past to the next tier.
461    Refused { message: String },
462    /// Transport / 5xx / parse failure. Cascade aborts.
463    Errored { error: String },
464}
465
466#[derive(Debug, Clone)]
467pub struct TierAttempt {
468    pub tier: PlanType,
469    pub outcome: TierOutcome,
470}
471
472#[derive(Debug, Clone)]
473pub struct ModelsInfo {
474    /// Model names of the **available** subset, in server order.
475    /// Parallel to `provider_names` — these are the entries that
476    /// actually got registered as providers.
477    pub display_names: Vec<String>,
478    /// Provider keys actually inserted into Config (available only).
479    pub provider_names: Vec<String>,
480    /// Which of `provider_names` was set as `default_provider`.
481    pub default_provider: String,
482    /// Outcome of vision_preprocessor_provider auto-config. Drives the
483    /// "Vision preprocessor → ..." line in the rendered report.
484    pub vision_preprocessor: VisionPreprocessorOutcome,
485    /// Full v2 model list — including `plan_available=false` entries
486    /// that we didn't register as providers. Renderer iterates this
487    /// to show locked models with strikethrough so users see what
488    /// upgrading the plan would unlock.
489    pub all_models: Vec<ModelEntry>,
490}
491
492/// Entry point. Mutates `config` in place (providers + default_provider);
493/// the caller is responsible for persisting it to disk after a successful
494/// run. This keeps the core free of I/O concerns — tests can call `run`
495/// against a `Config::default()` without touching the filesystem.
496///
497/// Emits exactly one `TakeCodingplan { Success | Fail }` event at each exit path.
498pub fn run(
499    config: &mut Config,
500    tel: Option<&Arc<atomcode_telemetry::Telemetry>>,
501) -> Result<SetupReport> {
502    // Step 1: login
503    let login = step_login(tel);
504    if login.is_err() {
505        // No point continuing — every downstream call needs a token.
506        if let Some(t) = tel {
507            t.track(atomcode_telemetry::Event::TakeCodingplan {
508                type_: atomcode_telemetry::CodingplanResult::Fail,
509                error_kind: Some(atomcode_telemetry::CodingplanErrorKind::AuthError),
510                error_data: Some(serde_json::json!({
511                    "step": "login",
512                    "message": "Not logged in",
513                }).to_string()),
514            });
515        }
516        // Use the cascade sentinel so format() suppresses the three
517        // "Foo failed — skipped: login failed" rows that used to spam
518        // the report. The login-failure line above is the only thing
519        // worth showing; the rest is implied.
520        return Ok(SetupReport {
521            login,
522            claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
523            claim_attempts: Vec::new(),
524            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
525            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
526            auth_expired: false,
527        });
528    }
529
530    // Step 2: claim — cascade Max → Pro → Lite, first success wins.
531    let (claim, claim_attempts, claim_auth_expired) = step_claim();
532    if claim.is_err() {
533        // Claim failed at every tier — adding providers / fetching
534        // status both make no sense without an active plan. Bail
535        // with cascade markers for models/status; `claim_attempts`
536        // still carries every tier's outcome so the renderer shows
537        // the per-tier rows that explain WHY the cascade gave up.
538        return Ok(SetupReport {
539            login,
540            claim,
541            claim_attempts,
542            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
543            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
544            auth_expired: claim_auth_expired,
545        });
546    }
547
548    // Decide the plan_type to send to /models-v2. Three sources:
549    //   * Fresh `Ok` claim: use the tier the cascade landed on.
550    //   * `Skipped` (server returned `duplicate=true` at one of the
551    //     tiers): step_claim picked the tier it stopped at; we don't
552    //     have the structured value here, so fall back to Max — the
553    //     server will gate availability the same way regardless. Pro
554    //     and Lite users will see Pro/Max-tier models marked
555    //     `plan_available=false` and rendered with strikethrough,
556    //     which matches the spec ("show locked models too").
557    //   * (Err is unreachable here — handled above.)
558    let plan_type_for_models = match &claim {
559        StepResult::Ok(info) => info.plan_type,
560        _ => PlanType::Max,
561    };
562
563    // Step 3: models — critical. Without models there's nothing to set up.
564    let (models, models_auth_expired) = step_models_and_register(config, plan_type_for_models);
565    if models.is_err() {
566        if let Some(t) = tel {
567            t.track(atomcode_telemetry::Event::TakeCodingplan {
568                type_: atomcode_telemetry::CodingplanResult::Fail,
569                error_kind: Some(atomcode_telemetry::CodingplanErrorKind::NetworkError),
570                error_data: Some(serde_json::json!({
571                    "step": "models",
572                    "message": "Failed to fetch model list",
573                }).to_string()),
574            });
575        }
576        // Same cascade pattern: the models-failure line above is the
577        // explanation; "Status fetch failed — skipped: models step
578        // failed" adds nothing.
579        return Ok(SetupReport {
580            login,
581            claim,
582            claim_attempts,
583            models,
584            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
585            auth_expired: models_auth_expired,
586        });
587    }
588
589    // Step 4: status — warn-only. A 401 here is rare (claim+models
590    // both passed) but still worth surfacing so a retry has a chance
591    // to capture the warm token.
592    let (status, status_auth_expired) = step_status();
593
594    // All critical steps (login + models) succeeded. Emit success event.
595    if let Some(t) = tel {
596        t.track(atomcode_telemetry::Event::TakeCodingplan {
597            type_: atomcode_telemetry::CodingplanResult::Success,
598            error_kind: None,
599            error_data: Some(serde_json::json!({
600                "step": null,
601            }).to_string()),
602        });
603    }
604
605    Ok(SetupReport {
606        login,
607        claim,
608        claim_attempts,
609        models,
610        status,
611        auth_expired: status_auth_expired,
612    })
613}
614
615/// Sentinel reason used when downstream steps are skipped because an
616/// earlier required step failed (login / claim / models). `format()`
617/// recognises this exact string and renders nothing — the upstream
618/// failure line above already explains why nothing came after it.
619const CASCADE_FROM_UPSTREAM_FAIL: &str = "__cascade_upstream_fail__";
620
621fn step_login(tel: Option<&Arc<atomcode_telemetry::Telemetry>>) -> StepResult<LoginInfo> {
622    if auth::is_logged_in() {
623        // Already authed — surface the stored identity so the report
624        // shows *who* we're running as, not a bare "skipped". When
625        // display-name and username differ (the common case), show
626        // both so the user can tell them apart: `TheoCui(saulcy)`.
627        if let Some(info) = auth::get_stored_auth() {
628            let display = match info.user.name.as_deref() {
629                Some(name) if !name.is_empty() && name != info.user.username => {
630                    format!("{}({})", name, info.user.username)
631                }
632                _ => info.user.username.clone(),
633            };
634            return StepResult::Skipped(format!("already logged in as {}", display));
635        }
636        // Weird: is_logged_in said yes but stored auth is None. Treat
637        // as "login succeeded, details unavailable" rather than failing.
638        return StepResult::Skipped("already logged in".into());
639    }
640    // Not logged in — run OAuth. This prints to stdout + opens a browser.
641    // Callers in TUI context must have already suspended raw mode before
642    // calling `run`.
643    match auth::login(tel).and_then(|a| auth::save_auth(&a).map(|_| a)) {
644        Ok(auth_info) => StepResult::Ok(LoginInfo {
645            username: auth_info.user.username.clone(),
646            display_name: auth_info.user.name.clone(),
647            email: auth_info.user.email.clone(),
648        }),
649        Err(e) => StepResult::Err(format!("login failed: {:#}", e)),
650    }
651}
652
653/// Walk `PlanType::CASCADE_ORDER` (Max → Pro → Lite), POSTing
654/// `claim-v2` for each tier, and stop at the first that lands the
655/// user with an entitlement. Two outcomes count as "stop":
656///
657///   * `success=true`              — fresh claim of this tier.
658///   * `duplicate=true`            — user already holds this tier (or
659///                                   higher). Treat as success and use
660///                                   this tier as the working tier;
661///                                   trying lower tiers wouldn't help.
662///
663/// `success=false && duplicate=false` for a 2xx response is a per-tier
664/// "you can't have this" signal (e.g. quota exhausted at the Max tier
665/// but Pro/Lite slots still open). Try the next tier with the message
666/// preserved as the "last error" we'll show if everything below also
667/// fails.
668///
669/// Transport / 5xx errors abort the whole cascade — those mean the
670/// server is in a bad state, not "this tier is unavailable", so
671/// retrying lower tiers would just stack identical failures.
672/// Walk the cascade and capture every tier's outcome.
673///
674/// Returns `(overall, attempts, auth_expired)`:
675/// * `overall` — the legacy single-summary view of what happened
676///   (`Ok` / `Skipped` / `Err`). Drives `should_persist_config` and
677///   the downstream `step_models_and_register` plan-type selection.
678/// * `attempts` — every tier actually attempted, in cascade order.
679///   Renderer walks this to emit one row per tier (refused /
680///   errored / claimed) so users can see the full picture instead
681///   of just the winner.
682/// * `auth_expired` — true iff the failure was a 401/403 from
683///   `claim-v2` (or a `from_stored_auth` refresh failure). Bubbled
684///   up to `SetupReport.auth_expired` so the shell knows to
685///   re-OAuth and retry instead of just printing the failure.
686fn step_claim() -> (StepResult<ClaimInfo>, Vec<TierAttempt>, bool) {
687    let client = match Client::from_stored_auth() {
688        Ok(c) => c,
689        Err(e) => {
690            let auth_expired = is_auth_expired(&e);
691            return (
692                StepResult::Err(format!("build client: {:#}", e)),
693                Vec::new(),
694                auth_expired,
695            );
696        }
697    };
698    let mut attempts: Vec<TierAttempt> = Vec::with_capacity(PlanType::CASCADE_ORDER.len());
699    let mut last_msg = String::new();
700    for &tier in PlanType::CASCADE_ORDER {
701        match client.claim_v2(tier) {
702            Ok(resp) => {
703                if resp.duplicate {
704                    attempts.push(TierAttempt {
705                        tier,
706                        outcome: TierOutcome::AlreadyHeld {
707                            message: resp.message.clone(),
708                        },
709                    });
710                    let skipped = StepResult::Skipped(if resp.message.is_empty() {
711                        format!(
712                            "already claimed (or under review) — using {}",
713                            tier.as_str()
714                        )
715                    } else {
716                        format!("{} ({})", resp.message, tier.as_str())
717                    });
718                    return (skipped, attempts, false);
719                }
720                if resp.success {
721                    attempts.push(TierAttempt {
722                        tier,
723                        outcome: TierOutcome::Claimed {
724                            message: resp.message.clone(),
725                        },
726                    });
727                    let ok = StepResult::Ok(ClaimInfo {
728                        message: if resp.message.is_empty() {
729                            format!("claimed {}", tier.as_str())
730                        } else {
731                            resp.message
732                        },
733                        duplicate: false,
734                        plan_type: tier,
735                    });
736                    return (ok, attempts, false);
737                }
738                // 2xx + success=false + duplicate=false: per-tier
739                // refusal (quota / not eligible / 暂无开放).
740                attempts.push(TierAttempt {
741                    tier,
742                    outcome: TierOutcome::Refused {
743                        message: resp.message.clone(),
744                    },
745                });
746                last_msg = if resp.message.is_empty() {
747                    format!("{} claim refused", tier.as_str())
748                } else {
749                    format!("{}: {}", tier.as_str(), resp.message)
750                };
751            }
752            Err(e) => {
753                // Transport / 5xx / parse failure — bail. These don't
754                // get more useful when retried at a lower tier. Capture
755                // the auth-expired bit BEFORE flattening `e` to a string
756                // so the shell layer can retry with a fresh OAuth.
757                let auth_expired = is_auth_expired(&e);
758                let err_text = format!("{:#}", e);
759                attempts.push(TierAttempt {
760                    tier,
761                    outcome: TierOutcome::Errored {
762                        error: err_text.clone(),
763                    },
764                });
765                return (
766                    StepResult::Err(format!("claim {} request: {}", tier.as_str(), err_text)),
767                    attempts,
768                    auth_expired,
769                );
770            }
771        }
772    }
773    let overall = StepResult::Err(if last_msg.is_empty() {
774        "claim failed at every tier (Max/Pro/Lite)".into()
775    } else {
776        format!("claim failed at every tier — {}", last_msg)
777    });
778    (overall, attempts, false)
779}
780
781fn step_models_and_register(
782    config: &mut Config,
783    plan_type: PlanType,
784) -> (StepResult<ModelsInfo>, bool) {
785    let client = match Client::from_stored_auth() {
786        Ok(c) => c,
787        Err(e) => {
788            let auth_expired = is_auth_expired(&e);
789            return (
790                StepResult::Err(format!("build client: {:#}", e)),
791                auth_expired,
792            );
793        }
794    };
795    let all_models = match client.list_models_v2(plan_type) {
796        Ok(v) => v,
797        Err(e) => {
798            let auth_expired = is_auth_expired(&e);
799            return (
800                StepResult::Err(format!("list models-v2: {:#}", e)),
801                auth_expired,
802            );
803        }
804    };
805    if all_models.is_empty() {
806        return (
807            StepResult::Err(
808                "server returned an empty model list — cannot set up any provider".into(),
809            ),
810            false,
811        );
812    }
813
814    // Available subset — only these become providers. Locked ones
815    // (`plan_available=false`) survive in `all_models` for the
816    // strikethrough-display path; registering them as providers would
817    // give the user something they can `/model` into that 403s on the
818    // first request.
819    let available: Vec<&ModelEntry> = all_models.iter().filter(|m| m.plan_available).collect();
820    if available.is_empty() {
821        return (
822            StepResult::Err(format!(
823                "no models available on plan {} — server returned {} locked entries",
824                plan_type.as_str(),
825                all_models.len()
826            )),
827            false,
828        );
829    }
830
831    // Wipe any stale AtomGit* entries so we don't accumulate old names.
832    let stale: Vec<String> = config
833        .providers
834        .keys()
835        .filter(|k| is_codingplan_provider_name(k))
836        .cloned()
837        .collect();
838    for k in stale {
839        config.providers.remove(&k);
840    }
841
842    let names: Vec<String> = available
843        .iter()
844        .map(|m| m.display_model_name.clone())
845        .collect();
846    let provider_names = provider_names_for(&names);
847    let default_provider = provider_names
848        .first()
849        .cloned()
850        .unwrap_or_else(|| PROVIDER_PREFIX.to_string());
851
852    for (pname, m) in provider_names.iter().zip(available.iter()) {
853        let pc = build_codingplan_provider(m);
854        config.providers.insert(pname.clone(), pc);
855    }
856    config.default_provider = default_provider.clone();
857
858    // Auto-detect a vision_preprocessor candidate from the freshly
859    // installed list. Precedence:
860    //   - User-supplied non-AtomGit value: leave alone.
861    //   - None / AtomGit-* (i.e. previous /codingplan run): replace
862    //     with first VL/OCR model's provider key from the new list,
863    //     or clear to None when the new list has no VL candidate.
864    let vl_idx = names
865        .iter()
866        .position(|n| crate::provider::model_name_suggests_vision(n));
867    let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
868
869    let vision_preprocessor = {
870        let current = config.vision_preprocessor_provider.clone();
871        let user_supplied_non_atomgit = current
872            .as_deref()
873            .map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
874            .unwrap_or(false);
875
876        if user_supplied_non_atomgit {
877            VisionPreprocessorOutcome::UserSupplied(current.unwrap())
878        } else {
879            match new_vl_key {
880                Some(k) => {
881                    config.vision_preprocessor_provider = Some(k.clone());
882                    VisionPreprocessorOutcome::AutoSet(k)
883                }
884                None => {
885                    if current.is_some() {
886                        config.vision_preprocessor_provider = None;
887                        VisionPreprocessorOutcome::Cleared
888                    } else {
889                        VisionPreprocessorOutcome::UnchangedNone
890                    }
891                }
892            }
893        }
894    };
895
896    (
897        StepResult::Ok(ModelsInfo {
898            display_names: names,
899            provider_names,
900            default_provider,
901            vision_preprocessor,
902            all_models,
903        }),
904        false,
905    )
906}
907
908fn step_status() -> (StepResult<StatusResponse>, bool) {
909    let client = match Client::from_stored_auth() {
910        Ok(c) => c,
911        Err(e) => {
912            let auth_expired = is_auth_expired(&e);
913            return (
914                StepResult::Err(format!("build client: {:#}", e)),
915                auth_expired,
916            );
917        }
918    };
919    match client.status_v2() {
920        Ok(s) => (StepResult::Ok(s), false),
921        Err(e) => {
922            let auth_expired = is_auth_expired(&e);
923            (
924                StepResult::Err(format!("status-v2: {:#}", e)),
925                auth_expired,
926            )
927        }
928    }
929}
930
931/// Truncate a single-line message to at most `max` chars, appending `…`
932/// when shortened. Char-boundary safe (won't split a UTF-8 codepoint).
933/// Used when rendering error messages whose source includes a server
934/// response body — useful diagnostic prefix, useless multi-KB tail.
935fn truncate_inline(msg: &str, max: usize) -> String {
936    if msg.chars().count() <= max {
937        return msg.to_string();
938    }
939    let mut out: String = msg.chars().take(max).collect();
940    out.push('…');
941    out
942}
943
944/// Format a duration in seconds as a short human-readable label —
945/// `90s`, `5m`, `2h 30m`, `3d 4h`. Replaces the previous "{N}s" which
946/// was unreadable for anything past a minute (e.g. "in 86340s" instead
947/// of "in 23h 59m").
948fn format_duration_secs(secs: i64) -> String {
949    if secs < 0 {
950        return "—".into();
951    }
952    let s = secs as u64;
953    if s < 60 {
954        return format!("{}s", s);
955    }
956    let (m, sr) = (s / 60, s % 60);
957    if m < 60 {
958        return if sr == 0 { format!("{}m", m) } else { format!("{}m {}s", m, sr) };
959    }
960    let (h, mr) = (m / 60, m % 60);
961    if h < 24 {
962        return if mr == 0 { format!("{}h", h) } else { format!("{}h {}m", h, mr) };
963    }
964    let (d, hr) = (h / 24, h % 24);
965    if hr == 0 { format!("{}d", d) } else { format!("{}d {}h", d, hr) }
966}
967
968/// Decide the config-key name for each model. Single model → bare
969/// `AtomGit` (keeps the name tidy for the common case); 2+ models →
970/// `AtomGit-{name with / replaced by -}`.
971fn provider_names_for(model_names: &[String]) -> Vec<String> {
972    if model_names.len() == 1 {
973        vec![PROVIDER_PREFIX.to_string()]
974    } else {
975        model_names
976            .iter()
977            .map(|m| format!("{}-{}", PROVIDER_PREFIX, sanitize_model_for_name(m)))
978            .collect()
979    }
980}
981
982/// Turn `moonshotai/Kimi-K2-Instruct` → `moonshotai-Kimi-K2-Instruct`.
983/// Only swaps `/`; other punctuation stays verbatim (model names in the
984/// wild use `.` and digits freely, and TOML keys handle those fine).
985fn sanitize_model_for_name(model: &str) -> String {
986    model.replace('/', "-")
987}
988
989/// Match `AtomGit` OR `AtomGit-<anything>` — the set of config keys
990/// owned by the coding-plan flow. Used to wipe stale entries before
991/// re-populating from the fresh model list.
992fn is_codingplan_provider_name(name: &str) -> bool {
993    name == PROVIDER_PREFIX || name.starts_with(&format!("{}-", PROVIDER_PREFIX))
994}
995
996/// Build a ProviderConfig from a model-list entry. The server's
997/// per-model fields take precedence; missing fields fall back to the
998/// historical fallbacks ([`codingplan_llm_base_url`] / `PROVIDER_TYPE`
999/// / `CONTEXT_WINDOW`) so older `models-v2` payloads without the new
1000/// columns continue to work without code changes.
1001///
1002/// `api_key` stays `None` regardless — `create_provider()` loads the
1003/// OAuth token at runtime via `auth.toml` so we never persist it into
1004/// the user's `config.toml`.
1005fn build_codingplan_provider(entry: &ModelEntry) -> ProviderConfig {
1006    ProviderConfig {
1007        provider_type: entry
1008            .provider_type
1009            .clone()
1010            .filter(|s| !s.is_empty())
1011            .unwrap_or_else(|| PROVIDER_TYPE.to_string()),
1012        api_key: None,
1013        model: entry.display_model_name.clone(),
1014        base_url: Some(
1015            entry
1016                .base_url
1017                .clone()
1018                .filter(|s| !s.is_empty())
1019                .unwrap_or_else(codingplan_llm_base_url),
1020        ),
1021        system_prompt: None,
1022        user_agent: None,
1023        // `context_window: 0` from a misconfigured row would degrade
1024        // every request to a zero-token window; treat that as
1025        // "missing" and fall back rather than ship a broken provider.
1026        context_window: entry
1027            .context_window
1028            .filter(|n| *n > 0)
1029            .unwrap_or(CONTEXT_WINDOW),
1030        max_tokens: None,
1031        thinking_type: None,
1032        thinking_keep: None,
1033        reasoning_history: None,
1034        thinking_enabled: None,
1035        thinking_budget: None,
1036        skip_tls_verify: false,
1037        ephemeral: false,
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use std::collections::HashMap;
1045
1046    /// Build a `ModelEntry` for tests that only care about the
1047    /// model name and want every other field to take its fallback
1048    /// (`base_url` → [`codingplan_llm_base_url`], `provider_type` →
1049    /// `PROVIDER_TYPE`, `context_window` → `CONTEXT_WINDOW`,
1050    /// `plan_available: true`).
1051    /// Lets the bulk of the test suite stay short while the
1052    /// per-field-override behaviour gets its own dedicated tests
1053    /// further down.
1054    fn entry(display_model_name: &str) -> super::super::types::ModelEntry {
1055        super::super::types::ModelEntry {
1056            display_model_name: display_model_name.to_string(),
1057            plan_available: true,
1058            ..Default::default()
1059        }
1060    }
1061
1062    fn blank_config() -> Config {
1063        Config {
1064            default_provider: String::new(),
1065            default_workdir: None,
1066            providers: HashMap::new(),
1067            datalog: Default::default(),
1068            auto_update: true,
1069            notifications: Default::default(),
1070            telemetry: Default::default(),
1071            lsp: Default::default(),
1072            auto_commit: false,
1073            subagent: Default::default(),
1074            vision_preprocessor_provider: None,
1075            language: None,
1076            ui: Default::default(),
1077            plugin: Default::default(),
1078        }
1079    }
1080
1081    #[test]
1082    fn single_model_uses_bare_prefix() {
1083        let names = vec!["moonshotai/Kimi-K2-Instruct".into()];
1084        let p = provider_names_for(&names);
1085        assert_eq!(p, vec!["AtomGit".to_string()]);
1086    }
1087
1088    #[test]
1089    fn multiple_models_expand_to_prefix_suffixes() {
1090        let names = vec![
1091            "moonshotai/Kimi-K2-Instruct".into(),
1092            "anthropic/claude-3.5-sonnet".into(),
1093            "openai/gpt-5".into(),
1094        ];
1095        let p = provider_names_for(&names);
1096        assert_eq!(
1097            p,
1098            vec![
1099                "AtomGit-moonshotai-Kimi-K2-Instruct".to_string(),
1100                "AtomGit-anthropic-claude-3.5-sonnet".to_string(),
1101                "AtomGit-openai-gpt-5".to_string(),
1102            ]
1103        );
1104    }
1105
1106    #[test]
1107    fn sanitize_replaces_slash_only() {
1108        // `/` becomes `-`; `.` and digits stay (valid in TOML keys).
1109        assert_eq!(
1110            sanitize_model_for_name("anthropic/claude-3.5-sonnet"),
1111            "anthropic-claude-3.5-sonnet"
1112        );
1113    }
1114
1115    #[test]
1116    fn is_codingplan_name_matches_prefix_and_exact() {
1117        assert!(is_codingplan_provider_name("AtomGit"));
1118        assert!(is_codingplan_provider_name("AtomGit-foo"));
1119        assert!(is_codingplan_provider_name("AtomGit-moonshotai-Kimi-K2"));
1120        assert!(!is_codingplan_provider_name("AtomGitPlus"));
1121        assert!(!is_codingplan_provider_name("atomgit")); // case-sensitive
1122        assert!(!is_codingplan_provider_name("claude"));
1123    }
1124
1125    #[test]
1126    fn step_models_wipes_stale_atomgit_entries() {
1127        // Simulate a user who previously ran `/login` (old MiniMax entry)
1128        // and a manual `/provider` session (custom Anthropic entry). After
1129        // coding-plan setup, only fresh AtomGit* entries should remain;
1130        // the manual Anthropic one stays.
1131        let mut config = blank_config();
1132        config.providers.insert(
1133            "AtomGit".to_string(),
1134            build_codingplan_provider(&entry("stale-MiniMax")),
1135        );
1136        config.providers.insert(
1137            "AtomGit-legacy".to_string(),
1138            build_codingplan_provider(&entry("another-stale")),
1139        );
1140        config.providers.insert(
1141            "claude".to_string(),
1142            build_codingplan_provider(&entry("anthropic/claude-3.5")),
1143        );
1144
1145        // Manually drive the "install" side without network — mirror
1146        // what step_models_and_register does after a successful API call.
1147        let names = vec!["meta-llama/Llama-3-70B".to_string()];
1148        let stale: Vec<String> = config
1149            .providers
1150            .keys()
1151            .filter(|k| is_codingplan_provider_name(k))
1152            .cloned()
1153            .collect();
1154        for k in stale {
1155            config.providers.remove(&k);
1156        }
1157        let provider_names = provider_names_for(&names);
1158        for (pname, m) in provider_names.iter().zip(names.iter()) {
1159            config
1160                .providers
1161                .insert(pname.clone(), build_codingplan_provider(&entry(m)));
1162        }
1163        config.default_provider = provider_names[0].clone();
1164
1165        assert_eq!(config.providers.len(), 2, "claude + one fresh AtomGit");
1166        assert!(
1167            config.providers.contains_key("claude"),
1168            "unrelated entry kept"
1169        );
1170        assert!(
1171            config.providers.contains_key("AtomGit"),
1172            "fresh AtomGit added"
1173        );
1174        assert!(
1175            !config.providers.contains_key("AtomGit-legacy"),
1176            "stale removed"
1177        );
1178        let fresh = &config.providers["AtomGit"];
1179        assert_eq!(fresh.model, "meta-llama/Llama-3-70B");
1180        assert_eq!(
1181            fresh.base_url.as_deref(),
1182            Some(codingplan_llm_base_url().as_str())
1183        );
1184        assert_eq!(fresh.provider_type, PROVIDER_TYPE);
1185        assert_eq!(config.default_provider, "AtomGit");
1186    }
1187
1188    #[test]
1189    fn codingplan_llm_base_url_defaults_to_new_signed_gateway() {
1190        // Lock in the default. If `ATOMCODE_CODINGPLAN_LLM_BASE_URL` is
1191        // set in the test environment (CI / staging override / dev box
1192        // with a stray export), honour it — otherwise the default must
1193        // be the modern `llm-api.atomgit.com` host. Anything else (most
1194        // notably the legacy `api-ai.gitcode.com`) silently disables
1195        // codingplan request signing because `is_atomgit_gateway` in
1196        // `coding_plan::crypto` only whitelists the new host.
1197        //
1198        // OnceLock caches across test threads, so this test reflects
1199        // whatever the env was at the FIRST call site in the process.
1200        // That's deliberate — it ensures every test in this module
1201        // agrees on the URL, mirroring production behaviour where the
1202        // value is fixed for the lifetime of one `atomcode` run.
1203        let actual = codingplan_llm_base_url();
1204        let env_override = std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
1205            .ok()
1206            .map(|v| v.trim().trim_end_matches('/').to_string())
1207            .filter(|v| !v.is_empty());
1208        if let Some(want) = env_override {
1209            assert_eq!(actual, want, "env override must win when set");
1210        } else {
1211            assert_eq!(
1212                actual, "https://llm-api.atomgit.com/v1",
1213                "default must point at the new signed gateway (NOT legacy api-ai.gitcode.com); \
1214                 otherwise codingplan signing never engages"
1215            );
1216        }
1217    }
1218
1219    #[test]
1220    fn build_provider_uses_canonical_defaults() {
1221        // All optional server fields missing → fall back to the
1222        // historical constants. Pins the back-compat path for
1223        // older `models-v2` builds that don't yet emit `base_url`,
1224        // `type`, or `context_window`.
1225        let p = build_codingplan_provider(&entry("foo/bar"));
1226        assert_eq!(p.provider_type, "openai");
1227        assert_eq!(
1228            p.base_url.as_deref(),
1229            Some(codingplan_llm_base_url().as_str())
1230        );
1231        assert_eq!(p.context_window, 64_000);
1232        assert!(
1233            p.api_key.is_none(),
1234            "token loaded at runtime from auth.toml"
1235        );
1236        assert!(!p.ephemeral);
1237    }
1238
1239    #[test]
1240    fn build_provider_uses_server_overrides_when_present() {
1241        // Per-model server fields take precedence over the
1242        // hard-coded fallbacks. Mirrors the new wire shape:
1243        // `base_url`, `type`, `context_window` all populated.
1244        let e = super::super::types::ModelEntry {
1245            id: 2052994857682014210,
1246            is_infinity: 2,
1247            is_atomcode_exclusive: 1,
1248            display_model_name: "GLM-5.1".into(),
1249            base_url: Some("https://custom.example.com/v1".into()),
1250            provider_type: Some("claude".into()),
1251            context_window: Some(128_000),
1252            plan_available: true,
1253        };
1254        let p = build_codingplan_provider(&e);
1255        assert_eq!(p.model, "GLM-5.1");
1256        assert_eq!(p.provider_type, "claude");
1257        assert_eq!(p.base_url.as_deref(), Some("https://custom.example.com/v1"));
1258        assert_eq!(p.context_window, 128_000);
1259    }
1260
1261    #[test]
1262    fn build_provider_treats_empty_or_zero_overrides_as_missing() {
1263        // Defensive: a malformed server row (empty string base_url /
1264        // type, zero context window) shouldn't ship a provider that
1265        // refuses every request. Fall back to constants instead.
1266        let e = super::super::types::ModelEntry {
1267            display_model_name: "weird".into(),
1268            base_url: Some(String::new()),
1269            provider_type: Some(String::new()),
1270            context_window: Some(0),
1271            plan_available: true,
1272            ..Default::default()
1273        };
1274        let p = build_codingplan_provider(&e);
1275        assert_eq!(p.provider_type, "openai");
1276        assert_eq!(
1277            p.base_url.as_deref(),
1278            Some(codingplan_llm_base_url().as_str())
1279        );
1280        assert_eq!(p.context_window, 64_000);
1281    }
1282
1283    #[test]
1284    fn model_entry_deserialises_new_wire_shape() {
1285        // The exact JSON payload from the spec —
1286        // every new field must parse without error.
1287        let raw = r#"[{
1288            "id": 2052994857682014210,
1289            "is_infinity": 2,
1290            "is_atomcode_exclusive": 1,
1291            "display_model_name": "GLM-5.1",
1292            "base_url": "https://api-ai.gitcode.com/v1",
1293            "type": "openai",
1294            "context_window": 64000,
1295            "plan_available": true
1296        }]"#;
1297        let list: Vec<super::super::types::ModelEntry> =
1298            serde_json::from_str(raw).expect("payload deserialises");
1299        assert_eq!(list.len(), 1);
1300        let m = &list[0];
1301        assert_eq!(m.id, 2052994857682014210);
1302        assert_eq!(m.is_infinity, 2);
1303        assert_eq!(m.is_atomcode_exclusive, 1);
1304        assert_eq!(m.display_model_name, "GLM-5.1");
1305        assert_eq!(m.base_url.as_deref(), Some("https://api-ai.gitcode.com/v1"));
1306        assert_eq!(m.provider_type.as_deref(), Some("openai"));
1307        assert_eq!(m.context_window, Some(64_000));
1308        assert!(m.plan_available);
1309    }
1310
1311    #[test]
1312    fn model_entry_deserialises_legacy_wire_shape() {
1313        // Older server build with only the v2-minimum fields. New
1314        // fields default to `None` / `0` so older payloads keep
1315        // working — the orchestrator falls back to the constants.
1316        let raw = r#"[{
1317            "id": 1,
1318            "is_atomcode_exclusive": 0,
1319            "display_model_name": "legacy/model",
1320            "plan_available": true
1321        }]"#;
1322        let list: Vec<super::super::types::ModelEntry> =
1323            serde_json::from_str(raw).expect("legacy payload deserialises");
1324        let m = &list[0];
1325        assert_eq!(m.display_model_name, "legacy/model");
1326        assert!(m.base_url.is_none());
1327        assert!(m.provider_type.is_none());
1328        assert!(m.context_window.is_none());
1329        assert_eq!(m.is_infinity, 0);
1330    }
1331
1332    /// Render exercise: every step Ok. Verifies the three-line output
1333    /// structure the user sees on a fresh happy-path run.
1334    #[test]
1335    fn render_happy_path_has_all_checkmarks() {
1336        let report = SetupReport {
1337            login: StepResult::Ok(LoginInfo {
1338                username: "theo".into(),
1339                display_name: Some("Theo".into()),
1340                email: Some("theo@example.com".into()),
1341            }),
1342            claim: StepResult::Ok(ClaimInfo {
1343                message: "领取成功".into(),
1344                duplicate: false,
1345                plan_type: PlanType::Max,
1346            }),
1347            claim_attempts: Vec::new(),
1348            models: StepResult::Ok(ModelsInfo {
1349                display_names: vec!["moonshotai/Kimi-K2-Instruct".into()],
1350                provider_names: vec!["AtomGit".into()],
1351                default_provider: "AtomGit".into(),
1352                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1353                all_models: vec![],
1354            }),
1355            status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
1356                codingplan_free: Some(crate::coding_plan::types::PlanInfo {
1357                    plan_name: "CodingPlan Free".into(),
1358                    status: 1,
1359                    claimed_at: "2026-04-22".into(),
1360                    expires_at: "2026-05-22".into(),
1361                    remaining_days: 29,
1362                    total_days: 30,
1363                    apply_id: 1,
1364                }),
1365                current_usage: Some(crate::coding_plan::types::UsageInfo {
1366                    placeholder: false,
1367                    window_token_limit: 50000,
1368                    window_tokens_used: 0,
1369                    usage_percent: 0.0,
1370                    window_hours: 1,
1371                    reset_at: "2026-04-23T12:13:14".into(),
1372                    reset_at_display: "12:13".into(),
1373                    seconds_until_reset: 693,
1374                    reset_label: String::new(),
1375                    usage_status_desc: String::new(),
1376                }),
1377                audit_status: 1,
1378                expires_at: Some("2026-05-22".into()),
1379                window_quota_exhausted: false,
1380                window_quota_hint: None,
1381            }),
1382            auth_expired: false,
1383        };
1384        let out = report.render();
1385        assert!(out.contains("✓ Logged in as Theo"));
1386        assert!(out.contains("theo@example.com"));
1387        assert!(out.contains("CodingPlan claimed"));
1388        assert!(out.contains("Kimi-K2-Instruct"));
1389        assert!(out.contains("AtomGit"));
1390        assert!(out.contains("(default)"));
1391        assert!(out.contains("CodingPlan Free"));
1392        assert!(out.contains("12:13"));
1393        assert!(report.should_persist_config());
1394    }
1395
1396    /// Render exercise: claim returned duplicate=true. Must render as
1397    /// a skipped checkmark, NOT a failure — user already had the plan.
1398    #[test]
1399    fn render_claim_duplicate_renders_as_success() {
1400        let report = SetupReport {
1401            login: StepResult::Skipped("already logged in as theo".into()),
1402            claim: StepResult::Skipped("already claimed / in review".into()),
1403            claim_attempts: Vec::new(),
1404            models: StepResult::Ok(ModelsInfo {
1405                display_names: vec!["a/b".into()],
1406                provider_names: vec!["AtomGit".into()],
1407                default_provider: "AtomGit".into(),
1408                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1409                all_models: vec![],
1410            }),
1411            status: StepResult::Err("request timeout".into()),
1412            auth_expired: false,
1413        };
1414        let out = report.render();
1415        assert!(out.contains("✓ already logged in"));
1416        assert!(out.contains("already claimed"));
1417        assert!(!out.contains("✗ CodingPlan claim"), "duplicate ≠ failure");
1418        // Status failed but it's warn-only: ⚠ prefix, NOT ✗.
1419        assert!(out.contains("⚠ Status fetch failed"));
1420        assert!(!out.contains("✗ Status"));
1421        // Login skipped + models ok ⇒ config should still be persisted.
1422        assert!(report.should_persist_config());
1423    }
1424
1425    /// Regression: when a fresh claim hasn't activated yet the backend
1426    /// returns `claimed_at: null, expires_at: null, total_days: 0,
1427    /// remaining_days: 0`. Pre-fix the render line came out as
1428    /// `Plan: CodingPlan Free  ·  expires  (0d / 0d remaining)` — empty
1429    /// gap in the middle + bogus zeros, looked like a parser bug. Now
1430    /// the empty-expiry case shows a meaningful "pending activation"
1431    /// state instead.
1432    #[test]
1433    fn render_status_pending_activation_omits_zero_expiry() {
1434        let report = SetupReport {
1435            login: StepResult::Skipped("already logged in".into()),
1436            claim: StepResult::Ok(ClaimInfo {
1437                message: "claimed".into(),
1438                duplicate: false,
1439                plan_type: PlanType::Max,
1440            }),
1441            claim_attempts: Vec::new(),
1442            models: StepResult::Ok(ModelsInfo {
1443                display_names: vec!["a/b".into()],
1444                provider_names: vec!["AtomGit".into()],
1445                default_provider: "AtomGit".into(),
1446                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1447                all_models: vec![],
1448            }),
1449            status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
1450                codingplan_free: Some(crate::coding_plan::types::PlanInfo {
1451                    plan_name: "CodingPlan Free".into(),
1452                    status: 0,
1453                    claimed_at: String::new(),
1454                    expires_at: String::new(),
1455                    remaining_days: 0,
1456                    total_days: 0,
1457                    apply_id: 0,
1458                }),
1459                current_usage: None,
1460                audit_status: 0,
1461                expires_at: None,
1462                window_quota_exhausted: false,
1463                window_quota_hint: None,
1464            }),
1465            auth_expired: false,
1466        };
1467        let out = report.render();
1468        assert!(out.contains("Plan: CodingPlan Free"), "plan name still shown: {}", out);
1469        assert!(
1470            out.contains("pending activation"),
1471            "must surface pending state to user: {}",
1472            out
1473        );
1474        assert!(
1475            !out.contains("(0d / 0d"),
1476            "bogus zero countdown must not render: {}",
1477            out
1478        );
1479        assert!(
1480            !out.contains("expires  ("),
1481            "empty expires-date with double space must not render: {}",
1482            out
1483        );
1484    }
1485
1486    /// Render exercise: login failed. Downstream steps are pre-marked
1487    /// with the cascade sentinel; format() suppresses them so only the
1488    /// login-failure line appears. Config must NOT be persisted.
1489    #[test]
1490    fn render_login_failed_blocks_persist_and_suppresses_cascade() {
1491        let report = SetupReport {
1492            login: StepResult::Err("browser handshake timed out".into()),
1493            claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1494            claim_attempts: Vec::new(),
1495            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1496            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1497            auth_expired: false,
1498        };
1499        let out = report.render();
1500        assert!(out.contains("✗ Login failed"));
1501        // Cascade rows must NOT appear.
1502        assert!(!out.contains("CodingPlan claim"), "no cascade claim row on login fail");
1503        assert!(!out.contains("Models step"), "no cascade models row on login fail");
1504        assert!(!out.contains("Status fetch"), "no cascade status row on login fail");
1505        // Login Err ⇒ should_persist_config = false (login.is_ok_or_skipped() is false).
1506        assert!(
1507            !report.should_persist_config(),
1508            "don't write config on login failure"
1509        );
1510    }
1511
1512    /// Regression: claim returned Err (e.g. AtomGit `claim-v2` 500
1513    /// with the Spring `UnexpectedRollbackException` payload). `run()`
1514    /// short-circuits and stamps the cascade sentinel into `models` /
1515    /// `status`. Before this fix, `should_persist_config` only
1516    /// checked `login` and `models` — both `is_ok_or_skipped()` =
1517    /// `true` here — so the gate flipped open and `save_and_reload`
1518    /// rewrote `config.toml`. Surfaced to the user as "claim failed
1519    /// but models still got written to my config". Now the predicate
1520    /// also requires `claim.is_ok_or_skipped()` so any real claim
1521    /// failure blocks the persist.
1522    #[test]
1523    fn claim_err_blocks_persist() {
1524        let report = SetupReport {
1525            login: StepResult::Skipped("already logged in".into()),
1526            claim: StepResult::Err(
1527                "claim Pro request: claim-v2 returned 500 Internal Server Error \
1528                 — Transaction rolled back because it has been marked as rollback-only"
1529                    .into(),
1530            ),
1531            claim_attempts: Vec::new(),
1532            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1533            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1534            auth_expired: false,
1535        };
1536        assert!(
1537            !report.should_persist_config(),
1538            "claim Err must block save_and_reload — config rewrite was overwriting \
1539             manual edits between TUI startup and /codingplan",
1540        );
1541        // Sanity-check: the duplicate-claim Skipped path (server says
1542        // "already claimed") must STILL persist so two-runs-in-a-row
1543        // /codingplan keeps working as a model-list sync.
1544        let dup = SetupReport {
1545            login: StepResult::Skipped("already logged in".into()),
1546            claim: StepResult::Skipped("already claimed / using Max".into()),
1547            claim_attempts: Vec::new(),
1548            models: StepResult::Ok(ModelsInfo {
1549                display_names: vec!["a/b".into()],
1550                provider_names: vec!["AtomGit".into()],
1551                default_provider: "AtomGit".into(),
1552                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1553                all_models: vec![],
1554            }),
1555            status: StepResult::Err("status fetch timeout".into()),
1556            auth_expired: false,
1557        };
1558        assert!(
1559            dup.should_persist_config(),
1560            "duplicate-claim Skipped must still allow persist (it's the model-sync path)",
1561        );
1562    }
1563
1564    /// `auth_expired = true` MUST NOT flip `should_persist_config()`
1565    /// open on its own — the gate already requires every critical step
1566    /// to be `is_ok_or_skipped`, and that's where the actual safety
1567    /// lives. `auth_expired` is a side-channel for the shell to decide
1568    /// "retry with fresh OAuth"; it's orthogonal to "is this report
1569    /// good enough to write to disk". Regression guard: a future
1570    /// refactor that ANDs `auth_expired` into the predicate would
1571    /// double-gate (claim Err + auth_expired both block) but a future
1572    /// refactor that ORs it the wrong way would open the persist gate
1573    /// on an auth-expired-but-otherwise-skipped report. Lock the
1574    /// orthogonality in.
1575    #[test]
1576    fn auth_expired_alone_does_not_change_persist_gate() {
1577        // All-Skipped report (login skipped, no claim attempted, etc.)
1578        // with auth_expired=true. Persist gate is driven by the step
1579        // outcomes — Skipped counts as "ok or skipped" — so this should
1580        // still allow persist.
1581        let allow = SetupReport {
1582            login: StepResult::Skipped("already logged in".into()),
1583            claim: StepResult::Skipped("already claimed".into()),
1584            claim_attempts: Vec::new(),
1585            models: StepResult::Ok(ModelsInfo {
1586                display_names: vec!["a/b".into()],
1587                provider_names: vec!["AtomGit".into()],
1588                default_provider: "AtomGit".into(),
1589                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1590                all_models: vec![],
1591            }),
1592            status: StepResult::Skipped("ok".into()),
1593            auth_expired: true,
1594        };
1595        assert!(
1596            allow.should_persist_config(),
1597            "auth_expired must not gate persist when every critical step \
1598             is ok/skipped — it's a side-channel for retry, not safety",
1599        );
1600
1601        // Claim Err report. Persist gate already false, auth_expired
1602        // doesn't matter.
1603        let block = SetupReport {
1604            login: StepResult::Skipped("already logged in".into()),
1605            claim: StepResult::Err("auth failed".into()),
1606            claim_attempts: Vec::new(),
1607            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1608            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1609            auth_expired: true,
1610        };
1611        assert!(
1612            !block.should_persist_config(),
1613            "claim Err already blocks persist — auth_expired doesn't \
1614             relax it",
1615        );
1616    }
1617
1618    /// Per-tier cascade rendering: Max refused (额度已满) → Pro
1619    /// refused (额度已满) → Lite claimed. Users should see ALL three
1620    /// rows so they understand the cascade walked Max → Pro → Lite,
1621    /// not just the winner.
1622    #[test]
1623    fn render_per_tier_cascade_shows_every_attempt() {
1624        let report = SetupReport {
1625            login: StepResult::Skipped("already logged in as Code_dh".into()),
1626            claim: StepResult::Ok(ClaimInfo {
1627                message: "claimed".into(),
1628                duplicate: false,
1629                plan_type: PlanType::Lite,
1630            }),
1631            claim_attempts: vec![
1632                TierAttempt {
1633                    tier: PlanType::Max,
1634                    outcome: TierOutcome::Refused {
1635                        message: "额度已满".into(),
1636                    },
1637                },
1638                TierAttempt {
1639                    tier: PlanType::Pro,
1640                    outcome: TierOutcome::Refused {
1641                        message: "额度已满".into(),
1642                    },
1643                },
1644                TierAttempt {
1645                    tier: PlanType::Lite,
1646                    outcome: TierOutcome::Claimed {
1647                        message: "领取成功".into(),
1648                    },
1649                },
1650            ],
1651            models: StepResult::Skipped("models step not exercised here".into()),
1652            status: StepResult::Skipped("status not exercised here".into()),
1653            auth_expired: false,
1654        };
1655        let out = report.render();
1656        // Max + Pro must surface as 领取失败 with the actual server
1657        // message so users can tell the cascade walked past them
1658        // (and why) instead of a single "claim failed: Lite: 额度已满".
1659        assert!(
1660            out.contains("CodingPlan Max 领取失败 — 额度已满")
1661                || out.contains("CodingPlan Max claim failed — 额度已满"),
1662            "Max refusal row missing: {}",
1663            out
1664        );
1665        assert!(
1666            out.contains("CodingPlan Pro 领取失败 — 额度已满")
1667                || out.contains("CodingPlan Pro claim failed — 额度已满"),
1668            "Pro refusal row missing: {}",
1669            out
1670        );
1671        // Lite must surface as 领取成功 (the winner).
1672        assert!(
1673            out.contains("CodingPlan Lite 领取成功")
1674                || out.contains("CodingPlan Lite claimed"),
1675            "Lite success row missing: {}",
1676            out
1677        );
1678        // The legacy single-line summary must NOT appear when
1679        // claim_attempts is populated — would be a duplicate "claimed
1680        // Lite" row.
1681        assert!(
1682            !out.contains("CodingPlan claimed"),
1683            "legacy claim-summary row must be suppressed when per-tier rows present: {}",
1684            out
1685        );
1686    }
1687
1688    /// Per-tier cascade where every tier refused — winning tier is
1689    /// `None`, overall claim is `Err`. Each refused tier still gets
1690    /// its own row.
1691    #[test]
1692    fn render_per_tier_cascade_all_refused() {
1693        let report = SetupReport {
1694            login: StepResult::Skipped("already logged in".into()),
1695            claim: StepResult::Err(
1696                "claim failed at every tier — Lite: 暂无开放".into(),
1697            ),
1698            claim_attempts: vec![
1699                TierAttempt {
1700                    tier: PlanType::Max,
1701                    outcome: TierOutcome::Refused {
1702                        message: "暂无开放".into(),
1703                    },
1704                },
1705                TierAttempt {
1706                    tier: PlanType::Pro,
1707                    outcome: TierOutcome::Refused {
1708                        message: "暂无开放".into(),
1709                    },
1710                },
1711                TierAttempt {
1712                    tier: PlanType::Lite,
1713                    outcome: TierOutcome::Refused {
1714                        message: "暂无开放".into(),
1715                    },
1716                },
1717            ],
1718            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1719            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1720            auth_expired: false,
1721        };
1722        let out = report.render();
1723        // All three tier rows present with the 暂无开放 message.
1724        for tier in &["Max", "Pro", "Lite"] {
1725            let zh = format!("CodingPlan {} 领取失败 — 暂无开放", tier);
1726            let en = format!("CodingPlan {} claim failed — 暂无开放", tier);
1727            assert!(
1728                out.contains(&zh) || out.contains(&en),
1729                "{} refusal row missing: {}",
1730                tier,
1731                out
1732            );
1733        }
1734        // Overall claim is Err but with claim_attempts populated, the
1735        // legacy "✗ CodingPlan claim failed — ..." summary line is
1736        // suppressed (per-tier rows already explain the failure).
1737        assert!(
1738            !out.contains("claim failed at every tier"),
1739            "legacy err-summary row must not appear: {}",
1740            out
1741        );
1742        // Models row also suppressed (cascade sentinel).
1743        assert!(
1744            !out.contains("Models step"),
1745            "cascade-from-claim-fail must hide models row: {}",
1746            out
1747        );
1748    }
1749
1750    /// Per-tier cascade where Max errored (5xx). `Errored` and
1751    /// `Refused` both render as `领取失败` with the message — same
1752    /// visual to the user, same cause from their POV. Make sure the
1753    /// error text gets truncated so a long stack trace doesn't blow
1754    /// up the row.
1755    #[test]
1756    fn render_per_tier_cascade_with_errored_tier_truncates_long_message() {
1757        let long_err = "x".repeat(500);
1758        let report = SetupReport {
1759            login: StepResult::Skipped("already logged in".into()),
1760            claim: StepResult::Err(format!("claim Max request: {}", long_err)),
1761            claim_attempts: vec![TierAttempt {
1762                tier: PlanType::Max,
1763                outcome: TierOutcome::Errored {
1764                    error: long_err.clone(),
1765                },
1766            }],
1767            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1768            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1769            auth_expired: false,
1770        };
1771        let out = report.render();
1772        // The Max row is present with 领取失败.
1773        assert!(
1774            out.contains("CodingPlan Max 领取失败 —")
1775                || out.contains("CodingPlan Max claim failed —"),
1776            "Max errored row missing: {}",
1777            out
1778        );
1779        // The full 500-char error must NOT appear verbatim — truncated.
1780        assert!(
1781            !out.contains(&long_err),
1782            "long error must be truncated, not pasted whole: {}",
1783            out
1784        );
1785    }
1786
1787    /// Render exercise: multi-model report. Verifies each provider
1788    /// name gets its own bullet + `(default)` marks only the first.
1789    #[test]
1790    fn render_multi_model_lists_all_providers_with_default_mark() {
1791        let report = SetupReport {
1792            login: StepResult::Skipped("already logged in as theo".into()),
1793            claim: StepResult::Ok(ClaimInfo {
1794                message: String::new(),
1795                duplicate: false,
1796                plan_type: PlanType::Max,
1797            }),
1798            claim_attempts: Vec::new(),
1799            models: StepResult::Ok(ModelsInfo {
1800                display_names: vec![
1801                    "moonshotai/Kimi-K2-Instruct".into(),
1802                    "anthropic/claude-3.5-sonnet".into(),
1803                    "openai/gpt-5".into(),
1804                ],
1805                provider_names: vec![
1806                    "AtomGit-moonshotai-Kimi-K2-Instruct".into(),
1807                    "AtomGit-anthropic-claude-3.5-sonnet".into(),
1808                    "AtomGit-openai-gpt-5".into(),
1809                ],
1810                default_provider: "AtomGit-moonshotai-Kimi-K2-Instruct".into(),
1811                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1812                all_models: vec![],
1813            }),
1814            status: StepResult::Err("status endpoint 500".into()),
1815            auth_expired: false,
1816        };
1817        let out = report.render();
1818        assert!(out.contains("Added 3 providers"));
1819        assert!(out.contains(
1820            "AtomGit-moonshotai-Kimi-K2-Instruct  →  moonshotai/Kimi-K2-Instruct  (default)"
1821        ));
1822        assert!(
1823            out.contains("AtomGit-anthropic-claude-3.5-sonnet  →  anthropic/claude-3.5-sonnet\n")
1824        );
1825        assert!(
1826            !out.contains("anthropic/claude-3.5-sonnet  (default)"),
1827            "only first is default"
1828        );
1829    }
1830
1831    /// Render exercise: claim failed. The cascade markers on models +
1832    /// status must render as nothing — the claim-failed line is the
1833    /// explanation, repeating it twice more is noise.
1834    #[test]
1835    fn render_claim_failed_suppresses_cascade_rows() {
1836        let report = SetupReport {
1837            login: StepResult::Skipped("already logged in as theo".into()),
1838            claim: StepResult::Err("今日codingplan申请额度已满,请明天再试".into()),
1839            claim_attempts: Vec::new(),
1840            models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1841            status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1842            auth_expired: false,
1843        };
1844        let out = report.render();
1845        assert!(out.contains("✗ CodingPlan claim failed"));
1846        assert!(out.contains("今日codingplan申请额度已满"));
1847        // The cascade rows must NOT appear.
1848        assert!(!out.contains("Models step skipped"), "no cascade row for models");
1849        assert!(!out.contains("Status fetch skipped"), "no cascade row for status");
1850        assert!(!out.contains("Added "), "must not say 'Added N providers' on claim fail");
1851        // The huge JSON body that used to leak through here must NOT appear.
1852        assert!(!out.contains("invalid type: null"));
1853        assert!(!out.contains("plan_name"));
1854    }
1855
1856    /// Non-cascade Skipped reasons still render — only the sentinel
1857    /// (`__cascade_upstream_fail__`) is suppressed.
1858    #[test]
1859    fn render_skipped_with_non_cascade_reason_still_shows() {
1860        let report = SetupReport {
1861            login: StepResult::Skipped("already logged in as theo".into()),
1862            claim: StepResult::Skipped("already claimed".into()),
1863            claim_attempts: Vec::new(),
1864            models: StepResult::Skipped("models cached locally".into()),
1865            status: StepResult::Skipped("server returned 503; using cached".into()),
1866            auth_expired: false,
1867        };
1868        let out = report.render();
1869        assert!(out.contains("Models step skipped — models cached locally"));
1870        assert!(out.contains("Status fetch skipped — server returned 503"));
1871    }
1872
1873    /// Render exercise: status fetch failed with a multi-KB body chain.
1874    /// Output must be truncated to keep the report readable.
1875    #[test]
1876    fn render_status_error_truncates_long_message() {
1877        let huge = format!(
1878            "status: parse status response (body: {}): invalid type",
1879            "x".repeat(1000),
1880        );
1881        let report = SetupReport {
1882            login: StepResult::Skipped("already logged in".into()),
1883            claim: StepResult::Ok(ClaimInfo {
1884                message: "ok".into(),
1885                duplicate: false,
1886                plan_type: PlanType::Max,
1887            }),
1888            claim_attempts: Vec::new(),
1889            models: StepResult::Ok(ModelsInfo {
1890                display_names: vec!["a/b".into()],
1891                provider_names: vec!["AtomGit".into()],
1892                default_provider: "AtomGit".into(),
1893                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1894                all_models: vec![],
1895            }),
1896            status: StepResult::Err(huge),
1897            auth_expired: false,
1898        };
1899        let out = report.render();
1900        // Find the status line and check its length is bounded.
1901        let line = out.lines().find(|l| l.contains("Status fetch failed")).unwrap();
1902        // 150 chars + ellipsis + prefix + leading spaces ⇒ comfortably under 250.
1903        assert!(line.chars().count() < 250, "line still ~{} chars long", line.chars().count());
1904        assert!(line.contains('…'), "truncation marker present");
1905    }
1906
1907    #[test]
1908    fn format_duration_secs_human_readable() {
1909        assert_eq!(format_duration_secs(0), "0s");
1910        assert_eq!(format_duration_secs(45), "45s");
1911        assert_eq!(format_duration_secs(60), "1m");
1912        assert_eq!(format_duration_secs(90), "1m 30s");
1913        assert_eq!(format_duration_secs(3600), "1h");
1914        assert_eq!(format_duration_secs(3660), "1h 1m");
1915        assert_eq!(format_duration_secs(86400), "1d");
1916        assert_eq!(format_duration_secs(90060), "1d 1h");
1917        assert_eq!(format_duration_secs(-1), "—");
1918    }
1919
1920    #[test]
1921    fn truncate_inline_passes_short_strings_through() {
1922        assert_eq!(truncate_inline("short", 10), "short");
1923        assert_eq!(truncate_inline("exactly_ten", 11), "exactly_ten");
1924    }
1925
1926    #[test]
1927    fn truncate_inline_appends_ellipsis_when_long() {
1928        let r = truncate_inline("abcdefghijklmnop", 5);
1929        assert_eq!(r, "abcde…");
1930    }
1931
1932    #[test]
1933    fn truncate_inline_handles_unicode_safely() {
1934        // 5 CJK chars = 5 chars (regardless of byte count). No char-boundary panic.
1935        let r = truncate_inline("一二三四五六七八", 5);
1936        assert_eq!(r, "一二三四五…");
1937    }
1938
1939    // ── Vision-preprocessor auto-config tests ────────────────────────────
1940
1941    fn vl_model_entry(model: &str) -> super::super::types::ModelEntry {
1942        super::super::types::ModelEntry {
1943            id: 1,
1944            display_model_name: model.to_string(),
1945            // Tests in this section drive `run_register` directly with
1946            // a curated `Vec<ModelEntry>` — they're testing the
1947            // post-availability-filter logic, so every entry counts as
1948            // "available". The split-by-`plan_available` happens
1949            // upstream in the real `step_models_and_register`.
1950            plan_available: true,
1951            // The new wire-shape optional fields default to None/0 —
1952            // these tests only care about the model name and the
1953            // availability flag, so let them fall back to the
1954            // constants via `Default`.
1955            ..Default::default()
1956        }
1957    }
1958
1959    /// Helper that mirrors `step_models_and_register`'s wipe-and-insert
1960    /// + auto-detect body, sans network call. Tests the precedence logic
1961    /// in isolation.
1962    fn run_register(
1963        config: &mut Config,
1964        models: Vec<super::super::types::ModelEntry>,
1965    ) -> ModelsInfo {
1966        let stale: Vec<String> = config
1967            .providers
1968            .keys()
1969            .filter(|k| is_codingplan_provider_name(k))
1970            .cloned()
1971            .collect();
1972        for k in stale {
1973            config.providers.remove(&k);
1974        }
1975        let names: Vec<String> = models.iter().map(|m| m.display_model_name.clone()).collect();
1976        let provider_names = provider_names_for(&names);
1977        let default_provider = provider_names
1978            .first()
1979            .cloned()
1980            .unwrap_or_else(|| PROVIDER_PREFIX.to_string());
1981        for (pname, m) in provider_names.iter().zip(models.iter()) {
1982            config
1983                .providers
1984                .insert(pname.clone(), build_codingplan_provider(m));
1985        }
1986        config.default_provider = default_provider.clone();
1987
1988        let vl_idx = names
1989            .iter()
1990            .position(|n| crate::provider::model_name_suggests_vision(n));
1991        let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
1992        let vision_preprocessor = {
1993            let current = config.vision_preprocessor_provider.clone();
1994            let user_supplied_non_atomgit = current
1995                .as_deref()
1996                .map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
1997                .unwrap_or(false);
1998            if user_supplied_non_atomgit {
1999                VisionPreprocessorOutcome::UserSupplied(current.unwrap())
2000            } else {
2001                match new_vl_key {
2002                    Some(k) => {
2003                        config.vision_preprocessor_provider = Some(k.clone());
2004                        VisionPreprocessorOutcome::AutoSet(k)
2005                    }
2006                    None => {
2007                        if current.is_some() {
2008                            config.vision_preprocessor_provider = None;
2009                            VisionPreprocessorOutcome::Cleared
2010                        } else {
2011                            VisionPreprocessorOutcome::UnchangedNone
2012                        }
2013                    }
2014                }
2015            }
2016        };
2017
2018        ModelsInfo {
2019            display_names: names,
2020            provider_names,
2021            default_provider,
2022            vision_preprocessor,
2023            // Test helper doesn't exercise the locked-model rendering
2024            // path; mirror the input slice into all_models so the
2025            // shape stays consistent if any future assertion peeks.
2026            all_models: models,
2027        }
2028    }
2029
2030    #[test]
2031    fn vision_preprocessor_auto_set_when_none_and_list_has_vl() {
2032        let mut config = blank_config();
2033        let models = vec![
2034            vl_model_entry("moonshotai/Kimi-K2-Instruct"),
2035            vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
2036            vl_model_entry("deepseek/deepseek-v4-flash"),
2037        ];
2038        let info = run_register(&mut config, models);
2039        let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
2040        assert_eq!(
2041            info.vision_preprocessor,
2042            VisionPreprocessorOutcome::AutoSet(expected.clone())
2043        );
2044        assert_eq!(config.vision_preprocessor_provider, Some(expected));
2045    }
2046
2047    #[test]
2048    fn vision_preprocessor_unchanged_none_when_list_has_no_vl() {
2049        let mut config = blank_config();
2050        let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
2051        let info = run_register(&mut config, models);
2052        assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::UnchangedNone);
2053        assert_eq!(config.vision_preprocessor_provider, None);
2054    }
2055
2056    #[test]
2057    fn vision_preprocessor_overwrites_stale_atomgit_value() {
2058        let mut config = blank_config();
2059        config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
2060        let models = vec![
2061            vl_model_entry("Kimi-K2-Instruct"),
2062            vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
2063        ];
2064        let info = run_register(&mut config, models);
2065        let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
2066        assert_eq!(
2067            info.vision_preprocessor,
2068            VisionPreprocessorOutcome::AutoSet(expected.clone())
2069        );
2070        assert_eq!(config.vision_preprocessor_provider, Some(expected));
2071    }
2072
2073    #[test]
2074    fn vision_preprocessor_cleared_when_stale_atomgit_and_list_has_no_vl() {
2075        let mut config = blank_config();
2076        config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
2077        let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
2078        let info = run_register(&mut config, models);
2079        assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::Cleared);
2080        assert_eq!(config.vision_preprocessor_provider, None);
2081    }
2082
2083    #[test]
2084    fn vision_preprocessor_preserves_user_set_non_atomgit() {
2085        let mut config = blank_config();
2086        config.vision_preprocessor_provider = Some("Qwen3-VL-32B-Instruct".into());
2087        let models = vec![
2088            vl_model_entry("Kimi-K2-Instruct"),
2089            vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
2090        ];
2091        let info = run_register(&mut config, models);
2092        assert_eq!(
2093            info.vision_preprocessor,
2094            VisionPreprocessorOutcome::UserSupplied("Qwen3-VL-32B-Instruct".into())
2095        );
2096        assert_eq!(
2097            config.vision_preprocessor_provider.as_deref(),
2098            Some("Qwen3-VL-32B-Instruct")
2099        );
2100    }
2101
2102    #[test]
2103    fn vision_preprocessor_recognises_pure_ocr_model_name() {
2104        let mut config = blank_config();
2105        let models = vec![
2106            vl_model_entry("Kimi-K2-Instruct"),
2107            vl_model_entry("PaddleOCR-2.0"),
2108        ];
2109        let info = run_register(&mut config, models);
2110        let expected = "AtomGit-PaddleOCR-2.0".to_string();
2111        assert_eq!(
2112            info.vision_preprocessor,
2113            VisionPreprocessorOutcome::AutoSet(expected.clone())
2114        );
2115        assert_eq!(config.vision_preprocessor_provider, Some(expected));
2116    }
2117
2118    #[test]
2119    fn render_includes_vision_preprocessor_auto_set_line() {
2120        let report = SetupReport {
2121            login: StepResult::Skipped("already logged in".into()),
2122            claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2123            claim_attempts: Vec::new(),
2124            models: StepResult::Ok(ModelsInfo {
2125                display_names: vec![
2126                    "Kimi-K2-Instruct".into(),
2127                    "Qwen/Qwen3-VL-32B-Instruct".into(),
2128                ],
2129                provider_names: vec![
2130                    "AtomGit-Kimi-K2-Instruct".into(),
2131                    "AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
2132                ],
2133                default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2134                vision_preprocessor: VisionPreprocessorOutcome::AutoSet(
2135                    "AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
2136                ),
2137                all_models: vec![],
2138            }),
2139            status: StepResult::Skipped("status check skipped for this test".into()),
2140            auth_expired: false,
2141        };
2142        let out = report.render();
2143        assert!(
2144            out.contains("Vision preprocessor → AtomGit-Qwen-Qwen3-VL-32B-Instruct"),
2145            "render must include the auto-detected line: {out}",
2146        );
2147        assert!(out.contains("(auto-detected)"));
2148    }
2149
2150    #[test]
2151    fn render_includes_vision_preprocessor_cleared_line_when_stale_dropped() {
2152        let report = SetupReport {
2153            login: StepResult::Skipped("already logged in".into()),
2154            claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2155            claim_attempts: Vec::new(),
2156            models: StepResult::Ok(ModelsInfo {
2157                display_names: vec!["Kimi-K2-Instruct".into()],
2158                provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
2159                default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2160                vision_preprocessor: VisionPreprocessorOutcome::Cleared,
2161                all_models: vec![],
2162            }),
2163            status: StepResult::Skipped("test skip".into()),
2164            auth_expired: false,
2165        };
2166        let out = report.render();
2167        assert!(out.contains("Vision preprocessor cleared"));
2168    }
2169
2170    #[test]
2171    fn render_includes_vision_preprocessor_user_supplied_line() {
2172        let report = SetupReport {
2173            login: StepResult::Skipped("already logged in".into()),
2174            claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2175            claim_attempts: Vec::new(),
2176            models: StepResult::Ok(ModelsInfo {
2177                display_names: vec![
2178                    "Kimi-K2-Instruct".into(),
2179                    "Qwen/Qwen3-VL-32B-Instruct".into(),
2180                ],
2181                provider_names: vec![
2182                    "AtomGit-Kimi-K2-Instruct".into(),
2183                    "AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
2184                ],
2185                default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2186                vision_preprocessor: VisionPreprocessorOutcome::UserSupplied(
2187                    "Qwen3-VL-32B-Instruct".into(),
2188                ),
2189                all_models: vec![],
2190            }),
2191            status: StepResult::Skipped("test skip".into()),
2192            auth_expired: false,
2193        };
2194        let out = report.render();
2195        assert!(out.contains("Vision preprocessor → Qwen3-VL-32B-Instruct"));
2196        assert!(out.contains("(user setting kept)"));
2197    }
2198
2199    #[test]
2200    fn render_omits_vision_preprocessor_line_when_unchanged_none() {
2201        let report = SetupReport {
2202            login: StepResult::Skipped("already logged in".into()),
2203            claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2204            claim_attempts: Vec::new(),
2205            models: StepResult::Ok(ModelsInfo {
2206                display_names: vec!["Kimi-K2-Instruct".into()],
2207                provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
2208                default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2209                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
2210                all_models: vec![],
2211            }),
2212            status: StepResult::Skipped("test skip".into()),
2213            auth_expired: false,
2214        };
2215        let out = report.render();
2216        assert!(!out.contains("Vision preprocessor"));
2217    }
2218
2219    /// Locked models (plan_available=false on a higher tier) must
2220    /// surface in the rendered report with a distinctive `✗` prefix
2221    /// + the explicit "(requires Pro plan or higher)" suffix, the whole row
2222    /// wrapped in SGR 31 (terminal-theme red), and appended to the
2223    /// same `Added N provider(s)` bullet list as the available models
2224    /// so users see the full slate at a glance. Pins the v2 spec's
2225    /// "若不可用的模型也展示出来" requirement.
2226    ///
2227    /// Three layered signals — colour, prefix glyph, suffix text —
2228    /// because each can fail independently:
2229    ///   * SGR 31 only fires when the renderer's sanitizer keeps SGR
2230    ///     (alt_screen, plain) — retained's strict strip pathway
2231    ///     drops the colour but the glyph + text still carry the
2232    ///     meaning.
2233    ///   * The `✗` glyph relies on font support (every common
2234    ///     terminal font has it; this is the strongest of the three).
2235    ///   * The "(requires Pro plan or higher)" suffix is plain ASCII / CJK
2236    ///     and survives even font-fallback-tofu rendering.
2237    ///
2238    /// Earlier attempts at strikethrough (SGR 9 then U+0336
2239    /// combining mark) were both dropped — SGR 9 was eaten by the
2240    /// universal CSI sanitizer, and U+0336 was silently skipped by
2241    /// some fonts in the wild — so this test also pins that those
2242    /// markers do NOT regress back into the template.
2243    #[test]
2244    fn render_shows_locked_models_with_prefix_marker() {
2245        let avail = super::super::types::ModelEntry {
2246            id: 1,
2247            display_model_name: "lite/foo".into(),
2248            plan_available: true,
2249            ..Default::default()
2250        };
2251        let locked = super::super::types::ModelEntry {
2252            id: 2,
2253            display_model_name: "max/super-secret".into(),
2254            plan_available: false,
2255            ..Default::default()
2256        };
2257        let report = SetupReport {
2258            login: StepResult::Skipped("already logged in".into()),
2259            claim: StepResult::Ok(ClaimInfo {
2260                message: "claimed".into(),
2261                duplicate: false,
2262                plan_type: PlanType::Lite,
2263            }),
2264            claim_attempts: Vec::new(),
2265            models: StepResult::Ok(ModelsInfo {
2266                display_names: vec!["lite/foo".into()],
2267                provider_names: vec!["AtomGit".into()],
2268                default_provider: "AtomGit".into(),
2269                vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
2270                all_models: vec![avail, locked],
2271            }),
2272            status: StepResult::Skipped("test skip".into()),
2273            auth_expired: false,
2274        };
2275        let out = report.render();
2276        // Plan tier appears next to claim line.
2277        assert!(out.contains("(CodingPlan Lite)"), "claim row must show tier:\n{out}");
2278        // Available model: standard provider line.
2279        assert!(out.contains("AtomGit") && out.contains("lite/foo"));
2280        // Locked model: `✗` prefix immediately before the name, plus
2281        // the explicit `(requires Pro plan or higher)` suffix, all wrapped
2282        // in SGR 31 (red fg) → SGR 39 (default fg) so the terminal
2283        // renders the whole row in the theme's red.
2284        assert!(
2285            out.contains("\x1b[31m✗ max/super-secret"),
2286            "locked model must open with SGR 31 + ✗ prefix:\n{out}"
2287        );
2288        assert!(out.contains("(requires Pro plan or higher)\x1b[39m"));
2289        // Strikethrough is intentionally NOT used (SGR 9 was eaten by
2290        // the renderer's CSI sanitizer; U+0336 was font-dependent and
2291        // silently dropped on some setups). Lock those decisions in.
2292        assert!(
2293            !out.contains("\x1b[9m"),
2294            "locked-model line must not emit SGR 9 strikethrough:\n{out}"
2295        );
2296        assert!(
2297            !out.contains('\u{0336}'),
2298            "locked-model line must not emit U+0336 combining strikethrough:\n{out}"
2299        );
2300        // Locked model appears INSIDE the providers bullet list — its
2301        // line must come after the "Added N provider(s):" header and
2302        // before the next top-level section (Vision preprocessor /
2303        // CodingPlan status). The strikethrough + suffix already mark
2304        // it as unavailable; no separate "locked model" header.
2305        assert!(
2306            !out.contains("locked model"),
2307            "no separate locked-model section expected:\n{out}"
2308        );
2309        let added_idx = out.find("Added 1 provider").expect("Added header");
2310        let locked_idx = out.find("max/super-secret").expect("locked model line");
2311        let avail_idx = out.find("lite/foo").expect("available model line");
2312        assert!(
2313            locked_idx > added_idx,
2314            "locked model must render after the Added header:\n{out}"
2315        );
2316        assert!(
2317            locked_idx < avail_idx,
2318            "locked model must render BEFORE available providers (top-of-list upgrade prompt):\n{out}"
2319        );
2320    }
2321
2322}