use anyhow::Result;
use std::sync::Arc;
use super::client::{is_auth_expired, Client};
use super::types::{ModelEntry, PlanType, StatusResponse};
use crate::auth;
use crate::config::provider::ProviderConfig;
use crate::config::Config;
const DEFAULT_CODINGPLAN_LLM_BASE_URL: &str = "https://llm-api.atomgit.com/v1";
fn codingplan_llm_base_url() -> String {
use std::sync::OnceLock;
static URL: OnceLock<String> = OnceLock::new();
URL.get_or_init(|| {
std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
.ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_CODINGPLAN_LLM_BASE_URL.to_string())
})
.clone()
}
const PROVIDER_TYPE: &str = "openai";
const CONTEXT_WINDOW: usize = 64_000;
const PROVIDER_PREFIX: &str = "AtomGit";
#[derive(Debug, Clone)]
pub enum StepResult<T> {
Ok(T),
Skipped(String),
Err(String),
}
impl<T> StepResult<T> {
pub fn is_err(&self) -> bool {
matches!(self, StepResult::Err(_))
}
pub fn is_ok_or_skipped(&self) -> bool {
!self.is_err()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VisionPreprocessorOutcome {
UnchangedNone,
UserSupplied(String),
AutoSet(String),
Cleared,
}
impl SetupReport {
pub fn render(&self) -> String {
use crate::i18n::{t, Msg};
let mut out = String::new();
out.push_str(&t(Msg::CpSetupHeader));
match &self.login {
StepResult::Ok(info) => {
let who = info.display_name.as_deref().unwrap_or(&info.username);
let email = info.email.as_deref().unwrap_or("—");
out.push_str(&t(Msg::CpLoggedIn {
who,
username: &info.username,
email,
}));
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpStepSkipped { reason }));
}
StepResult::Err(msg) => {
out.push_str(&t(Msg::CpLoginFailed { error: msg }));
}
}
if !self.claim_attempts.is_empty() {
for attempt in &self.claim_attempts {
let tier = attempt.tier.as_str();
match &attempt.outcome {
TierOutcome::Claimed { .. } => {
out.push_str(&t(Msg::CpClaimTierSucceeded { tier }));
}
TierOutcome::AlreadyHeld { .. } => {
out.push_str(&t(Msg::CpClaimTierAlreadyHeld { tier }));
}
TierOutcome::Refused { message } => {
let reason = if message.is_empty() {
"(no reason given)"
} else {
message.as_str()
};
out.push_str(&t(Msg::CpClaimTierFailed { tier, reason }));
}
TierOutcome::Errored { error } => {
let reason = truncate_inline(error, 150);
out.push_str(&t(Msg::CpClaimTierFailed {
tier,
reason: &reason,
}));
}
}
}
} else {
match &self.claim {
StepResult::Ok(info) => {
let fallback = t(Msg::CpClaimSuccessFallback);
let message = if info.message.is_empty() {
fallback.as_ref()
} else {
info.message.as_str()
};
out.push_str(&t(Msg::CpClaimed {
message,
plan_type: info.plan_type.as_str(),
}));
}
StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpAlreadyClaimed { reason }));
}
StepResult::Err(msg) => {
out.push_str(&t(Msg::CpClaimFailed { error: msg }));
}
}
}
match &self.models {
StepResult::Ok(info) => {
let count = info.provider_names.len();
let plural_s = if count == 1 { "" } else { "s" };
out.push_str(&t(Msg::CpAddedProviders { count, plural_s }));
let registered: std::collections::HashSet<&str> =
info.display_names.iter().map(|s| s.as_str()).collect();
let locked: Vec<&ModelEntry> = info
.all_models
.iter()
.filter(|m| !m.plan_available && !registered.contains(m.display_model_name.as_str()))
.collect();
for m in &locked {
out.push_str(&t(Msg::CpLocked {
name: &m.display_model_name,
}));
}
let default_suffix_cow = t(Msg::CpDefaultSuffix);
for (pname, model) in info.provider_names.iter().zip(info.display_names.iter()) {
let suffix = if pname == &info.default_provider {
default_suffix_cow.as_ref()
} else {
""
};
out.push_str(&t(Msg::CpProviderRow {
provider: pname,
model,
default_suffix: suffix,
}));
}
match &info.vision_preprocessor {
VisionPreprocessorOutcome::AutoSet(k) => {
out.push_str(&t(Msg::CpVisionAuto { kind: k }));
}
VisionPreprocessorOutcome::UserSupplied(k) => {
out.push_str(&t(Msg::CpVisionUserSupplied { kind: k }));
}
VisionPreprocessorOutcome::Cleared => {
out.push_str(&t(Msg::CpVisionCleared));
}
VisionPreprocessorOutcome::UnchangedNone => {
}
}
}
StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpModelsSkipped { reason }));
}
StepResult::Err(msg) => {
out.push_str(&t(Msg::CpModelsFailed { error: msg }));
}
}
match &self.status {
StepResult::Ok(s) => {
out.push_str(&t(Msg::CpStatusHeader));
if let Some(plan) = &s.codingplan_free {
if plan.expires_at.is_empty() {
out.push_str(&t(Msg::CpPlanPending { plan: &plan.plan_name }));
} else {
out.push_str(&t(Msg::CpPlanActive {
plan: &plan.plan_name,
expires_at: &plan.expires_at,
remaining_days: plan.remaining_days,
total_days: plan.total_days,
}));
}
}
if let Some(u) = &s.current_usage {
out.push_str(&t(Msg::CpUsageLine {
usage: &u.display_desc(),
reset_at: &u.reset_at_display,
duration: &format_duration_secs(u.seconds_until_reset),
}));
}
if s.window_quota_exhausted {
if let Some(hint) = &s.window_quota_hint {
out.push_str(&t(Msg::CpWindowQuotaHint { hint }));
} else {
out.push_str(&t(Msg::CpWindowQuotaExhausted));
}
}
}
StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpStatusFetchSkipped { reason }));
}
StepResult::Err(msg) => {
out.push_str(&t(Msg::CpStatusFetchFailed {
error: &truncate_inline(msg, 150),
}));
}
}
out
}
pub fn should_persist_config(&self) -> bool {
self.login.is_ok_or_skipped()
&& self.claim.is_ok_or_skipped()
&& self.models.is_ok_or_skipped()
}
}
#[derive(Debug, Clone)]
pub struct SetupReport {
pub login: StepResult<LoginInfo>,
pub claim: StepResult<ClaimInfo>,
pub claim_attempts: Vec<TierAttempt>,
pub models: StepResult<ModelsInfo>,
pub status: StepResult<StatusResponse>,
pub auth_expired: bool,
}
#[derive(Debug, Clone)]
pub struct LoginInfo {
pub username: String,
pub display_name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ClaimInfo {
pub message: String,
pub duplicate: bool,
pub plan_type: PlanType,
}
#[derive(Debug, Clone)]
pub enum TierOutcome {
Claimed { message: String },
AlreadyHeld { message: String },
Refused { message: String },
Errored { error: String },
}
#[derive(Debug, Clone)]
pub struct TierAttempt {
pub tier: PlanType,
pub outcome: TierOutcome,
}
#[derive(Debug, Clone)]
pub struct ModelsInfo {
pub display_names: Vec<String>,
pub provider_names: Vec<String>,
pub default_provider: String,
pub vision_preprocessor: VisionPreprocessorOutcome,
pub all_models: Vec<ModelEntry>,
}
pub fn run(
config: &mut Config,
tel: Option<&Arc<atomcode_telemetry::Telemetry>>,
) -> Result<SetupReport> {
let login = step_login(tel);
if login.is_err() {
if let Some(t) = tel {
t.track(atomcode_telemetry::Event::TakeCodingplan {
type_: atomcode_telemetry::CodingplanResult::Fail,
error_kind: Some(atomcode_telemetry::CodingplanErrorKind::AuthError),
error_data: Some(serde_json::json!({
"step": "login",
"message": "Not logged in",
}).to_string()),
});
}
return Ok(SetupReport {
login,
claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
});
}
let (claim, claim_attempts, claim_auth_expired) = step_claim();
if claim.is_err() {
return Ok(SetupReport {
login,
claim,
claim_attempts,
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: claim_auth_expired,
});
}
let plan_type_for_models = match &claim {
StepResult::Ok(info) => info.plan_type,
_ => PlanType::Max,
};
let (models, models_auth_expired) = step_models_and_register(config, plan_type_for_models);
if models.is_err() {
if let Some(t) = tel {
t.track(atomcode_telemetry::Event::TakeCodingplan {
type_: atomcode_telemetry::CodingplanResult::Fail,
error_kind: Some(atomcode_telemetry::CodingplanErrorKind::NetworkError),
error_data: Some(serde_json::json!({
"step": "models",
"message": "Failed to fetch model list",
}).to_string()),
});
}
return Ok(SetupReport {
login,
claim,
claim_attempts,
models,
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: models_auth_expired,
});
}
let (status, status_auth_expired) = step_status();
if let Some(t) = tel {
t.track(atomcode_telemetry::Event::TakeCodingplan {
type_: atomcode_telemetry::CodingplanResult::Success,
error_kind: None,
error_data: Some(serde_json::json!({
"step": null,
}).to_string()),
});
}
Ok(SetupReport {
login,
claim,
claim_attempts,
models,
status,
auth_expired: status_auth_expired,
})
}
const CASCADE_FROM_UPSTREAM_FAIL: &str = "__cascade_upstream_fail__";
fn step_login(tel: Option<&Arc<atomcode_telemetry::Telemetry>>) -> StepResult<LoginInfo> {
if auth::is_logged_in() {
if let Some(info) = auth::get_stored_auth() {
let display = match info.user.name.as_deref() {
Some(name) if !name.is_empty() && name != info.user.username => {
format!("{}({})", name, info.user.username)
}
_ => info.user.username.clone(),
};
return StepResult::Skipped(format!("already logged in as {}", display));
}
return StepResult::Skipped("already logged in".into());
}
match auth::login(tel).and_then(|a| auth::save_auth(&a).map(|_| a)) {
Ok(auth_info) => StepResult::Ok(LoginInfo {
username: auth_info.user.username.clone(),
display_name: auth_info.user.name.clone(),
email: auth_info.user.email.clone(),
}),
Err(e) => StepResult::Err(format!("login failed: {:#}", e)),
}
}
fn step_claim() -> (StepResult<ClaimInfo>, Vec<TierAttempt>, bool) {
let client = match Client::from_stored_auth() {
Ok(c) => c,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("build client: {:#}", e)),
Vec::new(),
auth_expired,
);
}
};
let mut attempts: Vec<TierAttempt> = Vec::with_capacity(PlanType::CASCADE_ORDER.len());
let mut last_msg = String::new();
for &tier in PlanType::CASCADE_ORDER {
match client.claim_v2(tier) {
Ok(resp) => {
if resp.duplicate {
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::AlreadyHeld {
message: resp.message.clone(),
},
});
let skipped = StepResult::Skipped(if resp.message.is_empty() {
format!(
"already claimed (or under review) — using {}",
tier.as_str()
)
} else {
format!("{} ({})", resp.message, tier.as_str())
});
return (skipped, attempts, false);
}
if resp.success {
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::Claimed {
message: resp.message.clone(),
},
});
let ok = StepResult::Ok(ClaimInfo {
message: if resp.message.is_empty() {
format!("claimed {}", tier.as_str())
} else {
resp.message
},
duplicate: false,
plan_type: tier,
});
return (ok, attempts, false);
}
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::Refused {
message: resp.message.clone(),
},
});
last_msg = if resp.message.is_empty() {
format!("{} claim refused", tier.as_str())
} else {
format!("{}: {}", tier.as_str(), resp.message)
};
}
Err(e) => {
let auth_expired = is_auth_expired(&e);
let err_text = format!("{:#}", e);
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::Errored {
error: err_text.clone(),
},
});
return (
StepResult::Err(format!("claim {} request: {}", tier.as_str(), err_text)),
attempts,
auth_expired,
);
}
}
}
let overall = StepResult::Err(if last_msg.is_empty() {
"claim failed at every tier (Max/Pro/Lite)".into()
} else {
format!("claim failed at every tier — {}", last_msg)
});
(overall, attempts, false)
}
fn step_models_and_register(
config: &mut Config,
plan_type: PlanType,
) -> (StepResult<ModelsInfo>, bool) {
let client = match Client::from_stored_auth() {
Ok(c) => c,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("build client: {:#}", e)),
auth_expired,
);
}
};
let all_models = match client.list_models_v2(plan_type) {
Ok(v) => v,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("list models-v2: {:#}", e)),
auth_expired,
);
}
};
if all_models.is_empty() {
return (
StepResult::Err(
"server returned an empty model list — cannot set up any provider".into(),
),
false,
);
}
let available: Vec<&ModelEntry> = all_models.iter().filter(|m| m.plan_available).collect();
if available.is_empty() {
return (
StepResult::Err(format!(
"no models available on plan {} — server returned {} locked entries",
plan_type.as_str(),
all_models.len()
)),
false,
);
}
let stale: Vec<String> = config
.providers
.keys()
.filter(|k| is_codingplan_provider_name(k))
.cloned()
.collect();
for k in stale {
config.providers.remove(&k);
}
let names: Vec<String> = available
.iter()
.map(|m| m.display_model_name.clone())
.collect();
let provider_names = provider_names_for(&names);
let default_provider = provider_names
.first()
.cloned()
.unwrap_or_else(|| PROVIDER_PREFIX.to_string());
for (pname, m) in provider_names.iter().zip(available.iter()) {
let pc = build_codingplan_provider(m);
config.providers.insert(pname.clone(), pc);
}
config.default_provider = default_provider.clone();
let vl_idx = names
.iter()
.position(|n| crate::provider::model_name_suggests_vision(n));
let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
let vision_preprocessor = {
let current = config.vision_preprocessor_provider.clone();
let user_supplied_non_atomgit = current
.as_deref()
.map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
.unwrap_or(false);
if user_supplied_non_atomgit {
VisionPreprocessorOutcome::UserSupplied(current.unwrap())
} else {
match new_vl_key {
Some(k) => {
config.vision_preprocessor_provider = Some(k.clone());
VisionPreprocessorOutcome::AutoSet(k)
}
None => {
if current.is_some() {
config.vision_preprocessor_provider = None;
VisionPreprocessorOutcome::Cleared
} else {
VisionPreprocessorOutcome::UnchangedNone
}
}
}
}
};
(
StepResult::Ok(ModelsInfo {
display_names: names,
provider_names,
default_provider,
vision_preprocessor,
all_models,
}),
false,
)
}
fn step_status() -> (StepResult<StatusResponse>, bool) {
let client = match Client::from_stored_auth() {
Ok(c) => c,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("build client: {:#}", e)),
auth_expired,
);
}
};
match client.status_v2() {
Ok(s) => (StepResult::Ok(s), false),
Err(e) => {
let auth_expired = is_auth_expired(&e);
(
StepResult::Err(format!("status-v2: {:#}", e)),
auth_expired,
)
}
}
}
fn truncate_inline(msg: &str, max: usize) -> String {
if msg.chars().count() <= max {
return msg.to_string();
}
let mut out: String = msg.chars().take(max).collect();
out.push('…');
out
}
fn format_duration_secs(secs: i64) -> String {
if secs < 0 {
return "—".into();
}
let s = secs as u64;
if s < 60 {
return format!("{}s", s);
}
let (m, sr) = (s / 60, s % 60);
if m < 60 {
return if sr == 0 { format!("{}m", m) } else { format!("{}m {}s", m, sr) };
}
let (h, mr) = (m / 60, m % 60);
if h < 24 {
return if mr == 0 { format!("{}h", h) } else { format!("{}h {}m", h, mr) };
}
let (d, hr) = (h / 24, h % 24);
if hr == 0 { format!("{}d", d) } else { format!("{}d {}h", d, hr) }
}
fn provider_names_for(model_names: &[String]) -> Vec<String> {
if model_names.len() == 1 {
vec![PROVIDER_PREFIX.to_string()]
} else {
model_names
.iter()
.map(|m| format!("{}-{}", PROVIDER_PREFIX, sanitize_model_for_name(m)))
.collect()
}
}
fn sanitize_model_for_name(model: &str) -> String {
model.replace('/', "-")
}
fn is_codingplan_provider_name(name: &str) -> bool {
name == PROVIDER_PREFIX || name.starts_with(&format!("{}-", PROVIDER_PREFIX))
}
fn build_codingplan_provider(entry: &ModelEntry) -> ProviderConfig {
ProviderConfig {
provider_type: entry
.provider_type
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| PROVIDER_TYPE.to_string()),
api_key: None,
model: entry.display_model_name.clone(),
base_url: Some(
entry
.base_url
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(codingplan_llm_base_url),
),
system_prompt: None,
user_agent: None,
context_window: entry
.context_window
.filter(|n| *n > 0)
.unwrap_or(CONTEXT_WINDOW),
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn entry(display_model_name: &str) -> super::super::types::ModelEntry {
super::super::types::ModelEntry {
display_model_name: display_model_name.to_string(),
plan_available: true,
..Default::default()
}
}
fn blank_config() -> Config {
Config {
default_provider: String::new(),
default_workdir: None,
providers: HashMap::new(),
datalog: Default::default(),
auto_update: true,
notifications: Default::default(),
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
}
}
#[test]
fn single_model_uses_bare_prefix() {
let names = vec!["moonshotai/Kimi-K2-Instruct".into()];
let p = provider_names_for(&names);
assert_eq!(p, vec!["AtomGit".to_string()]);
}
#[test]
fn multiple_models_expand_to_prefix_suffixes() {
let names = vec![
"moonshotai/Kimi-K2-Instruct".into(),
"anthropic/claude-3.5-sonnet".into(),
"openai/gpt-5".into(),
];
let p = provider_names_for(&names);
assert_eq!(
p,
vec![
"AtomGit-moonshotai-Kimi-K2-Instruct".to_string(),
"AtomGit-anthropic-claude-3.5-sonnet".to_string(),
"AtomGit-openai-gpt-5".to_string(),
]
);
}
#[test]
fn sanitize_replaces_slash_only() {
assert_eq!(
sanitize_model_for_name("anthropic/claude-3.5-sonnet"),
"anthropic-claude-3.5-sonnet"
);
}
#[test]
fn is_codingplan_name_matches_prefix_and_exact() {
assert!(is_codingplan_provider_name("AtomGit"));
assert!(is_codingplan_provider_name("AtomGit-foo"));
assert!(is_codingplan_provider_name("AtomGit-moonshotai-Kimi-K2"));
assert!(!is_codingplan_provider_name("AtomGitPlus"));
assert!(!is_codingplan_provider_name("atomgit")); assert!(!is_codingplan_provider_name("claude"));
}
#[test]
fn step_models_wipes_stale_atomgit_entries() {
let mut config = blank_config();
config.providers.insert(
"AtomGit".to_string(),
build_codingplan_provider(&entry("stale-MiniMax")),
);
config.providers.insert(
"AtomGit-legacy".to_string(),
build_codingplan_provider(&entry("another-stale")),
);
config.providers.insert(
"claude".to_string(),
build_codingplan_provider(&entry("anthropic/claude-3.5")),
);
let names = vec!["meta-llama/Llama-3-70B".to_string()];
let stale: Vec<String> = config
.providers
.keys()
.filter(|k| is_codingplan_provider_name(k))
.cloned()
.collect();
for k in stale {
config.providers.remove(&k);
}
let provider_names = provider_names_for(&names);
for (pname, m) in provider_names.iter().zip(names.iter()) {
config
.providers
.insert(pname.clone(), build_codingplan_provider(&entry(m)));
}
config.default_provider = provider_names[0].clone();
assert_eq!(config.providers.len(), 2, "claude + one fresh AtomGit");
assert!(
config.providers.contains_key("claude"),
"unrelated entry kept"
);
assert!(
config.providers.contains_key("AtomGit"),
"fresh AtomGit added"
);
assert!(
!config.providers.contains_key("AtomGit-legacy"),
"stale removed"
);
let fresh = &config.providers["AtomGit"];
assert_eq!(fresh.model, "meta-llama/Llama-3-70B");
assert_eq!(
fresh.base_url.as_deref(),
Some(codingplan_llm_base_url().as_str())
);
assert_eq!(fresh.provider_type, PROVIDER_TYPE);
assert_eq!(config.default_provider, "AtomGit");
}
#[test]
fn codingplan_llm_base_url_defaults_to_new_signed_gateway() {
let actual = codingplan_llm_base_url();
let env_override = std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
.ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty());
if let Some(want) = env_override {
assert_eq!(actual, want, "env override must win when set");
} else {
assert_eq!(
actual, "https://llm-api.atomgit.com/v1",
"default must point at the new signed gateway (NOT legacy api-ai.gitcode.com); \
otherwise codingplan signing never engages"
);
}
}
#[test]
fn build_provider_uses_canonical_defaults() {
let p = build_codingplan_provider(&entry("foo/bar"));
assert_eq!(p.provider_type, "openai");
assert_eq!(
p.base_url.as_deref(),
Some(codingplan_llm_base_url().as_str())
);
assert_eq!(p.context_window, 64_000);
assert!(
p.api_key.is_none(),
"token loaded at runtime from auth.toml"
);
assert!(!p.ephemeral);
}
#[test]
fn build_provider_uses_server_overrides_when_present() {
let e = super::super::types::ModelEntry {
id: 2052994857682014210,
is_infinity: 2,
is_atomcode_exclusive: 1,
display_model_name: "GLM-5.1".into(),
base_url: Some("https://custom.example.com/v1".into()),
provider_type: Some("claude".into()),
context_window: Some(128_000),
plan_available: true,
};
let p = build_codingplan_provider(&e);
assert_eq!(p.model, "GLM-5.1");
assert_eq!(p.provider_type, "claude");
assert_eq!(p.base_url.as_deref(), Some("https://custom.example.com/v1"));
assert_eq!(p.context_window, 128_000);
}
#[test]
fn build_provider_treats_empty_or_zero_overrides_as_missing() {
let e = super::super::types::ModelEntry {
display_model_name: "weird".into(),
base_url: Some(String::new()),
provider_type: Some(String::new()),
context_window: Some(0),
plan_available: true,
..Default::default()
};
let p = build_codingplan_provider(&e);
assert_eq!(p.provider_type, "openai");
assert_eq!(
p.base_url.as_deref(),
Some(codingplan_llm_base_url().as_str())
);
assert_eq!(p.context_window, 64_000);
}
#[test]
fn model_entry_deserialises_new_wire_shape() {
let raw = r#"[{
"id": 2052994857682014210,
"is_infinity": 2,
"is_atomcode_exclusive": 1,
"display_model_name": "GLM-5.1",
"base_url": "https://api-ai.gitcode.com/v1",
"type": "openai",
"context_window": 64000,
"plan_available": true
}]"#;
let list: Vec<super::super::types::ModelEntry> =
serde_json::from_str(raw).expect("payload deserialises");
assert_eq!(list.len(), 1);
let m = &list[0];
assert_eq!(m.id, 2052994857682014210);
assert_eq!(m.is_infinity, 2);
assert_eq!(m.is_atomcode_exclusive, 1);
assert_eq!(m.display_model_name, "GLM-5.1");
assert_eq!(m.base_url.as_deref(), Some("https://api-ai.gitcode.com/v1"));
assert_eq!(m.provider_type.as_deref(), Some("openai"));
assert_eq!(m.context_window, Some(64_000));
assert!(m.plan_available);
}
#[test]
fn model_entry_deserialises_legacy_wire_shape() {
let raw = r#"[{
"id": 1,
"is_atomcode_exclusive": 0,
"display_model_name": "legacy/model",
"plan_available": true
}]"#;
let list: Vec<super::super::types::ModelEntry> =
serde_json::from_str(raw).expect("legacy payload deserialises");
let m = &list[0];
assert_eq!(m.display_model_name, "legacy/model");
assert!(m.base_url.is_none());
assert!(m.provider_type.is_none());
assert!(m.context_window.is_none());
assert_eq!(m.is_infinity, 0);
}
#[test]
fn render_happy_path_has_all_checkmarks() {
let report = SetupReport {
login: StepResult::Ok(LoginInfo {
username: "theo".into(),
display_name: Some("Theo".into()),
email: Some("theo@example.com".into()),
}),
claim: StepResult::Ok(ClaimInfo {
message: "领取成功".into(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["moonshotai/Kimi-K2-Instruct".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
codingplan_free: Some(crate::coding_plan::types::PlanInfo {
plan_name: "CodingPlan Free".into(),
status: 1,
claimed_at: "2026-04-22".into(),
expires_at: "2026-05-22".into(),
remaining_days: 29,
total_days: 30,
apply_id: 1,
}),
current_usage: Some(crate::coding_plan::types::UsageInfo {
placeholder: false,
window_token_limit: 50000,
window_tokens_used: 0,
usage_percent: 0.0,
window_hours: 1,
reset_at: "2026-04-23T12:13:14".into(),
reset_at_display: "12:13".into(),
seconds_until_reset: 693,
reset_label: String::new(),
usage_status_desc: String::new(),
}),
audit_status: 1,
expires_at: Some("2026-05-22".into()),
window_quota_exhausted: false,
window_quota_hint: None,
}),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("✓ Logged in as Theo"));
assert!(out.contains("theo@example.com"));
assert!(out.contains("CodingPlan claimed"));
assert!(out.contains("Kimi-K2-Instruct"));
assert!(out.contains("AtomGit"));
assert!(out.contains("(default)"));
assert!(out.contains("CodingPlan Free"));
assert!(out.contains("12:13"));
assert!(report.should_persist_config());
}
#[test]
fn render_claim_duplicate_renders_as_success() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Skipped("already claimed / in review".into()),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err("request timeout".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("✓ already logged in"));
assert!(out.contains("already claimed"));
assert!(!out.contains("✗ CodingPlan claim"), "duplicate ≠ failure");
assert!(out.contains("⚠ Status fetch failed"));
assert!(!out.contains("✗ Status"));
assert!(report.should_persist_config());
}
#[test]
fn render_status_pending_activation_omits_zero_expiry() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo {
message: "claimed".into(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
codingplan_free: Some(crate::coding_plan::types::PlanInfo {
plan_name: "CodingPlan Free".into(),
status: 0,
claimed_at: String::new(),
expires_at: String::new(),
remaining_days: 0,
total_days: 0,
apply_id: 0,
}),
current_usage: None,
audit_status: 0,
expires_at: None,
window_quota_exhausted: false,
window_quota_hint: None,
}),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Plan: CodingPlan Free"), "plan name still shown: {}", out);
assert!(
out.contains("pending activation"),
"must surface pending state to user: {}",
out
);
assert!(
!out.contains("(0d / 0d"),
"bogus zero countdown must not render: {}",
out
);
assert!(
!out.contains("expires ("),
"empty expires-date with double space must not render: {}",
out
);
}
#[test]
fn render_login_failed_blocks_persist_and_suppresses_cascade() {
let report = SetupReport {
login: StepResult::Err("browser handshake timed out".into()),
claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("✗ Login failed"));
assert!(!out.contains("CodingPlan claim"), "no cascade claim row on login fail");
assert!(!out.contains("Models step"), "no cascade models row on login fail");
assert!(!out.contains("Status fetch"), "no cascade status row on login fail");
assert!(
!report.should_persist_config(),
"don't write config on login failure"
);
}
#[test]
fn claim_err_blocks_persist() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err(
"claim Pro request: claim-v2 returned 500 Internal Server Error \
— Transaction rolled back because it has been marked as rollback-only"
.into(),
),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
assert!(
!report.should_persist_config(),
"claim Err must block save_and_reload — config rewrite was overwriting \
manual edits between TUI startup and /codingplan",
);
let dup = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Skipped("already claimed / using Max".into()),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err("status fetch timeout".into()),
auth_expired: false,
};
assert!(
dup.should_persist_config(),
"duplicate-claim Skipped must still allow persist (it's the model-sync path)",
);
}
#[test]
fn auth_expired_alone_does_not_change_persist_gate() {
let allow = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Skipped("already claimed".into()),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Skipped("ok".into()),
auth_expired: true,
};
assert!(
allow.should_persist_config(),
"auth_expired must not gate persist when every critical step \
is ok/skipped — it's a side-channel for retry, not safety",
);
let block = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err("auth failed".into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: true,
};
assert!(
!block.should_persist_config(),
"claim Err already blocks persist — auth_expired doesn't \
relax it",
);
}
#[test]
fn render_per_tier_cascade_shows_every_attempt() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as Code_dh".into()),
claim: StepResult::Ok(ClaimInfo {
message: "claimed".into(),
duplicate: false,
plan_type: PlanType::Lite,
}),
claim_attempts: vec![
TierAttempt {
tier: PlanType::Max,
outcome: TierOutcome::Refused {
message: "额度已满".into(),
},
},
TierAttempt {
tier: PlanType::Pro,
outcome: TierOutcome::Refused {
message: "额度已满".into(),
},
},
TierAttempt {
tier: PlanType::Lite,
outcome: TierOutcome::Claimed {
message: "领取成功".into(),
},
},
],
models: StepResult::Skipped("models step not exercised here".into()),
status: StepResult::Skipped("status not exercised here".into()),
auth_expired: false,
};
let out = report.render();
assert!(
out.contains("CodingPlan Max 领取失败 — 额度已满")
|| out.contains("CodingPlan Max claim failed — 额度已满"),
"Max refusal row missing: {}",
out
);
assert!(
out.contains("CodingPlan Pro 领取失败 — 额度已满")
|| out.contains("CodingPlan Pro claim failed — 额度已满"),
"Pro refusal row missing: {}",
out
);
assert!(
out.contains("CodingPlan Lite 领取成功")
|| out.contains("CodingPlan Lite claimed"),
"Lite success row missing: {}",
out
);
assert!(
!out.contains("CodingPlan claimed"),
"legacy claim-summary row must be suppressed when per-tier rows present: {}",
out
);
}
#[test]
fn render_per_tier_cascade_all_refused() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err(
"claim failed at every tier — Lite: 暂无开放".into(),
),
claim_attempts: vec![
TierAttempt {
tier: PlanType::Max,
outcome: TierOutcome::Refused {
message: "暂无开放".into(),
},
},
TierAttempt {
tier: PlanType::Pro,
outcome: TierOutcome::Refused {
message: "暂无开放".into(),
},
},
TierAttempt {
tier: PlanType::Lite,
outcome: TierOutcome::Refused {
message: "暂无开放".into(),
},
},
],
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
for tier in &["Max", "Pro", "Lite"] {
let zh = format!("CodingPlan {} 领取失败 — 暂无开放", tier);
let en = format!("CodingPlan {} claim failed — 暂无开放", tier);
assert!(
out.contains(&zh) || out.contains(&en),
"{} refusal row missing: {}",
tier,
out
);
}
assert!(
!out.contains("claim failed at every tier"),
"legacy err-summary row must not appear: {}",
out
);
assert!(
!out.contains("Models step"),
"cascade-from-claim-fail must hide models row: {}",
out
);
}
#[test]
fn render_per_tier_cascade_with_errored_tier_truncates_long_message() {
let long_err = "x".repeat(500);
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err(format!("claim Max request: {}", long_err)),
claim_attempts: vec![TierAttempt {
tier: PlanType::Max,
outcome: TierOutcome::Errored {
error: long_err.clone(),
},
}],
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
assert!(
out.contains("CodingPlan Max 领取失败 —")
|| out.contains("CodingPlan Max claim failed —"),
"Max errored row missing: {}",
out
);
assert!(
!out.contains(&long_err),
"long error must be truncated, not pasted whole: {}",
out
);
}
#[test]
fn render_multi_model_lists_all_providers_with_default_mark() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Ok(ClaimInfo {
message: String::new(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec![
"moonshotai/Kimi-K2-Instruct".into(),
"anthropic/claude-3.5-sonnet".into(),
"openai/gpt-5".into(),
],
provider_names: vec![
"AtomGit-moonshotai-Kimi-K2-Instruct".into(),
"AtomGit-anthropic-claude-3.5-sonnet".into(),
"AtomGit-openai-gpt-5".into(),
],
default_provider: "AtomGit-moonshotai-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err("status endpoint 500".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Added 3 providers"));
assert!(out.contains(
"AtomGit-moonshotai-Kimi-K2-Instruct → moonshotai/Kimi-K2-Instruct (default)"
));
assert!(
out.contains("AtomGit-anthropic-claude-3.5-sonnet → anthropic/claude-3.5-sonnet\n")
);
assert!(
!out.contains("anthropic/claude-3.5-sonnet (default)"),
"only first is default"
);
}
#[test]
fn render_claim_failed_suppresses_cascade_rows() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Err("今日codingplan申请额度已满,请明天再试".into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("✗ CodingPlan claim failed"));
assert!(out.contains("今日codingplan申请额度已满"));
assert!(!out.contains("Models step skipped"), "no cascade row for models");
assert!(!out.contains("Status fetch skipped"), "no cascade row for status");
assert!(!out.contains("Added "), "must not say 'Added N providers' on claim fail");
assert!(!out.contains("invalid type: null"));
assert!(!out.contains("plan_name"));
}
#[test]
fn render_skipped_with_non_cascade_reason_still_shows() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Skipped("already claimed".into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped("models cached locally".into()),
status: StepResult::Skipped("server returned 503; using cached".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Models step skipped — models cached locally"));
assert!(out.contains("Status fetch skipped — server returned 503"));
}
#[test]
fn render_status_error_truncates_long_message() {
let huge = format!(
"status: parse status response (body: {}): invalid type",
"x".repeat(1000),
);
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo {
message: "ok".into(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err(huge),
auth_expired: false,
};
let out = report.render();
let line = out.lines().find(|l| l.contains("Status fetch failed")).unwrap();
assert!(line.chars().count() < 250, "line still ~{} chars long", line.chars().count());
assert!(line.contains('…'), "truncation marker present");
}
#[test]
fn format_duration_secs_human_readable() {
assert_eq!(format_duration_secs(0), "0s");
assert_eq!(format_duration_secs(45), "45s");
assert_eq!(format_duration_secs(60), "1m");
assert_eq!(format_duration_secs(90), "1m 30s");
assert_eq!(format_duration_secs(3600), "1h");
assert_eq!(format_duration_secs(3660), "1h 1m");
assert_eq!(format_duration_secs(86400), "1d");
assert_eq!(format_duration_secs(90060), "1d 1h");
assert_eq!(format_duration_secs(-1), "—");
}
#[test]
fn truncate_inline_passes_short_strings_through() {
assert_eq!(truncate_inline("short", 10), "short");
assert_eq!(truncate_inline("exactly_ten", 11), "exactly_ten");
}
#[test]
fn truncate_inline_appends_ellipsis_when_long() {
let r = truncate_inline("abcdefghijklmnop", 5);
assert_eq!(r, "abcde…");
}
#[test]
fn truncate_inline_handles_unicode_safely() {
let r = truncate_inline("一二三四五六七八", 5);
assert_eq!(r, "一二三四五…");
}
fn vl_model_entry(model: &str) -> super::super::types::ModelEntry {
super::super::types::ModelEntry {
id: 1,
display_model_name: model.to_string(),
plan_available: true,
..Default::default()
}
}
fn run_register(
config: &mut Config,
models: Vec<super::super::types::ModelEntry>,
) -> ModelsInfo {
let stale: Vec<String> = config
.providers
.keys()
.filter(|k| is_codingplan_provider_name(k))
.cloned()
.collect();
for k in stale {
config.providers.remove(&k);
}
let names: Vec<String> = models.iter().map(|m| m.display_model_name.clone()).collect();
let provider_names = provider_names_for(&names);
let default_provider = provider_names
.first()
.cloned()
.unwrap_or_else(|| PROVIDER_PREFIX.to_string());
for (pname, m) in provider_names.iter().zip(models.iter()) {
config
.providers
.insert(pname.clone(), build_codingplan_provider(m));
}
config.default_provider = default_provider.clone();
let vl_idx = names
.iter()
.position(|n| crate::provider::model_name_suggests_vision(n));
let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
let vision_preprocessor = {
let current = config.vision_preprocessor_provider.clone();
let user_supplied_non_atomgit = current
.as_deref()
.map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
.unwrap_or(false);
if user_supplied_non_atomgit {
VisionPreprocessorOutcome::UserSupplied(current.unwrap())
} else {
match new_vl_key {
Some(k) => {
config.vision_preprocessor_provider = Some(k.clone());
VisionPreprocessorOutcome::AutoSet(k)
}
None => {
if current.is_some() {
config.vision_preprocessor_provider = None;
VisionPreprocessorOutcome::Cleared
} else {
VisionPreprocessorOutcome::UnchangedNone
}
}
}
}
};
ModelsInfo {
display_names: names,
provider_names,
default_provider,
vision_preprocessor,
all_models: models,
}
}
#[test]
fn vision_preprocessor_auto_set_when_none_and_list_has_vl() {
let mut config = blank_config();
let models = vec![
vl_model_entry("moonshotai/Kimi-K2-Instruct"),
vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
vl_model_entry("deepseek/deepseek-v4-flash"),
];
let info = run_register(&mut config, models);
let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::AutoSet(expected.clone())
);
assert_eq!(config.vision_preprocessor_provider, Some(expected));
}
#[test]
fn vision_preprocessor_unchanged_none_when_list_has_no_vl() {
let mut config = blank_config();
let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
let info = run_register(&mut config, models);
assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::UnchangedNone);
assert_eq!(config.vision_preprocessor_provider, None);
}
#[test]
fn vision_preprocessor_overwrites_stale_atomgit_value() {
let mut config = blank_config();
config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
let models = vec![
vl_model_entry("Kimi-K2-Instruct"),
vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
];
let info = run_register(&mut config, models);
let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::AutoSet(expected.clone())
);
assert_eq!(config.vision_preprocessor_provider, Some(expected));
}
#[test]
fn vision_preprocessor_cleared_when_stale_atomgit_and_list_has_no_vl() {
let mut config = blank_config();
config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
let info = run_register(&mut config, models);
assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::Cleared);
assert_eq!(config.vision_preprocessor_provider, None);
}
#[test]
fn vision_preprocessor_preserves_user_set_non_atomgit() {
let mut config = blank_config();
config.vision_preprocessor_provider = Some("Qwen3-VL-32B-Instruct".into());
let models = vec![
vl_model_entry("Kimi-K2-Instruct"),
vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
];
let info = run_register(&mut config, models);
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::UserSupplied("Qwen3-VL-32B-Instruct".into())
);
assert_eq!(
config.vision_preprocessor_provider.as_deref(),
Some("Qwen3-VL-32B-Instruct")
);
}
#[test]
fn vision_preprocessor_recognises_pure_ocr_model_name() {
let mut config = blank_config();
let models = vec![
vl_model_entry("Kimi-K2-Instruct"),
vl_model_entry("PaddleOCR-2.0"),
];
let info = run_register(&mut config, models);
let expected = "AtomGit-PaddleOCR-2.0".to_string();
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::AutoSet(expected.clone())
);
assert_eq!(config.vision_preprocessor_provider, Some(expected));
}
#[test]
fn render_includes_vision_preprocessor_auto_set_line() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec![
"Kimi-K2-Instruct".into(),
"Qwen/Qwen3-VL-32B-Instruct".into(),
],
provider_names: vec![
"AtomGit-Kimi-K2-Instruct".into(),
"AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::AutoSet(
"AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
),
all_models: vec![],
}),
status: StepResult::Skipped("status check skipped for this test".into()),
auth_expired: false,
};
let out = report.render();
assert!(
out.contains("Vision preprocessor → AtomGit-Qwen-Qwen3-VL-32B-Instruct"),
"render must include the auto-detected line: {out}",
);
assert!(out.contains("(auto-detected)"));
}
#[test]
fn render_includes_vision_preprocessor_cleared_line_when_stale_dropped() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["Kimi-K2-Instruct".into()],
provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::Cleared,
all_models: vec![],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Vision preprocessor cleared"));
}
#[test]
fn render_includes_vision_preprocessor_user_supplied_line() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec![
"Kimi-K2-Instruct".into(),
"Qwen/Qwen3-VL-32B-Instruct".into(),
],
provider_names: vec![
"AtomGit-Kimi-K2-Instruct".into(),
"AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::UserSupplied(
"Qwen3-VL-32B-Instruct".into(),
),
all_models: vec![],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Vision preprocessor → Qwen3-VL-32B-Instruct"));
assert!(out.contains("(user setting kept)"));
}
#[test]
fn render_omits_vision_preprocessor_line_when_unchanged_none() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["Kimi-K2-Instruct".into()],
provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
assert!(!out.contains("Vision preprocessor"));
}
#[test]
fn render_shows_locked_models_with_prefix_marker() {
let avail = super::super::types::ModelEntry {
id: 1,
display_model_name: "lite/foo".into(),
plan_available: true,
..Default::default()
};
let locked = super::super::types::ModelEntry {
id: 2,
display_model_name: "max/super-secret".into(),
plan_available: false,
..Default::default()
};
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo {
message: "claimed".into(),
duplicate: false,
plan_type: PlanType::Lite,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["lite/foo".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![avail, locked],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("(CodingPlan Lite)"), "claim row must show tier:\n{out}");
assert!(out.contains("AtomGit") && out.contains("lite/foo"));
assert!(
out.contains("\x1b[31m✗ max/super-secret"),
"locked model must open with SGR 31 + ✗ prefix:\n{out}"
);
assert!(out.contains("(requires Pro plan or higher)\x1b[39m"));
assert!(
!out.contains("\x1b[9m"),
"locked-model line must not emit SGR 9 strikethrough:\n{out}"
);
assert!(
!out.contains('\u{0336}'),
"locked-model line must not emit U+0336 combining strikethrough:\n{out}"
);
assert!(
!out.contains("locked model"),
"no separate locked-model section expected:\n{out}"
);
let added_idx = out.find("Added 1 provider").expect("Added header");
let locked_idx = out.find("max/super-secret").expect("locked model line");
let avail_idx = out.find("lite/foo").expect("available model line");
assert!(
locked_idx > added_idx,
"locked model must render after the Added header:\n{out}"
);
assert!(
locked_idx < avail_idx,
"locked model must render BEFORE available providers (top-of-list upgrade prompt):\n{out}"
);
}
}