use crate::config::Config;
use crate::doctor::Remedy;
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Done,
Missing,
Wrong,
Unknown,
Declined,
}
#[derive(Debug, Clone, Serialize)]
pub struct Step {
pub id: String,
pub title: String,
pub status: Status,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub remedy: Option<Remedy>,
pub optional: bool,
}
impl Step {
fn new(id: &str, title: &str, status: Status, detail: impl Into<String>) -> Self {
Step {
id: id.into(),
title: title.into(),
status,
detail: detail.into(),
remedy: None,
optional: false,
}
}
fn optional(mut self) -> Self {
self.optional = true;
self
}
fn with(mut self, description: &str, argv: &[&str], needs_terminal: bool) -> Self {
self.remedy = Some(Remedy {
description: description.into(),
argv: argv.iter().map(|s| s.to_string()).collect(),
needs_terminal,
});
self
}
}
#[derive(Debug, Clone, Default)]
pub struct Facts {
pub has_mail_binary: bool,
pub has_docs_binary: bool,
pub has_graph_binary: bool,
pub mail_accounts: Option<usize>,
pub docs_accounts: Option<usize>,
pub slack_linked: Option<bool>,
pub props: Option<crate::provider::preflight::Props>,
pub provider_credential: bool,
pub config_file: bool,
pub local_probe: LocalProbe,
pub scheduler_installed: bool,
pub trigger_count: usize,
pub charter: CharterState,
pub declined: std::collections::BTreeSet<String>,
}
#[derive(Debug, Clone)]
pub struct LocalServer {
pub base_url: String,
pub props: crate::provider::preflight::Props,
}
#[derive(Debug, Clone, Default)]
pub enum LocalProbe {
#[default]
NotAttempted,
NothingAnswered,
Found(LocalServer),
}
pub fn answers_like_a_model_server(props: &crate::provider::preflight::Props) -> bool {
props.model_alias.is_some() || props.default_generation_settings.n_ctx.is_some()
}
pub fn local_probe_candidates() -> &'static [&'static str] {
&["http://127.0.0.1:8080"]
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum CharterState {
#[default]
Absent,
Empty,
Lines(usize),
Broken(String),
Unknown,
}
pub fn plan(cfg: &Config, provider_name: &str, facts: &Facts) -> Vec<Step> {
let mut steps = Vec::new();
let local = cfg
.providers
.get(provider_name)
.filter(|p| p.kind == "local");
if !facts.config_file {
steps.push(
Step::new(
"config-file",
"A config file to change things in",
Status::Missing,
concat!(
"Everything runs on defaults until there is one, and defaults are ",
"fine — but the model, the server and the budgets are all set here, ",
"so the first thing anyone needs is a file to put them in. It is ",
"written commented, so it doubles as the list of what is adjustable."
),
)
.with(
"Write a commented starter config to ~/.mecha/config.toml.",
&["mecha", "config", "init"],
false,
),
);
}
if !facts.provider_credential && local.is_none() {
steps.push(provider_step(provider_name, cfg, facts));
}
if let Some(pcfg) = local {
match &facts.props {
None => steps.push(Step::new(
"local-server",
"The local server is reachable",
Status::Missing,
format!(
"Nothing answered at {}. Start the server before the rest of this can be \
checked — every value below is read back from it rather than guessed.",
pcfg.base_url.as_deref().unwrap_or("(no base_url)")
),
)),
Some(props) => {
let mismatches =
crate::provider::preflight::disagreements(provider_name, pcfg, props);
if mismatches.is_empty() {
steps.push(Step::new(
"local-server",
"The local server agrees with the config",
Status::Done,
format!(
"serving {}, {} tokens per slot, vision {}",
props.model_alias.as_deref().unwrap_or("(unnamed)"),
props
.default_generation_settings
.n_ctx
.map(|n| n.to_string())
.unwrap_or_else(|| "?".into()),
if props.modalities.vision { "on" } else { "off" },
),
));
} else {
steps.push(
Step::new(
"local-server",
"The config disagrees with what is served",
Status::Wrong,
mismatches.join("\n\n"),
)
.with(
"Rewrite these from what the server reports, rather than editing \
them by hand.",
&["mecha", "setup", "--write"],
false,
),
);
}
}
}
}
steps.extend(integration_steps(facts));
steps.push(charter_step(&facts.charter));
if !facts.scheduler_installed && facts.trigger_count > 0 {
steps.push(
Step::new(
"scheduler",
"Something to fire the triggers",
Status::Missing,
format!(
"{} trigger(s) are defined and nothing is running them. Being due is a \
function of the ledger and the clock, so any of a systemd timer, a \
crontab line running `mecha trigger tick`, or `mecha trigger daemon` \
will do.",
facts.trigger_count
),
)
.with(
"Print a systemd user unit for the daemon, to review before installing.",
&["mecha", "trigger", "daemon", "--print-unit"],
false,
),
);
}
for step in &mut steps {
if step.optional
&& matches!(step.status, Status::Missing)
&& facts.declined.contains(&step.id)
{
step.status = Status::Declined;
step.remedy = None;
}
}
steps
}
fn provider_step(provider_name: &str, cfg: &Config, facts: &Facts) -> Step {
let env_var = cfg
.providers
.get(provider_name)
.and_then(|p| p.api_key_env.clone());
let step = |detail: String| {
Step::new(
"provider-credential",
"A provider that can answer",
Status::Missing,
detail,
)
};
if let LocalProbe::Found(found) = &facts.local_probe {
let serving = found
.props
.model_alias
.as_deref()
.unwrap_or("(an unnamed model)");
return step(format!(
concat!(
"`{provider_name}` has no usable credential — but something is ",
"already serving {serving} at {url}, and nothing in the config names ",
"it. Writing it down reads every value back off the server rather ",
"than asking you for any of them, which is the only way ",
"`context_window` ever gets to be the per-slot figure rather than ",
"`-c`."
),
provider_name = provider_name,
serving = serving,
url = found.base_url
))
.with(
"Write the local server down as a provider, from what it reports about itself.",
&["mecha", "setup", "--write"],
false,
);
}
if let Some((name, pcfg)) = cfg
.providers
.iter()
.find(|(name, p)| p.kind == "local" && *name != provider_name)
{
let where_it_points = pcfg
.base_url
.as_deref()
.map(|u| format!(" ({u})"))
.unwrap_or_default();
return step(format!(
concat!(
"`{provider_name}` has no usable credential — but you already have a ",
"local provider configured, `{name}`{where_it_points}, and it is not ",
"the default. Point `default_provider` at it in the config, or select ",
"it for one run with `-p {name}`. Nothing here probed {name}: whether ",
"it is up is what `mecha setup` reports once it is the one being used."
),
provider_name = provider_name,
name = name,
where_it_points = where_it_points
));
}
let key_line = match &env_var {
Some(var) => format!(
concat!(
"Set `{var}` in your shell (`export {var}=…`) and start a new one — ",
"mecha stores the variable's *name* in the config and never the key ",
"itself, so nothing here has to hold a secret."
),
var = var
),
None => format!(
concat!(
"`{provider_name}` names no `api_key_env`, so there is no variable to ",
"set — give it one, or point `default_provider` at a local server."
),
provider_name = provider_name
),
};
let local_line = match &facts.local_probe {
LocalProbe::NothingAnswered => format!(
concat!(
"2. Run a model locally — the target rather than the fallback. Serve ",
"it, then `mecha setup --write` reads the settings off it. Nothing ",
"was answering at {tried} when this ran."
),
tried = local_probe_candidates().join(", ")
),
_ => concat!(
"2. Run a model locally — the target rather than the fallback. Serve it, ",
"then `mecha setup --write` reads the settings off it."
)
.to_string(),
};
step(format!(
"Nothing can answer a prompt yet, so nothing below this can be tested. \
Two ways out.\n\n1. {key_line}\n\n{local_line}"
))
}
fn charter_step(state: &CharterState) -> Step {
const WHY: &str = concat!(
"A short ranked list of standing priorities, in your own words, that rides in ",
"every run's prompt. Order is rank: when two conflict, the higher one wins ",
"outright. mecha never writes a line of it."
);
match state {
CharterState::Lines(n) => Step::new(
"charter",
"Your charter",
Status::Done,
format!(
"{n} standing priorit{} in rank order",
if *n == 1 { "y" } else { "ies" }
),
),
CharterState::Empty => Step::new(
"charter",
"Your charter",
Status::Missing,
format!(
"The file exists with no `[[line]]` entries yet, so nothing from it rides \
in any prompt. {WHY}"
),
)
.with(
"Open the charter in $EDITOR.",
&["mecha", "charter", "edit"],
true,
)
.optional(),
CharterState::Absent => Step::new(
"charter",
"Your charter",
Status::Missing,
format!("Nothing written yet — every run is proceeding un-chartered. {WHY}"),
)
.with(
"Create it from a commented template and open it in $EDITOR.",
&["mecha", "charter", "edit"],
true,
)
.optional(),
CharterState::Broken(e) => Step::new(
"charter",
"Your charter does not load",
Status::Wrong,
format!(
"{e}
Every run is starting un-chartered until this parses."
),
)
.with(
"Open the charter in $EDITOR and fix it.",
&["mecha", "charter", "edit"],
true,
),
CharterState::Unknown => Step::new(
"charter",
"Your charter",
Status::Unknown,
"the charter could not be read from here.",
),
}
}
fn integration_steps(facts: &Facts) -> Vec<Step> {
let mut steps = Vec::new();
steps.push(match (facts.has_mail_binary, facts.mail_accounts) {
(false, _) => Step::new(
"mail",
"Mail and calendar",
Status::Missing,
"`mecha-mail` is not on PATH. It is a separate crate, and optional — nothing else \
needs it.",
)
.with(
"Install the mail and calendar MCP servers.",
&["cargo", "install", "mecha-mail", "--locked"],
false,
)
.optional(),
(true, Some(0)) => Step::new(
"mail",
"Mail and calendar",
Status::Missing,
"`mecha-mail` is installed with no accounts authorised. The model names an \
*account*, never a provider, so add one per mailbox.",
)
.with(
"Authorise a mailbox. Needs a browser, or `--paste` over SSH.",
&["mecha-mail", "auth", "personal", "--provider", "google"],
true,
)
.optional(),
(true, Some(n)) => Step::new(
"mail",
"Mail and calendar",
Status::Done,
format!("{n} account(s) authorised"),
),
(true, None) => Step::new(
"mail",
"Mail and calendar",
Status::Unknown,
"`mecha-mail` is installed; its credential store could not be read from here.",
),
});
steps.push(match (facts.has_docs_binary, facts.docs_accounts) {
(false, _) => Step::new(
"docs",
"Google Docs, Sheets and Slides",
Status::Missing,
"`mecha-docs` ships with the mail crate. Under `drive.file` it reaches only files \
it created or you handed it in Google's own picker — which is the reason to want \
it, and no instruction inside a run can widen that.",
)
.with(
"Install the documents server (same crate as mail).",
&["cargo", "install", "mecha-mail", "--locked"],
false,
)
.optional(),
(true, Some(0)) => Step::new(
"docs",
"Google Docs, Sheets and Slides",
Status::Missing,
"`mecha-docs` is installed with no account authorised.",
)
.with(
"Authorise Drive access. Use `--paste` if there is no browser here.",
&["mecha-docs", "auth", "personal"],
true,
)
.optional(),
(true, Some(n)) => Step::new(
"docs",
"Google Docs, Sheets and Slides",
Status::Done,
format!("{n} account(s) authorised"),
),
(true, None) => Step::new(
"docs",
"Google Docs, Sheets and Slides",
Status::Unknown,
"installed; the credential store could not be read from here.",
),
});
steps.push(match facts.slack_linked {
Some(true) => Step::new(
"slack",
"Slack as a remote control",
Status::Done,
"linked to a workspace",
),
Some(false) => Step::new(
"slack",
"Slack as a remote control",
Status::Missing,
"Watch a run from a phone, approve what it wants to send, and hand files in and \
out. The owner is bound by a nonce printed on this machine, so proving shell \
access here is what claims it.",
)
.with(
"Start the Slack setup, which prints the binding nonce.",
&["mecha", "slack", "auth"],
true,
)
.optional(),
None => Step::new(
"slack",
"Slack as a remote control",
Status::Unknown,
"the binding store could not be read from here.",
),
});
steps.push(if facts.has_graph_binary {
Step::new(
"graph",
"The personal knowledge graph",
Status::Done,
"`mecha-graph-mcp` is on PATH. Its own sources — ambient conversations, a \
calendar ICS feed, Slack, messages, mail — are configured with `mecha-graph \
source`, in that project. mecha reaches the graph only through its MCP tools \
and deliberately knows nothing else about it.",
)
} else {
Step::new(
"graph",
"The personal knowledge graph",
Status::Missing,
"Memory: who people are, what happened when. A separate project, wired in as an \
MCP server whose reads are marked untrusted — a graph fed by mail and messages \
holds third-party text by construction.",
)
.with(
"Install the graph's MCP server.",
&["cargo", "install", "mecha-graph-mcp", "--locked"],
false,
)
.optional()
});
steps
}
pub fn verified_settings(props: &crate::provider::preflight::Props) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
if let Some(alias) = &props.model_alias {
out.push(("model", toml_string(alias)));
}
if let Some(n) = props.default_generation_settings.n_ctx {
out.push(("context_window", n.to_string()));
}
out.push(("vision", props.modalities.vision.to_string()));
out
}
pub fn toml_string(s: &str) -> String {
toml::Value::String(s.to_string()).to_string()
}
pub fn count_accounts(root: &Path) -> Option<usize> {
match std::fs::read_dir(root) {
Ok(entries) => Some(
entries
.flatten()
.filter(|e| e.path().join("oauth.json").is_file())
.count(),
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
Err(_) => None,
}
}
pub fn declined_path(home: &Path) -> PathBuf {
home.join("setup-declined.json")
}
pub fn read_declined(home: &Path) -> Option<std::collections::BTreeSet<String>> {
let path = declined_path(home);
match std::fs::read_to_string(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Some(std::collections::BTreeSet::new())
}
Err(_) => None,
Ok(text) => serde_json::from_str::<Declined>(&text)
.ok()
.map(|d| d.declined),
}
}
pub fn decline(home: &Path, id: &str) -> std::io::Result<DeclineWrite> {
let (mut set, salvaged) = read_for_write(home);
let changed = set.insert(id.to_string());
write_declined(home, &set)?;
Ok(DeclineWrite { salvaged, changed })
}
pub fn undecline(home: &Path, id: Option<&str>) -> std::io::Result<DeclineWrite> {
let (mut set, salvaged) = read_for_write(home);
let changed = match id {
Some(id) => set.remove(id),
None => {
let had = !set.is_empty();
set.clear();
had
}
};
write_declined(home, &set)?;
Ok(DeclineWrite { salvaged, changed })
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DeclineWrite {
pub salvaged: Option<PathBuf>,
pub changed: bool,
}
fn read_for_write(home: &Path) -> (std::collections::BTreeSet<String>, Option<PathBuf>) {
match read_declined(home) {
Some(set) => (set, None),
None => (Default::default(), salvage_unreadable(home)),
}
}
fn salvage_unreadable(home: &Path) -> Option<PathBuf> {
let path = declined_path(home);
let aside = path.with_extension(format!(
"json.unreadable.{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
));
std::fs::rename(&path, &aside).ok().map(|()| aside)
}
fn write_declined(home: &Path, set: &std::collections::BTreeSet<String>) -> std::io::Result<()> {
let path = declined_path(home);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(&Declined {
declined: set.clone(),
})
.map_err(std::io::Error::other)?;
let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, &path)
}
#[derive(Debug, Default, Serialize, serde::Deserialize)]
struct Declined {
#[serde(default)]
declined: std::collections::BTreeSet<String>,
}
pub fn charter_state(path: &Path) -> CharterState {
match std::fs::metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return CharterState::Absent,
Err(_) => return CharterState::Unknown,
Ok(_) => {}
}
match crate::charter::Charter::load(path) {
Err(e) => CharterState::Broken(format!("{e:#}")),
Ok(c) if c.is_empty() => CharterState::Empty,
Ok(c) => CharterState::Lines(c.lines().len()),
}
}
pub fn on_path(name: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::preflight::{GenerationSettings, Modalities, Props};
fn cfg_with_local(context_window: u64, vision: Option<bool>) -> Config {
let mut cfg = Config::default();
let mut p = cfg.providers.get("anthropic").cloned().unwrap();
p.kind = "local".into();
p.model = Some("qwen3.6-35b-a3b".into());
p.base_url = Some("http://127.0.0.1:8080".into());
p.api_key_env = None;
p.context_window = Some(context_window);
p.vision = vision;
cfg.providers.insert("local".into(), p);
cfg
}
fn props(n_ctx: u64, slots: u64, vision: bool) -> Props {
Props {
model_alias: Some("qwen3.6-35b-a3b".into()),
total_slots: Some(slots),
modalities: Modalities { vision },
default_generation_settings: GenerationSettings { n_ctx: Some(n_ctx) },
}
}
fn facts(props: Option<Props>) -> Facts {
Facts {
provider_credential: true,
props,
mail_accounts: Some(1),
docs_accounts: Some(1),
slack_linked: Some(true),
has_mail_binary: true,
has_docs_binary: true,
has_graph_binary: true,
scheduler_installed: true,
trigger_count: 0,
charter: CharterState::Lines(3),
config_file: true,
local_probe: LocalProbe::NotAttempted,
declined: Default::default(),
}
}
fn step<'a>(steps: &'a [Step], id: &str) -> &'a Step {
steps.iter().find(|s| s.id == id).expect("step missing")
}
#[test]
fn everything_configured_and_agreeing_reports_no_work() {
let cfg = cfg_with_local(262144, Some(true));
let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
assert!(
steps.iter().all(|s| s.status == Status::Done),
"unexpected work: {:?}",
steps
.iter()
.filter(|s| s.status != Status::Done)
.map(|s| &s.id)
.collect::<Vec<_>>()
);
}
#[test]
fn a_fresh_install_is_offered_a_charter_and_the_offer_authors_nothing() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.charter = CharterState::Absent;
let steps = plan(&cfg, "local", &f);
let charter = step(&steps, "charter");
assert_eq!(charter.status, Status::Missing);
let remedy = charter.remedy.as_ref().expect("a fresh charter is offered");
assert_eq!(remedy.argv, ["mecha", "charter", "edit"]);
assert!(
remedy.needs_terminal,
"handing over $EDITOR needs a keyboard"
);
assert!(
charter.detail.contains("in your own words"),
"the detail should say whose words these are: {}",
charter.detail
);
}
#[test]
fn an_empty_charter_reads_differently_from_an_absent_one() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.charter = CharterState::Empty;
let empty = step(&plan(&cfg, "local", &f), "charter").clone();
f.charter = CharterState::Absent;
let absent = step(&plan(&cfg, "local", &f), "charter").clone();
assert_eq!(empty.status, Status::Missing);
assert_eq!(absent.status, Status::Missing);
assert_ne!(
empty.detail, absent.detail,
"a template nobody filled in is not the same finding as a fresh install"
);
}
#[test]
fn a_charter_that_does_not_load_is_wrong_rather_than_missing() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.charter = CharterState::Broken("duplicate id `x`".into());
let charter = step(&plan(&cfg, "local", &f), "charter").clone();
assert_eq!(charter.status, Status::Wrong);
assert!(charter.detail.contains("duplicate id"));
assert!(
charter.detail.contains("un-chartered"),
"say what it costs, not just that it failed: {}",
charter.detail
);
}
#[test]
fn a_declined_step_reports_the_decision_rather_than_the_absence() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.slack_linked = Some(false);
let before = step(&plan(&cfg, "local", &f), "slack").clone();
assert_eq!(before.status, Status::Missing);
assert!(before.remedy.is_some());
f.declined.insert("slack".into());
let after = step(&plan(&cfg, "local", &f), "slack").clone();
assert_eq!(after.status, Status::Declined);
assert!(
after.remedy.is_none(),
"a remedy is an offer, and this one has been answered"
);
assert_eq!(
after.detail, before.detail,
"a decline changes whether a step is asked for, never what it says"
);
}
#[test]
fn a_running_local_server_turns_the_blocking_step_into_something_runnable() {
let cfg = Config::default();
let mut f = facts(None);
f.provider_credential = false;
f.local_probe = LocalProbe::Found(LocalServer {
base_url: "http://127.0.0.1:8080".into(),
props: props(32768, 4, false),
});
let s = plan(&cfg, "anthropic", &f);
let step = step(&s, "provider-credential");
assert_eq!(
step.remedy.as_ref().map(|r| r.argv.clone()),
Some(vec!["mecha".into(), "setup".into(), "--write".into()]),
"a server is running: writing it down is a thing this tool can do"
);
assert!(
step.detail.contains("127.0.0.1:8080") && step.detail.contains("qwen3.6-35b-a3b"),
"name what was found, so the offer is checkable: {}",
step.detail
);
assert!(!step.optional);
}
#[test]
fn with_no_server_the_step_names_the_variable_and_promises_not_to_store_it() {
let cfg = Config::default();
let mut f = facts(None);
f.provider_credential = false;
f.local_probe = LocalProbe::NothingAnswered;
let s = plan(&cfg, "anthropic", &f);
let step = step(&s, "provider-credential");
assert!(
step.detail.contains("ANTHROPIC_API_KEY"),
"name the variable rather than describing it: {}",
step.detail
);
assert!(
step.detail.contains("never the key itself"),
"say where the secret does *not* go: {}",
step.detail
);
assert!(step.detail.contains("locally"), "{}", step.detail);
assert!(
step.remedy.is_none(),
"there is no command that can set somebody's environment for them, \
and offering one that only prints is what this replaced"
);
}
#[test]
fn a_provider_naming_no_key_variable_is_not_told_to_set_one() {
let mut cfg = Config::default();
cfg.providers.get_mut("anthropic").unwrap().api_key_env = None;
let mut f = facts(None);
f.provider_credential = false;
f.local_probe = LocalProbe::NothingAnswered;
let detail = step(&plan(&cfg, "anthropic", &f), "provider-credential")
.detail
.clone();
assert!(detail.contains("names no `api_key_env`"), "{detail}");
assert!(!detail.contains("export "), "nothing to export: {detail}");
}
#[test]
fn an_unattempted_probe_is_never_reported_as_a_failed_one() {
let cfg = Config::default();
let mut f = facts(None);
f.provider_credential = false;
f.local_probe = LocalProbe::NothingAnswered;
let asked = step(&plan(&cfg, "anthropic", &f), "provider-credential")
.detail
.clone();
assert!(
asked.contains("Nothing was answering"),
"a probe that ran may report what it found: {asked}"
);
f.local_probe = LocalProbe::NotAttempted;
let never_asked = step(&plan(&cfg, "anthropic", &f), "provider-credential")
.detail
.clone();
assert!(
!never_asked.contains("Nothing was answering"),
"a probe that never ran must claim nothing about what is there: {never_asked}"
);
assert!(never_asked.contains("Run a model locally"), "{never_asked}");
}
#[test]
fn a_configured_but_unselected_local_provider_is_named_as_the_way_out() {
let mut cfg = Config::default();
let mut local = cfg.providers.get("anthropic").cloned().unwrap();
local.kind = "local".into();
local.base_url = Some("http://127.0.0.1:8080".into());
local.api_key_env = None;
cfg.providers.insert("local".into(), local);
let mut f = facts(None);
f.provider_credential = false;
f.local_probe = LocalProbe::NotAttempted;
let detail = step(&plan(&cfg, "anthropic", &f), "provider-credential")
.detail
.clone();
assert!(
detail.contains("`local`") && detail.contains("127.0.0.1:8080"),
"name the provider they already have, and where it points: {detail}"
);
assert!(
detail.contains("default_provider"),
"and the one-line fix: {detail}"
);
assert!(
!detail.contains("Nothing was answering"),
"nothing probed it, so nothing may be claimed about it: {detail}"
);
}
#[test]
fn a_stranger_answering_200_is_not_a_model_server() {
use crate::provider::preflight::Props;
let stranger: Props =
serde_json::from_str("{}").expect("Props defaults every field, so `{}` parses");
assert!(
!answers_like_a_model_server(&stranger),
"any JSON service answering 200 would otherwise read as a model server"
);
let proxy: Props = serde_json::from_str(r#"{"status":"ok","uptime":42}"#)
.expect("unknown fields are ignored");
assert!(!answers_like_a_model_server(&proxy));
let named = Props {
model_alias: Some("qwen3-14b".into()),
..Props::default()
};
assert!(answers_like_a_model_server(&named));
assert!(answers_like_a_model_server(&props(32768, 4, false)));
}
#[test]
fn a_missing_config_file_is_offered_and_a_present_one_is_not_mentioned() {
let cfg = Config::default();
let mut f = facts(None);
f.config_file = false;
let s = plan(&cfg, "anthropic", &f);
assert_eq!(
step(&s, "config-file")
.remedy
.as_ref()
.map(|r| r.argv.clone()),
Some(vec!["mecha".into(), "config".into(), "init".into()])
);
f.config_file = true;
assert!(
!plan(&cfg, "anthropic", &f)
.iter()
.any(|s| s.id == "config-file"),
"a file that exists is not a step"
);
}
#[test]
fn a_step_that_is_not_optional_cannot_be_declined_even_by_editing_the_file() {
let mut cfg = Config::default();
let p = cfg.providers.get_mut("anthropic").unwrap();
p.api_key_env = Some("MECHA_TEST_NO_SUCH_KEY".into());
let mut f = facts(None);
f.provider_credential = false;
f.slack_linked = Some(false);
for id in [
"provider-credential",
"mail",
"docs",
"slack",
"graph",
"charter",
] {
f.declined.insert(id.to_string());
}
let steps = plan(&cfg, "anthropic", &f);
assert_eq!(
step(&steps, "provider-credential").status,
Status::Missing,
"a credential is not a feature somebody can decline"
);
assert_eq!(step(&steps, "slack").status, Status::Declined);
}
#[test]
fn only_genuinely_optional_things_are_declinable() {
let cfg = Config::default();
let mut f = facts(None);
f.provider_credential = false;
f.mail_accounts = Some(0);
f.docs_accounts = Some(0);
f.slack_linked = Some(false);
f.has_graph_binary = false;
f.charter = CharterState::Absent;
f.trigger_count = 1;
f.scheduler_installed = false;
let steps = plan(&cfg, "anthropic", &f);
let optional: Vec<&str> = steps
.iter()
.filter(|s| s.optional)
.map(|s| s.id.as_str())
.collect();
assert_eq!(optional, ["mail", "docs", "slack", "graph", "charter"]);
}
#[test]
fn no_step_detail_carries_its_source_indentation() {
let cfg = Config::default();
let mut f = facts(None);
f.provider_credential = false;
f.charter = CharterState::Empty;
f.trigger_count = 1;
f.scheduler_installed = false;
for s in plan(&cfg, "anthropic", &f) {
assert!(
!s.detail.contains(" "),
"`{}` carries a run of spaces from its source literal: {:?}",
s.id,
s.detail
);
}
}
#[test]
fn a_decline_never_overwrites_a_step_that_is_actually_done() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.slack_linked = Some(true);
f.declined.insert("slack".into());
assert_eq!(step(&plan(&cfg, "local", &f), "slack").status, Status::Done);
}
#[test]
fn a_decline_cannot_suppress_a_broken_one() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.charter = CharterState::Broken("bad toml".into());
f.declined.insert("charter".into());
assert_eq!(
step(&plan(&cfg, "local", &f), "charter").status,
Status::Wrong,
"a decline must not hide a document that stops every run being chartered"
);
}
#[test]
fn a_decline_does_not_apply_to_an_unknown_step() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.mail_accounts = None;
f.declined.insert("mail".into());
assert_eq!(
step(&plan(&cfg, "local", &f), "mail").status,
Status::Unknown
);
}
#[test]
fn declines_round_trip_and_can_be_taken_back() {
let home = std::env::temp_dir().join(format!(
"mecha-declined-test-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
assert_eq!(read_declined(&home), Some(Default::default()));
decline(&home, "slack").unwrap();
decline(&home, "slack").unwrap();
decline(&home, "docs").unwrap();
let set = read_declined(&home).unwrap();
assert_eq!(set.len(), 2, "declining twice declines once");
assert!(set.contains("slack") && set.contains("docs"));
undecline(&home, Some("slack")).unwrap();
assert_eq!(
read_declined(&home)
.unwrap()
.into_iter()
.collect::<Vec<_>>(),
["docs"]
);
undecline(&home, None).unwrap();
assert!(read_declined(&home).unwrap().is_empty());
std::fs::write(declined_path(&home), "{not json").unwrap();
assert_eq!(read_declined(&home), None);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn declining_over_an_unreadable_store_keeps_the_old_bytes() {
let home = std::env::temp_dir().join(format!(
"mecha-declined-salvage-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let damaged = r#"{"declined": ["slack", "docs"] "#; std::fs::write(declined_path(&home), damaged).unwrap();
assert_eq!(
read_declined(&home),
None,
"the fixture is genuinely unreadable"
);
let salvaged = decline(&home, "mail")
.unwrap()
.salvaged
.expect("the old file is kept");
assert_eq!(
std::fs::read_to_string(&salvaged).unwrap(),
damaged,
"kept byte for byte — a salvage that rewrites is not a salvage"
);
let now = read_declined(&home).unwrap();
assert_eq!(now.into_iter().collect::<Vec<_>>(), ["mail"]);
assert_eq!(decline(&home, "slack").unwrap().salvaged, None);
std::fs::write(declined_path(&home), damaged).unwrap();
assert!(
undecline(&home, Some("slack")).unwrap().salvaged.is_some(),
"the undo path overwrites the same file and must salvage too"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_write_reports_what_changed_rather_than_what_was_asked() {
let home = std::env::temp_dir().join(format!(
"mecha-declined-changed-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
assert!(
decline(&home, "slack").unwrap().changed,
"a new decline changes it"
);
assert!(
!decline(&home, "slack").unwrap().changed,
"declining twice is idempotent, and the second one changed nothing"
);
assert!(
!undecline(&home, Some("slak")).unwrap().changed,
"a typo restores nothing, and must not report that it did"
);
assert!(
undecline(&home, Some("slack")).unwrap().changed,
"and a real one does"
);
assert!(!undecline(&home, None).unwrap().changed);
decline(&home, "docs").unwrap();
assert!(undecline(&home, None).unwrap().changed);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_model_alias_is_escaped_for_toml_rather_than_for_rust() {
use crate::provider::preflight::Props;
for alias in [
"qwen3-14b",
"has \"quotes\"",
"has\\backslash",
"esc\u{1b}ape",
"new\nline",
"tab\there",
] {
let props = Props {
model_alias: Some(alias.to_string()),
..Props::default()
};
let rendered = verified_settings(&props)
.into_iter()
.find(|(k, _)| *k == "model")
.expect("a named model is written down")
.1;
let doc: toml::Table = format!("model = {rendered}")
.parse()
.unwrap_or_else(|e| panic!("{alias:?} rendered as {rendered} — unparseable: {e}"));
assert_eq!(
doc["model"].as_str(),
Some(alias),
"value survived the trip"
);
}
}
#[test]
fn charter_state_reads_what_a_run_would_get() {
let dir = std::env::temp_dir().join(format!(
"mecha-charter-state-test-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("charter.toml");
assert_eq!(charter_state(&path), CharterState::Absent);
std::fs::write(&path, crate::charter::TEMPLATE).unwrap();
assert_eq!(
charter_state(&path),
CharterState::Empty,
"the shipped template must parse to zero lines, or it is authoring priorities"
);
std::fs::write(&path, "[[line]]\nid = \"a\"\ntext = \"b\"\n").unwrap();
assert_eq!(charter_state(&path), CharterState::Lines(1));
std::fs::write(&path, "[[lines]]\nid = \"a\"\n").unwrap();
assert!(matches!(charter_state(&path), CharterState::Broken(_)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_context_window_that_names_c_rather_than_c_over_np_is_reported_wrong() {
let cfg = cfg_with_local(1048576, Some(true));
let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
let s = step(&steps, "local-server");
assert_eq!(s.status, Status::Wrong);
assert!(s.detail.contains("262144"), "{}", s.detail);
assert!(s.remedy.is_some(), "and it is fixable without hand-editing");
}
#[test]
fn a_vision_model_nobody_configured_to_use_is_reported_wrong() {
let cfg = cfg_with_local(262144, None); let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
assert_eq!(step(&steps, "local-server").status, Status::Wrong);
}
#[test]
fn a_server_that_is_not_up_is_missing_rather_than_wrong() {
let cfg = cfg_with_local(262144, Some(true));
let steps = plan(&cfg, "local", &facts(None));
assert_eq!(step(&steps, "local-server").status, Status::Missing);
assert!(step(&steps, "local-server").remedy.is_none());
}
#[test]
fn an_unreadable_store_is_unknown_and_offers_nothing() {
let mut f = facts(Some(props(262144, 4, true)));
f.mail_accounts = None;
let steps = plan(&cfg_with_local(262144, Some(true)), "local", &f);
let s = step(&steps, "mail");
assert_eq!(s.status, Status::Unknown);
assert!(s.remedy.is_none(), "unknown must not propose a fix");
}
#[test]
fn a_graph_step_never_offers_to_run_a_graph_source_command() {
let steps = plan(
&cfg_with_local(262144, Some(true)),
"local",
&facts(Some(props(262144, 4, true))),
);
for s in &steps {
if let Some(r) = &s.remedy {
assert!(
!r.argv.iter().any(|a| a == "source"),
"{} would drive the graph's own source CLI: {:?}",
s.id,
r.argv
);
}
}
}
#[test]
fn a_scheduler_is_only_offered_once_a_trigger_exists() {
let cfg = cfg_with_local(262144, Some(true));
let mut f = facts(Some(props(262144, 4, true)));
f.scheduler_installed = false;
f.trigger_count = 0;
assert!(
!plan(&cfg, "local", &f).iter().any(|s| s.id == "scheduler"),
"no triggers means nothing to run; do not offer a runner"
);
f.trigger_count = 2;
let steps = plan(&cfg, "local", &f);
let s = step(&steps, "scheduler");
assert_eq!(s.status, Status::Missing);
assert!(
!s.remedy.as_ref().unwrap().argv.contains(&"add".to_string()),
"offer the runner, never a schedule"
);
}
#[test]
fn verified_settings_are_read_back_from_the_server() {
let got = verified_settings(&props(65536, 4, true));
assert!(got.contains(&("context_window", "65536".into())), "{got:?}");
assert!(got.contains(&("vision", "true".into())), "{got:?}");
assert!(
got.iter().any(|(k, v)| *k == "model" && v.contains("qwen")),
"{got:?}"
);
}
#[test]
fn a_missing_credential_root_counts_zero_rather_than_unknown() {
assert_eq!(count_accounts(Path::new("/no/such/root")), Some(0));
}
}