use std::io::IsTerminal;
use std::io::Write;
use std::process::Command;
use std::time::Duration;
use serde_json::Value;
use crate::theme;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FirstRunCause {
Unreachable,
NoModelLoaded,
ModelNotPulled { configured: String },
Ready,
}
#[must_use]
pub fn classify_models_response(names: &[String], configured: &str) -> FirstRunCause {
if names.is_empty() {
return FirstRunCause::NoModelLoaded;
}
if crate::doctor::model_present(names, configured) {
return FirstRunCause::Ready;
}
FirstRunCause::ModelNotPulled {
configured: configured.to_string(),
}
}
#[must_use]
pub fn classify_backend(base_url: &str, openai_compat: bool, configured: &str) -> FirstRunCause {
let Ok(client) = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(4))
.build()
else {
return FirstRunCause::Unreachable;
};
let tags_url = if openai_compat {
format!("{base_url}/v1/models")
} else {
format!("{base_url}/api/tags")
};
match client.get(&tags_url).send() {
Ok(r) if r.status().is_success() => {
let Ok(body) = r.json::<Value>() else {
return FirstRunCause::Unreachable;
};
let names = crate::doctor::extract_model_names(&body, openai_compat);
classify_models_response(&names, configured)
}
_ => FirstRunCause::Unreachable,
}
}
fn confirm_default_yes(question: &str) -> bool {
let mut err = std::io::stderr().lock();
let _ = write!(
err,
" {} {question} [Y/n] ",
theme::warn(theme::WARN_GLYPH)
);
let _ = err.flush();
let mut buf = String::new();
match std::io::stdin().read_line(&mut buf) {
Ok(0) => false, Ok(_) => {
let a = buf.trim().to_ascii_lowercase();
a.is_empty() || a == "y" || a == "yes"
}
Err(_) => false,
}
}
#[must_use]
pub fn offer_fix_interactive() -> bool {
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return false;
}
if crate::egress::is_offline() {
return false;
}
let base_url = crate::api::resolve_ollama_url();
let compat = crate::api::resolve_openai_compat();
let model = crate::run::current_model();
match classify_backend(&base_url, compat, &model) {
FirstRunCause::Unreachable => {
eprintln!(
" {} {}",
theme::accent("↳ fix:"),
theme::dim(&crate::doctor::backend_start_hint(compat))
);
false
}
FirstRunCause::NoModelLoaded | FirstRunCause::ModelNotPulled { .. } => {
if compat {
eprintln!(
" {} {}",
theme::accent("↳ fix:"),
theme::dim(&crate::doctor::model_load_hint(compat, &model))
);
return false;
}
if !confirm_default_yes(&format!(
"`{model}` isn't pulled — pull it now with `ollama pull {model}`?"
)) {
return false;
}
match Command::new("ollama").args(["pull", &model]).status() {
Ok(s) if s.success() => {
match crate::api::probe_ollama() {
Ok(_) => {
eprintln!(
" {} {}",
theme::OK_GLYPH,
theme::ok(&format!("`{model}` pulled — continuing"))
);
true
}
Err(e) => {
eprintln!(
" {} {}",
theme::ERR_GLYPH,
theme::error(&format!(
"pull finished but the probe still fails: {e}"
))
);
false
}
}
}
Ok(s) => {
eprintln!(
" {} {}",
theme::ERR_GLYPH,
theme::error(&format!("`ollama pull {model}` exited with {s}"))
);
false
}
Err(e) => {
eprintln!(
" {} {}",
theme::ERR_GLYPH,
theme::error(&format!("could not run `ollama` ({e})"))
);
eprintln!(
" {} {}",
theme::accent("↳ fix:"),
theme::dim(&crate::doctor::backend_start_hint(compat))
);
false
}
}
}
FirstRunCause::Ready => false, }
}
#[cfg(test)]
mod tests {
use super::{classify_models_response, FirstRunCause};
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn empty_list_is_no_model_loaded() {
assert_eq!(
classify_models_response(&[], "qwen3.5:4b"),
FirstRunCause::NoModelLoaded
);
}
#[test]
fn missing_brain_is_model_not_pulled() {
assert_eq!(
classify_models_response(&names(&["llama3:8b", "phi4:14b"]), "qwen3.5:4b"),
FirstRunCause::ModelNotPulled {
configured: "qwen3.5:4b".to_string()
}
);
}
#[test]
fn present_brain_is_ready() {
assert_eq!(
classify_models_response(&names(&["qwen3.5:4b", "phi4:14b"]), "qwen3.5:4b"),
FirstRunCause::Ready
);
assert_eq!(
classify_models_response(&names(&["qwen3.5:4b:latest"]), "qwen3.5:4b"),
FirstRunCause::Ready
);
}
#[test]
fn offer_is_refused_when_stdin_is_piped() {
assert!(!super::offer_fix_interactive());
}
}