#![cfg(feature = "models")]
use std::path::{Path, PathBuf};
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_roteiro");
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn roteiro(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new(BIN)
.args(args)
.current_dir(dir)
.env("ROTEIRO_HOME", dir)
.env("HOME", dir)
.env("USERPROFILE", dir)
.output()
.expect("run roteiro")
}
fn fresh_repo(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("roteiro-models-{label}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("mkdir");
git(&dir, &["init", "-q"]);
std::fs::write(dir.join("README.md"), "# fixture\n").expect("write");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
dir
}
fn stdout(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn unset_resolves_to_the_models_used_before_the_table_existed() {
let dir = fresh_repo("unset");
let out = roteiro(&dir, &["config"]);
assert!(out.status.success(), "config failed: {out:?}");
let text = stdout(&out);
for (task, model) in [
("draft", "qwen3-0.6b"),
("chat", "qwen3-0.6b"),
("transcribe", "voxtral-mini-3b"),
("describe", "smolvlm-500m-gguf"),
("ocr", "ocrs-text"),
] {
let line = resolution_line(&text, task);
assert!(
line.contains(model),
"unset `{task}` still resolves to {model}: {line}"
);
assert!(
line.contains("built-in default"),
"and says nothing pinned it: {line}"
);
}
let embed = resolution_line(&text, "embed");
assert!(
embed.contains("hashing embedder"),
"embed falls back to the offline default: {embed}"
);
let json = roteiro(&dir, &["config", "--json"]);
let cfg: serde_json::Value = serde_json::from_slice(&json.stdout).expect("valid JSON");
let entries = cfg["model_resolution"]
.as_array()
.expect("model_resolution is an array");
assert_eq!(entries.len(), 6, "one entry per surface: {entries:?}");
let transcribe = entry(entries, "transcribe");
assert_eq!(transcribe["model"], "voxtral-mini-3b");
assert_eq!(transcribe["source"], "default");
assert_eq!(transcribe["config_key"], "audio");
assert!(transcribe["layer"].is_null(), "nothing pinned it");
assert!(
entry(entries, "embed")["model"].is_null(),
"no model needed"
);
assert!(cfg["infer"].is_object(), "existing sections intact: {cfg}");
assert!(
cfg["models"]["audio"].is_null(),
"the new key exists: {cfg}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_pin_is_reported_per_surface_with_the_layer_it_came_from() {
let dir = fresh_repo("pinned");
std::fs::write(dir.join("config.toml"), "[models]\nocr = \"ocrs-text\"\n").expect("write user");
std::fs::write(
dir.join("roteiro.toml"),
"[models]\naudio = \"voxtral-mini-3b\"\n",
)
.expect("write project");
let text = stdout(&roteiro(&dir, &["config"]));
let transcribe = resolution_line(&text, "transcribe");
assert!(
transcribe.contains("pinned by `[models] audio` in project config"),
"the pin and its layer are named: {transcribe}"
);
let ocr = resolution_line(&text, "ocr");
assert!(
ocr.contains("pinned by `[models] ocr` in user config"),
"the user layer is distinguished from the project one: {ocr}"
);
let describe = resolution_line(&text, "describe");
assert!(
describe.contains("built-in default") && describe.contains("smolvlm-500m-gguf"),
"an unrelated surface is untouched: {describe}"
);
let json = roteiro(&dir, &["config", "--json"]);
let cfg: serde_json::Value = serde_json::from_slice(&json.stdout).expect("valid JSON");
let entries = cfg["model_resolution"].as_array().expect("array");
assert_eq!(entry(entries, "transcribe")["source"], "pinned");
assert_eq!(entry(entries, "transcribe")["layer"], "project");
assert_eq!(entry(entries, "ocr")["layer"], "user");
assert_eq!(entry(entries, "describe")["source"], "default");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_wrong_modality_pin_is_refused_by_name_and_never_falls_back() {
let dir = fresh_repo("wrong-kind");
std::fs::write(
dir.join("roteiro.toml"),
"[models]\naudio = \"smolvlm-500m-gguf\"\n",
)
.expect("write project");
let out = roteiro(&dir, &["config"]);
assert!(
out.status.success(),
"`config` must still run — it is how you diagnose this: {out:?}"
);
let text = stdout(&out);
let line = resolution_line(&text, "transcribe");
assert!(
line.contains("UNRESOLVED"),
"not silently defaulted: {line}"
);
assert!(line.contains("[models] audio"), "names the key: {line}");
assert!(
line.contains("vision model") && line.contains("an audio model"),
"says what it is and what was needed: {line}"
);
assert!(
!line.contains("voxtral-mini-3b"),
"the default must not appear as the answer: {line}"
);
assert!(resolution_line(&text, "describe").contains("smolvlm-500m-gguf"));
assert!(resolution_line(&text, "ocr").contains("ocrs-text"));
let json = roteiro(&dir, &["config", "--json"]);
let cfg: serde_json::Value = serde_json::from_slice(&json.stdout).expect("valid JSON");
let transcribe = entry(
cfg["model_resolution"].as_array().expect("array"),
"transcribe",
);
assert!(transcribe["model"].is_null(), "no model was chosen");
assert!(
transcribe["error"]
.as_str()
.is_some_and(|e| e.contains("[models] audio")),
"the error names the key: {transcribe}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_blank_pin_reads_as_unset_on_every_surface() {
let dir = fresh_repo("blank-pin");
std::fs::write(dir.join("roteiro.toml"), "[models]\naudio = \" \"\n").expect("write project");
let text = stdout(&roteiro(&dir, &["config"]));
let key_line = text
.lines()
.find(|l| l.starts_with(" audio "))
.unwrap_or_else(|| panic!("no `[models] audio` line in:\n{text}"));
assert!(
key_line.contains("None") && key_line.contains("(default)"),
"a value that names no model is not a set key: {key_line}"
);
let line = resolution_line(&text, "transcribe");
assert!(
line.contains("built-in default") && line.contains("voxtral-mini-3b"),
"resolution is unchanged: {line}"
);
let json = roteiro(&dir, &["config", "--json"]);
let cfg: serde_json::Value = serde_json::from_slice(&json.stdout).expect("valid JSON");
assert!(
cfg["models"]["audio"].is_null(),
"the echoed config agrees with the resolution beside it: {}",
cfg["models"]
);
let transcribe = entry(
cfg["model_resolution"].as_array().expect("array"),
"transcribe",
);
assert_eq!(transcribe["source"], "default");
assert!(transcribe["layer"].is_null(), "no layer pinned it");
std::fs::write(
dir.join("roteiro.toml"),
"[models]\naudio = \" voxtral-mini-3b \"\n",
)
.expect("write project");
let text = stdout(&roteiro(&dir, &["config"]));
assert!(
resolution_line(&text, "transcribe").contains("pinned by `[models] audio`"),
"a padded name is still a pin: {text}"
);
let json = roteiro(&dir, &["config", "--json"]);
let cfg: serde_json::Value = serde_json::from_slice(&json.stdout).expect("valid JSON");
assert_eq!(
cfg["models"]["audio"], "voxtral-mini-3b",
"reported as the name that was resolved, not as it was typed"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unknown_pin_is_refused_rather_than_defaulted() {
let dir = fresh_repo("unknown");
std::fs::write(
dir.join("roteiro.toml"),
"[models]\ngenerative = \"qwen3-0.6\"\n",
)
.expect("write project");
let text = stdout(&roteiro(&dir, &["config"]));
for task in ["draft", "chat"] {
let line = resolution_line(&text, task);
assert!(line.contains("UNRESOLVED"), "{task}: {line}");
assert!(line.contains("roteiro model list"), "actionable: {line}");
assert!(
!line.contains("qwen3-0.6b"),
"the near-miss default is not substituted: {line}"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn media_status_names_the_pinned_model_to_pull() {
let dir = fresh_repo("media-status");
let before = stdout(&roteiro(&dir, &["media", "status"]));
assert!(
before.contains("roteiro model pull voxtral-mini-3b"),
"unset names the default: {before}"
);
std::fs::write(
dir.join("roteiro.toml"),
"[models]\naudio = \"qwen3-0.6b\"\n",
)
.expect("write project");
let bad = stdout(&roteiro(&dir, &["media", "status"]));
assert!(
bad.contains("[models] audio") && !bad.contains("model pull voxtral-mini-3b"),
"a generative model pinned for audio is refused, not defaulted: {bad}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
fn spec_draft_refuses_a_generative_pin_of_the_wrong_kind() {
let dir = fresh_repo("spec-draft");
std::fs::write(
dir.join("roteiro.toml"),
"[models]\ngenerative = \"bge-small-en-v1.5-gguf\"\n",
)
.expect("write project");
let out = roteiro(&dir, &["spec", "draft", "fixture"]);
assert!(!out.status.success(), "a bad pin must fail: {out:?}");
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(err.contains("[models] generative"), "names the key: {err}");
assert!(
err.contains("embedding model") && err.contains("a generative model"),
"says what is wrong with it: {err}"
);
assert!(
!err.contains("drafted"),
"and nothing was drafted with a substitute model: {err}"
);
assert!(
!String::from_utf8_lossy(&out.stdout).contains("# "),
"not even a scaffold: a refused pin produces no artifact"
);
std::fs::remove_dir_all(&dir).ok();
}
fn resolution_line(text: &str, task: &str) -> String {
let want = format!(" {task} ");
text.lines()
.find(|l| l.starts_with(&want))
.unwrap_or_else(|| panic!("no resolution line for `{task}` in:\n{text}"))
.to_owned()
}
fn entry<'a>(entries: &'a [serde_json::Value], task: &str) -> &'a serde_json::Value {
entries
.iter()
.find(|e| e["task"] == task)
.unwrap_or_else(|| panic!("no `{task}` entry in {entries:?}"))
}