Skip to main content

mecha_core/
onboarding.rs

1//! What a new install still needs, and the one command that fixes each.
2//!
3//! Two halves, split the way `compact.rs` and `candidate.rs` are split, and
4//! for the same reason: getting onboarding wrong is *silent*. A missing
5//! `context_window` does not error, it makes a long run die at a threshold
6//! nobody set; a missing `vision` does not error, it makes every screenshot
7//! arrive as a line of text. So the deciding is a pure function over
8//! already-gathered facts and is unit-tested without a machine, and the
9//! part that touches the world is thin enough to read.
10//!
11//! **The rule that makes this worth having: never write down a number the
12//! user believes.** `GET /props` reports the served alias, the per-slot
13//! `n_ctx` and whether a projector is loaded. Writing config from *that*
14//! retires a whole class of bug — `context_window` naming `-c` instead of
15//! `-c / -np`, `vision` unset against a multimodal model, `model` naming
16//! weights the server is not serving — none of which anything can detect
17//! later, because each one degrades quietly rather than failing.
18//!
19//! It is the same argument `Sandbox::preflight` makes, one step earlier:
20//! ask the thing itself rather than trusting what was configured about it.
21
22use crate::config::Config;
23use crate::doctor::Remedy;
24use serde::Serialize;
25use std::path::{Path, PathBuf};
26
27/// Where a step stands. Deliberately three-valued: "cannot tell from here"
28/// is a real answer and must not be printed as "not done" — a person told
29/// their mail is unconfigured, when it is merely unreadable from this
30/// process, goes and re-runs an OAuth flow they did not need.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Status {
34    Done,
35    Missing,
36    /// Configured, but something about it disagrees with reality.
37    Wrong,
38    Unknown,
39    /// The owner said they do not want this one.
40    ///
41    /// **Not a fifth shade of "not done" — its opposite.** Every other status
42    /// here describes the machine; this one describes a decision, and the two
43    /// were indistinguishable until it existed: somebody who does not use
44    /// Slack read `not set up` on every `mecha setup` forever, and every
45    /// scripted run exited non-zero over a choice they had already made. The
46    /// same "a dash is never zero" rule [`crate::backlog`] states, one noun
47    /// over — an absence of the thing and a decision against the thing are
48    /// different findings, and a reader that cannot tell them apart turns a
49    /// finished install into a permanent defect list.
50    Declined,
51}
52
53/// One thing a new install might still need.
54#[derive(Debug, Clone, Serialize)]
55pub struct Step {
56    /// Stable slug, so `--json` output can be matched on.
57    pub id: String,
58    pub title: String,
59    pub status: Status,
60    pub detail: String,
61    /// Optional because plenty of steps are somebody else's to do — picking
62    /// a model, deciding whether they want mail at all.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub remedy: Option<Remedy>,
65    /// May the owner say they never want this?
66    ///
67    /// **A property of the step, not of its status**, and the distinction is
68    /// load-bearing: inferring it from `Missing` made *"a provider that can
69    /// answer"* declinable, so a person could decline the one thing without
70    /// which nothing runs and be told `Nothing outstanding.` on an install
71    /// that could not answer a single prompt. Found by running the flow
72    /// rather than by reading it.
73    ///
74    /// True only where "I don't want this" is a coherent sentence — the
75    /// integrations, and the charter. Never for a credential, a server that
76    /// disagrees with its config, or anything else that is the machine being
77    /// wrong rather than a feature going unused.
78    pub optional: bool,
79}
80
81impl Step {
82    fn new(id: &str, title: &str, status: Status, detail: impl Into<String>) -> Self {
83        Step {
84            id: id.into(),
85            title: title.into(),
86            status,
87            detail: detail.into(),
88            remedy: None,
89            optional: false,
90        }
91    }
92    /// Mark a step as one the owner may decline outright.
93    fn optional(mut self) -> Self {
94        self.optional = true;
95        self
96    }
97    fn with(mut self, description: &str, argv: &[&str], needs_terminal: bool) -> Self {
98        self.remedy = Some(Remedy {
99            description: description.into(),
100            argv: argv.iter().map(|s| s.to_string()).collect(),
101            needs_terminal,
102        });
103        self
104    }
105}
106
107/// Everything the impure half gathered, so [`plan`] can stay a function.
108///
109/// A struct rather than a pile of arguments because it is going to grow, and
110/// because a caller that has to remember the order of six booleans will
111/// eventually get one wrong in a way that reads as a working install.
112#[derive(Debug, Clone, Default)]
113pub struct Facts {
114    /// Which helper binaries are on `PATH`. A crates.io install gets each
115    /// from its own `cargo install`, so "is it in the workspace" is the
116    /// wrong question — presence on `PATH` is the one that matters.
117    pub has_mail_binary: bool,
118    pub has_docs_binary: bool,
119    pub has_graph_binary: bool,
120    /// Whether any account/credential store has something in it. `None`
121    /// where the directory could not be read — see [`Status::Unknown`].
122    pub mail_accounts: Option<usize>,
123    pub docs_accounts: Option<usize>,
124    pub slack_linked: Option<bool>,
125    /// What the default provider's server said about itself, when it is
126    /// local and answered.
127    pub props: Option<crate::provider::preflight::Props>,
128    /// Whether the configured provider has a usable credential.
129    pub provider_credential: bool,
130    /// Whether a global config file exists at all.
131    ///
132    /// `Config::load_global` tolerates its absence and returns defaults,
133    /// which is right — mecha must work before anybody has written one — but
134    /// it meant a new install was never told the file exists or where it
135    /// lives, and the first thing anyone needs to change lives in it.
136    pub config_file: bool,
137    /// What the loopback probe found — **including whether it ran at all**.
138    ///
139    /// See [`LocalProbe`]. The point of the probe is to turn *"`anthropic` has
140    /// no usable credential"* into *"there is a server running right here,
141    /// shall I write it down"*, which is the difference between a remedy and
142    /// a diagnosis.
143    pub local_probe: LocalProbe,
144    /// Whether a trigger scheduler is running or installed.
145    pub scheduler_installed: bool,
146    pub trigger_count: usize,
147    /// What the owner's charter is doing, read through the ordinary loader.
148    pub charter: CharterState,
149    /// Step ids the owner has said they do not want, from
150    /// [`read_declined`].
151    ///
152    /// An unreadable store yields an **empty** set rather than a failure, and
153    /// the direction is deliberate: showing a step somebody declined is a
154    /// nuisance, hiding one they never declined is a silently incomplete
155    /// install. This is the one place in this module where unknown resolves
156    /// towards *more* noise, because here noise is the safe side. `setup`
157    /// says out loud that the store could not be read — **on stderr, and
158    /// before the `--json` return**, so the scriptable surface carries it
159    /// too and stdout stays a parseable array.
160    pub declined: std::collections::BTreeSet<String>,
161}
162
163/// A local server nobody has configured yet: where it is, and what it says
164/// about itself.
165#[derive(Debug, Clone)]
166pub struct LocalServer {
167    pub base_url: String,
168    pub props: crate::provider::preflight::Props,
169}
170
171/// What the loopback probe found — **three-valued, because two of these were
172/// being reported as the third.**
173///
174/// An `Option` here collapsed *"asked, and nothing answered"* into *"never
175/// asked"*, and the step then printed the first sentence for both: somebody
176/// with a configured-but-unselected `[providers.local]` and a llama-server
177/// happily running on it was told **"Nothing was answering at
178/// http://127.0.0.1:8080 when this ran"** — a fact asserted with no
179/// observation behind it, which is this module's own header rule
180/// (*never write down a number the user merely believes*) inverted. The
181/// distinction is the same one [`Status::Unknown`] and [`Facts::declined`]
182/// both keep: an absence and an unasked question are different findings.
183#[derive(Debug, Clone, Default)]
184pub enum LocalProbe {
185    /// No probe was attempted — something can already answer, or a local
186    /// provider is configured and it is not this module's place to go looking
187    /// for a second one.
188    #[default]
189    NotAttempted,
190    /// Asked, and nothing that looks like a model server answered.
191    NothingAnswered,
192    /// Asked, and found a server no provider names.
193    Found(LocalServer),
194}
195
196/// Does this `/props` answer come from something that is actually a model
197/// server?
198///
199/// **`preflight::Props` defaults every field on purpose**, so a llama-server
200/// version bump costs a check rather than a parse failure — which means `{}`
201/// with a 200 deserializes perfectly, and *any* JSON service answering on
202/// :8080 (a catch-all API, a proxy) parses as a `Props`. That tolerance is
203/// right where it is used for a server the owner has already told us about,
204/// and wrong here, where the whole question is whether this is a model server
205/// at all: without a check, an unrelated service gets announced as "already
206/// serving (an unnamed model)" and one `y` repoints `default_provider` at it,
207/// with no `model` and no `context_window` — the two settings this module's
208/// header is about, both of which degrade quietly rather than failing.
209///
210/// So the discovery site asks for one thing only llama-server supplies.
211/// Deliberately a *disjunction* rather than a required pair: either field
212/// alone is enough to say "a model server answered", and demanding both would
213/// reject a real server whose build reports one of them differently — which
214/// is the forward-compatibility `Props` was made tolerant for.
215pub fn answers_like_a_model_server(props: &crate::provider::preflight::Props) -> bool {
216    props.model_alias.is_some() || props.default_generation_settings.n_ctx.is_some()
217}
218
219/// Where to look for a local server when the configured provider cannot
220/// answer.
221///
222/// **Loopback only, and only on an install that has nothing working.** Every
223/// other network call in this module is to a server the config already names;
224/// this one is a guess, so it is confined to the address the documentation
225/// tells people to serve on and to a machine that is otherwise stuck. Nothing
226/// leaves the box, and a `mecha setup` on a working install makes no extra
227/// call at all.
228///
229/// One address rather than a scan: probing a range would be a port scanner in
230/// a setup tool, and the payoff — finding a server on a port nobody
231/// documented — is not worth a command that behaves like one.
232pub fn local_probe_candidates() -> &'static [&'static str] {
233    &["http://127.0.0.1:8080"]
234}
235
236/// What `~/.mecha/charter.toml` is doing — the five answers a reader has to
237/// tell apart.
238///
239/// `Absent` and `Empty` are **not** folded together, for the reason
240/// [`crate::doctor`]'s own charter check keeps them apart: a file that parses
241/// cleanly to zero lines is an authoring mistake by construction (nobody
242/// writes an empty charter on purpose), where no file at all is the ordinary
243/// state of a fresh install and is the one this module exists to offer
244/// something about.
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub enum CharterState {
247    /// No file. A new install, and the case worth prompting.
248    #[default]
249    Absent,
250    /// A file with no `[[line]]` entries — a template nobody filled in, or an
251    /// edit that removed the last line.
252    Empty,
253    /// `n` lines, loading cleanly.
254    Lines(usize),
255    /// It exists and does not load: every run is starting un-chartered.
256    Broken(String),
257    /// Could not be established from here.
258    Unknown,
259}
260
261/// What still needs doing. Empty means a complete install.
262///
263/// Ordered by what blocks what: a provider that cannot answer makes every
264/// step below it untestable, so it comes first, and integrations come before
265/// scheduling because an unattended run with nothing wired to it is a cron
266/// slot that prints "no mail configured" every morning.
267pub fn plan(cfg: &Config, provider_name: &str, facts: &Facts) -> Vec<Step> {
268    let mut steps = Vec::new();
269    let local = cfg
270        .providers
271        .get(provider_name)
272        .filter(|p| p.kind == "local");
273
274    // --- 0. somewhere to put settings at all
275    //
276    // `Config::load_global` returns defaults when there is no file, which is
277    // right — mecha must work before anybody has written one — but it also
278    // meant nothing ever told a new install that the file exists or where it
279    // lives, while every remaining step here is fixed by editing it.
280    if !facts.config_file {
281        steps.push(
282            Step::new(
283                "config-file",
284                "A config file to change things in",
285                Status::Missing,
286                concat!(
287                    "Everything runs on defaults until there is one, and defaults are ",
288                    "fine — but the model, the server and the budgets are all set here, ",
289                    "so the first thing anyone needs is a file to put them in. It is ",
290                    "written commented, so it doubles as the list of what is adjustable."
291                ),
292            )
293            .with(
294                "Write a commented starter config to ~/.mecha/config.toml.",
295                &["mecha", "config", "init"],
296                false,
297            ),
298        );
299    }
300
301    // --- 1. can anything answer at all
302    if !facts.provider_credential && local.is_none() {
303        steps.push(provider_step(provider_name, cfg, facts));
304    }
305
306    // --- 2. a local server, checked against itself
307    if let Some(pcfg) = local {
308        match &facts.props {
309            None => steps.push(Step::new(
310                "local-server",
311                "The local server is reachable",
312                Status::Missing,
313                format!(
314                    "Nothing answered at {}. Start the server before the rest of this can be \
315                     checked — every value below is read back from it rather than guessed.",
316                    pcfg.base_url.as_deref().unwrap_or("(no base_url)")
317                ),
318            )),
319            Some(props) => {
320                let mismatches =
321                    crate::provider::preflight::disagreements(provider_name, pcfg, props);
322                if mismatches.is_empty() {
323                    steps.push(Step::new(
324                        "local-server",
325                        "The local server agrees with the config",
326                        Status::Done,
327                        format!(
328                            "serving {}, {} tokens per slot, vision {}",
329                            props.model_alias.as_deref().unwrap_or("(unnamed)"),
330                            props
331                                .default_generation_settings
332                                .n_ctx
333                                .map(|n| n.to_string())
334                                .unwrap_or_else(|| "?".into()),
335                            if props.modalities.vision { "on" } else { "off" },
336                        ),
337                    ));
338                } else {
339                    steps.push(
340                        Step::new(
341                            "local-server",
342                            "The config disagrees with what is served",
343                            Status::Wrong,
344                            mismatches.join("\n\n"),
345                        )
346                        .with(
347                            "Rewrite these from what the server reports, rather than editing \
348                             them by hand.",
349                            &["mecha", "setup", "--write"],
350                            false,
351                        ),
352                    );
353                }
354            }
355        }
356    }
357
358    steps.extend(integration_steps(facts));
359    steps.push(charter_step(&facts.charter));
360
361    // --- 4. scheduling, and nothing is turned on for anyone
362    //
363    // A scheduled unattended agent run on a machine holding your mail is
364    // never a default — the same argument that keeps `[[trigger]]` out of a
365    // project's `mecha.toml`, where a cloned repository would be handing
366    // itself a cron slot. What is offered is the *scheduler*, not a schedule.
367    if !facts.scheduler_installed && facts.trigger_count > 0 {
368        steps.push(
369            Step::new(
370                "scheduler",
371                "Something to fire the triggers",
372                Status::Missing,
373                format!(
374                    "{} trigger(s) are defined and nothing is running them. Being due is a \
375                     function of the ledger and the clock, so any of a systemd timer, a \
376                     crontab line running `mecha trigger tick`, or `mecha trigger daemon` \
377                     will do.",
378                    facts.trigger_count
379                ),
380            )
381            .with(
382                "Print a systemd user unit for the daemon, to review before installing.",
383                &["mecha", "trigger", "daemon", "--print-unit"],
384                false,
385            ),
386        );
387    }
388
389    // Applied last, over the finished list, so a decline can never change
390    // what a step *says* — only whether it is still being asked for. A
391    // declined step keeps its detail and loses its remedy, because a remedy
392    // is an offer and this one has been answered.
393    //
394    // **A `Done` step is never overwritten.** Declining Slack and then
395    // linking it anyway (from the phone, from `mecha slack auth` directly)
396    // must read as done rather than as refused — the machine's state is a
397    // fact and the decision is a preference, and where they disagree the
398    // fact wins. Otherwise a stale decline would hide a working integration
399    // from its own owner. The same reason `Wrong` and `Broken` survive it:
400    // "I don't want mail" is not "I don't want to be told my mail is
401    // broken", and a decline that could suppress a failure would be a
402    // silently-degrading guard.
403    //
404    // Gated on `optional` as well as on the status, so the guarantee holds
405    // against a **hand-edited** store too: `setup-declined.json` is a plain
406    // file, and a decline that only the prompt refused to record would be
407    // one anybody could add with a text editor.
408    for step in &mut steps {
409        if step.optional
410            && matches!(step.status, Status::Missing)
411            && facts.declined.contains(&step.id)
412        {
413            step.status = Status::Declined;
414            step.remedy = None;
415        }
416    }
417
418    steps
419}
420
421/// The one step that blocks every other, and the only one whose remedy used
422/// to be a *viewer*.
423///
424/// It said `anthropic has no usable credential` and offered
425/// `mecha config show` — which displays a file and fixes nothing. The step
426/// that makes all the others untestable was the step with no path forward,
427/// which is precisely backwards.
428///
429/// There are exactly two ways out and they are not symmetric, so the step
430/// says which one this machine is actually in:
431///
432/// - **A local server is already running.** The common case for anybody who
433///   followed the hardware pages first, and a real remedy: every value that
434///   would be written is read back off `/props`, so the *existence* of the
435///   provider is as much a measured fact as its context window.
436/// - **No server.** Then the fix is a secret, and a secret is the one thing
437///   this tool must not write — mecha stores the **name of an environment
438///   variable**, never a key, so a config file can be read, copied or
439///   committed without leaking one. Naming the exact variable and the exact
440///   line is the most a remedy can honestly be here, so the detail carries
441///   both rather than pointing at a command that would only show what is
442///   already known.
443fn provider_step(provider_name: &str, cfg: &Config, facts: &Facts) -> Step {
444    let env_var = cfg
445        .providers
446        .get(provider_name)
447        .and_then(|p| p.api_key_env.clone());
448
449    let step = |detail: String| {
450        Step::new(
451            "provider-credential",
452            "A provider that can answer",
453            Status::Missing,
454            detail,
455        )
456    };
457
458    // 1. Something is serving, and no provider names it. The only branch with
459    //    a command behind it, because it is the only one where the fix is a
460    //    fact mecha can read rather than a secret only the owner has.
461    if let LocalProbe::Found(found) = &facts.local_probe {
462        let serving = found
463            .props
464            .model_alias
465            .as_deref()
466            .unwrap_or("(an unnamed model)");
467        return step(format!(
468            concat!(
469                "`{provider_name}` has no usable credential — but something is ",
470                "already serving {serving} at {url}, and nothing in the config names ",
471                "it. Writing it down reads every value back off the server rather ",
472                "than asking you for any of them, which is the only way ",
473                "`context_window` ever gets to be the per-slot figure rather than ",
474                "`-c`."
475            ),
476            provider_name = provider_name,
477            serving = serving,
478            url = found.base_url
479        ))
480        .with(
481            "Write the local server down as a provider, from what it reports about itself.",
482            &["mecha", "setup", "--write"],
483            false,
484        );
485    }
486
487    // 2. A local provider is configured and simply is not the selected one.
488    //
489    //    **This branch is why the probe's absence had to become visible.**
490    //    Nothing probes when a local provider exists, so the "nothing
491    //    answered" sentence below would be printed about an address never
492    //    asked — and this is exactly the config that hits it: a
493    //    `[providers.local]` on :8080 with a server running on it, and
494    //    `default_provider` still pointing at a cloud provider whose key was
495    //    never exported. Before this, that person got one step telling them
496    //    to do the thing they had already done, and was never told the actual
497    //    one-line fix.
498    if let Some((name, pcfg)) = cfg
499        .providers
500        .iter()
501        .find(|(name, p)| p.kind == "local" && *name != provider_name)
502    {
503        let where_it_points = pcfg
504            .base_url
505            .as_deref()
506            .map(|u| format!(" ({u})"))
507            .unwrap_or_default();
508        return step(format!(
509            concat!(
510                "`{provider_name}` has no usable credential — but you already have a ",
511                "local provider configured, `{name}`{where_it_points}, and it is not ",
512                "the default. Point `default_provider` at it in the config, or select ",
513                "it for one run with `-p {name}`. Nothing here probed {name}: whether ",
514                "it is up is what `mecha setup` reports once it is the one being used."
515            ),
516            provider_name = provider_name,
517            name = name,
518            where_it_points = where_it_points
519        ));
520    }
521
522    // 3. Nothing configured can answer and nothing was found. The fix is a
523    //    secret, and a secret is the one thing this tool must not write —
524    //    mecha stores the **name of an environment variable**, never a key, so
525    //    a config file can be read, copied or committed without leaking one.
526    //    Naming the exact variable is the most a remedy can honestly be, so
527    //    the detail carries it rather than pointing at a command that would
528    //    only show what is already known.
529    let key_line = match &env_var {
530        Some(var) => format!(
531            concat!(
532                "Set `{var}` in your shell (`export {var}=…`) and start a new one — ",
533                "mecha stores the variable's *name* in the config and never the key ",
534                "itself, so nothing here has to hold a secret."
535            ),
536            var = var
537        ),
538        // A provider configured with no `api_key_env` at all cannot be fixed
539        // by exporting anything, and saying "set the variable it names" about
540        // a provider that names none is the kind of instruction that sends
541        // somebody looking for a typo they did not make.
542        None => format!(
543            concat!(
544                "`{provider_name}` names no `api_key_env`, so there is no variable to ",
545                "set — give it one, or point `default_provider` at a local server."
546            ),
547            provider_name = provider_name
548        ),
549    };
550    // **Only said when a probe actually ran.** Reporting "nothing was
551    // answering at X" after not asking is the same class of claim as writing
552    // down a `context_window` nobody read off the wire.
553    let local_line = match &facts.local_probe {
554        LocalProbe::NothingAnswered => format!(
555            concat!(
556                "2. Run a model locally — the target rather than the fallback. Serve ",
557                "it, then `mecha setup --write` reads the settings off it. Nothing ",
558                "was answering at {tried} when this ran."
559            ),
560            tried = local_probe_candidates().join(", ")
561        ),
562        _ => concat!(
563            "2. Run a model locally — the target rather than the fallback. Serve it, ",
564            "then `mecha setup --write` reads the settings off it."
565        )
566        .to_string(),
567    };
568    step(format!(
569        "Nothing can answer a prompt yet, so nothing below this can be tested. \
570         Two ways out.\n\n1. {key_line}\n\n{local_line}"
571    ))
572}
573
574/// The charter: what mecha is for, in the owner's own words.
575///
576/// **Nothing anywhere used to mention this.** `doctor`'s charter check
577/// returns early on a file that does not exist — correctly, because never
578/// having written one is not a fault — so a fresh install had no surface at
579/// all that named the feature, and a never-written charter was
580/// indistinguishable from a deliberately-empty one. Discovery was scrolling
581/// the TUI's `/help` or finding the gear on the web page. That is the wrong
582/// way round for the one document that says what the machine is *for*.
583///
584/// The remedy hands over `$EDITOR`; it never composes a line. See
585/// [`crate::charter`]'s module doc for why that distinction, rather than the
586/// absence of a verb, is the actual invariant.
587fn charter_step(state: &CharterState) -> Step {
588    const WHY: &str = concat!(
589        "A short ranked list of standing priorities, in your own words, that rides in ",
590        "every run's prompt. Order is rank: when two conflict, the higher one wins ",
591        "outright. mecha never writes a line of it."
592    );
593    match state {
594        CharterState::Lines(n) => Step::new(
595            "charter",
596            "Your charter",
597            Status::Done,
598            format!(
599                "{n} standing priorit{} in rank order",
600                if *n == 1 { "y" } else { "ies" }
601            ),
602        ),
603        // Distinguished from `Absent` in the *detail*, not the status: both
604        // are "nothing rides in the prompt", and both are fixed by the same
605        // command, but only one of them is a half-finished edit somebody
606        // should be told about rather than a fresh install.
607        CharterState::Empty => Step::new(
608            "charter",
609            "Your charter",
610            Status::Missing,
611            format!(
612                "The file exists with no `[[line]]` entries yet, so nothing from it rides \
613                 in any prompt. {WHY}"
614            ),
615        )
616        .with(
617            "Open the charter in $EDITOR.",
618            &["mecha", "charter", "edit"],
619            true,
620        )
621        .optional(),
622        CharterState::Absent => Step::new(
623            "charter",
624            "Your charter",
625            Status::Missing,
626            format!("Nothing written yet — every run is proceeding un-chartered. {WHY}"),
627        )
628        .with(
629            "Create it from a commented template and open it in $EDITOR.",
630            &["mecha", "charter", "edit"],
631            true,
632        )
633        .optional(),
634        // `Wrong`, not `Missing`: there is a document and it disagrees with
635        // what a run can load, which is a different thing to do about it —
636        // and unlike the two above, this one is already a `doctor` finding,
637        // because it is a fault rather than an absence.
638        CharterState::Broken(e) => Step::new(
639            "charter",
640            "Your charter does not load",
641            Status::Wrong,
642            format!(
643                "{e}
644
645Every run is starting un-chartered until this parses."
646            ),
647        )
648        .with(
649            "Open the charter in $EDITOR and fix it.",
650            &["mecha", "charter", "edit"],
651            true,
652        ),
653        CharterState::Unknown => Step::new(
654            "charter",
655            "Your charter",
656            Status::Unknown,
657            "the charter could not be read from here.",
658        ),
659    }
660}
661
662/// The integrations, each detected the same way: is the binary there, and has
663/// anything been authorised through it.
664///
665/// **mecha's own integrations are offered as commands to run; the graph's are
666/// only ever named.** mecha reaches the knowledge graph through the MCP tool
667/// surface and nothing else — no dependency, no second reader of its store —
668/// and a setup flow that drove `mecha-graph source add` would be exactly the
669/// coupling that rule exists to prevent. So a graph source is a sentence
670/// pointing at that project's own CLI, never a command this one spawns.
671fn integration_steps(facts: &Facts) -> Vec<Step> {
672    let mut steps = Vec::new();
673
674    steps.push(match (facts.has_mail_binary, facts.mail_accounts) {
675        (false, _) => Step::new(
676            "mail",
677            "Mail and calendar",
678            Status::Missing,
679            "`mecha-mail` is not on PATH. It is a separate crate, and optional — nothing else \
680             needs it.",
681        )
682        .with(
683            "Install the mail and calendar MCP servers.",
684            &["cargo", "install", "mecha-mail", "--locked"],
685            false,
686        )
687        .optional(),
688        (true, Some(0)) => Step::new(
689            "mail",
690            "Mail and calendar",
691            Status::Missing,
692            "`mecha-mail` is installed with no accounts authorised. The model names an \
693             *account*, never a provider, so add one per mailbox.",
694        )
695        .with(
696            "Authorise a mailbox. Needs a browser, or `--paste` over SSH.",
697            &["mecha-mail", "auth", "personal", "--provider", "google"],
698            true,
699        )
700        .optional(),
701        (true, Some(n)) => Step::new(
702            "mail",
703            "Mail and calendar",
704            Status::Done,
705            format!("{n} account(s) authorised"),
706        ),
707        (true, None) => Step::new(
708            "mail",
709            "Mail and calendar",
710            Status::Unknown,
711            "`mecha-mail` is installed; its credential store could not be read from here.",
712        ),
713    });
714
715    steps.push(match (facts.has_docs_binary, facts.docs_accounts) {
716        (false, _) => Step::new(
717            "docs",
718            "Google Docs, Sheets and Slides",
719            Status::Missing,
720            "`mecha-docs` ships with the mail crate. Under `drive.file` it reaches only files \
721             it created or you handed it in Google's own picker — which is the reason to want \
722             it, and no instruction inside a run can widen that.",
723        )
724        .with(
725            "Install the documents server (same crate as mail).",
726            &["cargo", "install", "mecha-mail", "--locked"],
727            false,
728        )
729        .optional(),
730        (true, Some(0)) => Step::new(
731            "docs",
732            "Google Docs, Sheets and Slides",
733            Status::Missing,
734            "`mecha-docs` is installed with no account authorised.",
735        )
736        .with(
737            "Authorise Drive access. Use `--paste` if there is no browser here.",
738            &["mecha-docs", "auth", "personal"],
739            true,
740        )
741        .optional(),
742        (true, Some(n)) => Step::new(
743            "docs",
744            "Google Docs, Sheets and Slides",
745            Status::Done,
746            format!("{n} account(s) authorised"),
747        ),
748        (true, None) => Step::new(
749            "docs",
750            "Google Docs, Sheets and Slides",
751            Status::Unknown,
752            "installed; the credential store could not be read from here.",
753        ),
754    });
755
756    steps.push(match facts.slack_linked {
757        Some(true) => Step::new(
758            "slack",
759            "Slack as a remote control",
760            Status::Done,
761            "linked to a workspace",
762        ),
763        Some(false) => Step::new(
764            "slack",
765            "Slack as a remote control",
766            Status::Missing,
767            "Watch a run from a phone, approve what it wants to send, and hand files in and \
768             out. The owner is bound by a nonce printed on this machine, so proving shell \
769             access here is what claims it.",
770        )
771        .with(
772            "Start the Slack setup, which prints the binding nonce.",
773            &["mecha", "slack", "auth"],
774            true,
775        )
776        .optional(),
777        None => Step::new(
778            "slack",
779            "Slack as a remote control",
780            Status::Unknown,
781            "the binding store could not be read from here.",
782        ),
783    });
784
785    steps.push(if facts.has_graph_binary {
786        Step::new(
787            "graph",
788            "The personal knowledge graph",
789            Status::Done,
790            "`mecha-graph-mcp` is on PATH. Its own sources — ambient conversations, a \
791             calendar ICS feed, Slack, messages, mail — are configured with `mecha-graph \
792             source`, in that project. mecha reaches the graph only through its MCP tools \
793             and deliberately knows nothing else about it.",
794        )
795    } else {
796        Step::new(
797            "graph",
798            "The personal knowledge graph",
799            Status::Missing,
800            "Memory: who people are, what happened when. A separate project, wired in as an \
801             MCP server whose reads are marked untrusted — a graph fed by mail and messages \
802             holds third-party text by construction.",
803        )
804        .with(
805            "Install the graph's MCP server.",
806            &["cargo", "install", "mecha-graph-mcp", "--locked"],
807            false,
808        )
809        .optional()
810    });
811
812    steps
813}
814
815/// The values a local server reports about itself, ready to be written down.
816///
817/// Returned rather than applied, so the caller can show them before changing
818/// anything: this rewrites settings a person may have had reasons for, and
819/// "here is what I would write" is a different act from writing it.
820pub fn verified_settings(props: &crate::provider::preflight::Props) -> Vec<(&'static str, String)> {
821    let mut out = Vec::new();
822    if let Some(alias) = &props.model_alias {
823        out.push(("model", toml_string(alias)));
824    }
825    // The per-slot figure, which is the one a request actually gets. Reading
826    // it back is what makes `-c` versus `-c / -np` a non-question.
827    if let Some(n) = props.default_generation_settings.n_ctx {
828        out.push(("context_window", n.to_string()));
829    }
830    out.push(("vision", props.modalities.vision.to_string()));
831    out
832}
833
834/// A TOML string literal, escaped the way **TOML** escapes.
835///
836/// **Not `format!("{s:?}")`, which is Rust escaping.** `str`'s `Debug`
837/// renders a control character as `\u{1b}`; TOML's `\u` takes exactly four
838/// hex digits and no braces, so that value is written into a config file that
839/// then fails to parse. Quotes, backslashes, tabs and newlines happen to
840/// escape compatibly, which is why this survived unnoticed — only
841/// non-printables bite.
842///
843/// It matters here more than it looks because of where these bytes come from:
844/// a server's own `/props`, on the discovery path, where the server is one
845/// nobody has named. [`answers_like_a_model_server`] establishes that
846/// something answering `:8080` may be a stranger; this makes sure a stranger's
847/// answer cannot produce a config that no later `mecha` command can load.
848pub fn toml_string(s: &str) -> String {
849    toml::Value::String(s.to_string()).to_string()
850}
851
852/// Count the per-account directories under a credential root.
853///
854/// `None` when the root cannot be read *and does not simply not exist* — an
855/// absent directory is a confident zero, where an unreadable one is genuinely
856/// unknown and must not be reported as "no accounts".
857pub fn count_accounts(root: &Path) -> Option<usize> {
858    match std::fs::read_dir(root) {
859        Ok(entries) => Some(
860            entries
861                .flatten()
862                .filter(|e| e.path().join("oauth.json").is_file())
863                .count(),
864        ),
865        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
866        Err(_) => None,
867    }
868}
869
870/// Where declines are recorded.
871///
872/// **In `~/.mecha/`, never in layered config**, on the rule triggers, skills
873/// and the charter all keep: a project's `mecha.toml` arrives with a cloned
874/// repository, and a repo that could decline your integrations would be
875/// deciding what your machine offers you. There is deliberately no config
876/// field pointing anywhere else, which is the same way the other three keep
877/// the guarantee — by having no configurable path at all rather than by
878/// asking callers to choose the global loader.
879pub fn declined_path(home: &Path) -> PathBuf {
880    home.join("setup-declined.json")
881}
882
883/// Step ids the owner has said they do not want.
884///
885/// An absent file is a confident empty set; an unreadable or malformed one
886/// is `None`, so the caller can *say so* rather than quietly proceeding as
887/// though nothing had been declined. See [`Facts::declined`] for why the
888/// resolution of `None` is nonetheless "offer everything".
889pub fn read_declined(home: &Path) -> Option<std::collections::BTreeSet<String>> {
890    let path = declined_path(home);
891    match std::fs::read_to_string(&path) {
892        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
893            Some(std::collections::BTreeSet::new())
894        }
895        Err(_) => None,
896        Ok(text) => serde_json::from_str::<Declined>(&text)
897            .ok()
898            .map(|d| d.declined),
899    }
900}
901
902/// Record that the owner does not want `id`. Idempotent.
903///
904/// Read-modify-write rather than append, because this is a *set* and the
905/// file is small enough that the whole document is the unit. Written to a
906/// temp sibling and renamed, so a crash mid-write leaves the previous
907/// answer whole rather than a truncated file that reads as "nothing
908/// declined" — which would silently re-offer everything.
909///
910/// `Ok(Some(path))` says an unreadable store was **set aside** at `path`
911/// rather than overwritten — see [`salvage_unreadable`]. The caller is
912/// expected to say so; losing somebody's recorded answers is not a thing to
913/// do quietly.
914pub fn decline(home: &Path, id: &str) -> std::io::Result<DeclineWrite> {
915    let (mut set, salvaged) = read_for_write(home);
916    let changed = set.insert(id.to_string());
917    write_declined(home, &set)?;
918    Ok(DeclineWrite { salvaged, changed })
919}
920
921/// Take one back out — the undo, so a decline is a preference rather than a
922/// door that locks behind you. `id` of `None` clears every one.
923pub fn undecline(home: &Path, id: Option<&str>) -> std::io::Result<DeclineWrite> {
924    let (mut set, salvaged) = read_for_write(home);
925    let changed = match id {
926        Some(id) => set.remove(id),
927        None => {
928            let had = !set.is_empty();
929            set.clear();
930            had
931        }
932    };
933    write_declined(home, &set)?;
934    Ok(DeclineWrite { salvaged, changed })
935}
936
937/// What a write to the decline store actually did.
938///
939/// **`changed` is graded off the set, never off the argument.** `undecline`
940/// used to discard `BTreeSet::remove`'s answer, so a typo'd id wrote the set
941/// back untouched and the caller still announced *"`slak` will be offered
942/// again"* and exited 0 — the person believed the way back had been taken,
943/// ran `mecha setup`, and saw `you said no thanks` on the step they had just
944/// "restored", with nothing anywhere saying the two disagreed. A claim about
945/// a local write is worth checking against the write, which is the same rule
946/// [`salvage_unreadable`] one line up exists for and the same rule the
947/// harness applies to everything a *model* says about its own work.
948#[derive(Debug, Default, Clone, PartialEq, Eq)]
949pub struct DeclineWrite {
950    /// Where an unreadable store was set aside, when it had to be.
951    pub salvaged: Option<PathBuf>,
952    /// Whether the recorded set actually changed.
953    pub changed: bool,
954}
955
956/// The set to modify, and where the previous file went if it could not be
957/// read.
958///
959/// **`unwrap_or_default()` here was this module's own rule inverted.**
960/// [`read_declined`] answers `None` for an unreadable or malformed store
961/// precisely so a caller can tell *unknown* from *empty* — and collapsing it
962/// to empty on the write path then **persisted** the collapse: somebody with
963/// a typo in `setup-declined.json` saw `setup`'s honest "could not be read"
964/// warning, answered `never` to one step, and had the file rewritten with
965/// exactly that one id, every previously recorded answer gone with no further
966/// word. [`write_declined`]'s own comment argues that a partial write "would
967/// silently re-offer everything"; this path was doing it on purpose.
968///
969/// So the bytes are kept. Same move `mecha setup --write` makes before
970/// editing a config it did not author: a file somebody may have meant
971/// something by is moved aside, never overwritten.
972fn read_for_write(home: &Path) -> (std::collections::BTreeSet<String>, Option<PathBuf>) {
973    match read_declined(home) {
974        Some(set) => (set, None),
975        None => (Default::default(), salvage_unreadable(home)),
976    }
977}
978
979/// Move an unreadable decline store aside so the write about to happen
980/// cannot destroy it. Best-effort: a salvage that fails must not stop the
981/// answer being recorded, or a read-only directory would make `never`
982/// permanently unavailable.
983fn salvage_unreadable(home: &Path) -> Option<PathBuf> {
984    let path = declined_path(home);
985    // Stamped, so a second corruption later does not overwrite the first
986    // salvage — the whole point here is that nothing is lost quietly.
987    let aside = path.with_extension(format!(
988        "json.unreadable.{}",
989        std::time::SystemTime::now()
990            .duration_since(std::time::UNIX_EPOCH)
991            .map(|d| d.as_secs())
992            .unwrap_or(0)
993    ));
994    std::fs::rename(&path, &aside).ok().map(|()| aside)
995}
996
997fn write_declined(home: &Path, set: &std::collections::BTreeSet<String>) -> std::io::Result<()> {
998    let path = declined_path(home);
999    if let Some(parent) = path.parent() {
1000        std::fs::create_dir_all(parent)?;
1001    }
1002    let body = serde_json::to_string_pretty(&Declined {
1003        declined: set.clone(),
1004    })
1005    .map_err(std::io::Error::other)?;
1006    // Same directory, so the rename cannot cross a filesystem — the shape
1007    // `serve::settings`' charter save already uses, and for the same reason.
1008    let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
1009    std::fs::write(&tmp, body)?;
1010    std::fs::rename(&tmp, &path)
1011}
1012
1013/// The file's own shape. A named field rather than a bare array so the
1014/// document can grow a sibling (a timestamp, a reason) without the next
1015/// version having to guess what a top-level list meant.
1016#[derive(Debug, Default, Serialize, serde::Deserialize)]
1017struct Declined {
1018    #[serde(default)]
1019    declined: std::collections::BTreeSet<String>,
1020}
1021
1022/// Read the charter the way a run does, for [`Facts`].
1023///
1024/// Through [`crate::charter::Charter::load`] rather than by looking at the
1025/// file, because the question is not "is there a file" but "would a run get
1026/// anything from it" — and those differ for a template nobody filled in and
1027/// for a document with a typo'd table name.
1028pub fn charter_state(path: &Path) -> CharterState {
1029    match std::fs::metadata(path) {
1030        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return CharterState::Absent,
1031        Err(_) => return CharterState::Unknown,
1032        Ok(_) => {}
1033    }
1034    match crate::charter::Charter::load(path) {
1035        Err(e) => CharterState::Broken(format!("{e:#}")),
1036        Ok(c) if c.is_empty() => CharterState::Empty,
1037        Ok(c) => CharterState::Lines(c.lines().len()),
1038    }
1039}
1040
1041/// Is `name` runnable from `PATH`?
1042pub fn on_path(name: &str) -> bool {
1043    let Some(path) = std::env::var_os("PATH") else {
1044        return false;
1045    };
1046    std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052    use crate::provider::preflight::{GenerationSettings, Modalities, Props};
1053
1054    fn cfg_with_local(context_window: u64, vision: Option<bool>) -> Config {
1055        let mut cfg = Config::default();
1056        let mut p = cfg.providers.get("anthropic").cloned().unwrap();
1057        p.kind = "local".into();
1058        p.model = Some("qwen3.6-35b-a3b".into());
1059        p.base_url = Some("http://127.0.0.1:8080".into());
1060        p.api_key_env = None;
1061        p.context_window = Some(context_window);
1062        p.vision = vision;
1063        cfg.providers.insert("local".into(), p);
1064        cfg
1065    }
1066
1067    fn props(n_ctx: u64, slots: u64, vision: bool) -> Props {
1068        Props {
1069            model_alias: Some("qwen3.6-35b-a3b".into()),
1070            total_slots: Some(slots),
1071            modalities: Modalities { vision },
1072            default_generation_settings: GenerationSettings { n_ctx: Some(n_ctx) },
1073        }
1074    }
1075
1076    fn facts(props: Option<Props>) -> Facts {
1077        Facts {
1078            provider_credential: true,
1079            props,
1080            mail_accounts: Some(1),
1081            docs_accounts: Some(1),
1082            slack_linked: Some(true),
1083            has_mail_binary: true,
1084            has_docs_binary: true,
1085            has_graph_binary: true,
1086            scheduler_installed: true,
1087            trigger_count: 0,
1088            // A complete install has a charter and a config file, so
1089            // `everything_configured…` keeps meaning what it says.
1090            charter: CharterState::Lines(3),
1091            config_file: true,
1092            local_probe: LocalProbe::NotAttempted,
1093            declined: Default::default(),
1094        }
1095    }
1096
1097    fn step<'a>(steps: &'a [Step], id: &str) -> &'a Step {
1098        steps.iter().find(|s| s.id == id).expect("step missing")
1099    }
1100
1101    /// A complete install has nothing to say about itself.
1102    #[test]
1103    fn everything_configured_and_agreeing_reports_no_work() {
1104        let cfg = cfg_with_local(262144, Some(true));
1105        let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
1106        assert!(
1107            steps.iter().all(|s| s.status == Status::Done),
1108            "unexpected work: {:?}",
1109            steps
1110                .iter()
1111                .filter(|s| s.status != Status::Done)
1112                .map(|s| &s.id)
1113                .collect::<Vec<_>>()
1114        );
1115    }
1116
1117    /// A fresh install is *offered* a charter, and the offer never composes
1118    /// one.
1119    ///
1120    /// The gap this closes: `doctor::check_charter` returns early on a file
1121    /// that does not exist — right, because never having written one is not
1122    /// a fault — so before this step nothing on any surface named the
1123    /// feature to a new user at all.
1124    #[test]
1125    fn a_fresh_install_is_offered_a_charter_and_the_offer_authors_nothing() {
1126        let cfg = cfg_with_local(262144, Some(true));
1127        let mut f = facts(Some(props(262144, 4, true)));
1128        f.charter = CharterState::Absent;
1129        let steps = plan(&cfg, "local", &f);
1130        let charter = step(&steps, "charter");
1131        assert_eq!(charter.status, Status::Missing);
1132        let remedy = charter.remedy.as_ref().expect("a fresh charter is offered");
1133        assert_eq!(remedy.argv, ["mecha", "charter", "edit"]);
1134        assert!(
1135            remedy.needs_terminal,
1136            "handing over $EDITOR needs a keyboard"
1137        );
1138        // The offer must not put words in anyone's mouth: the *only* text
1139        // this module supplies about a charter is a description of what one
1140        // is. Nothing here may read as a suggested priority, because a
1141        // priority mecha proposed is a priority a model could later argue
1142        // from — see `charter.rs`'s module doc for the invariant.
1143        assert!(
1144            charter.detail.contains("in your own words"),
1145            "the detail should say whose words these are: {}",
1146            charter.detail
1147        );
1148    }
1149
1150    /// A file with no lines and no file at all are both "nothing rides in
1151    /// the prompt" and are told apart in the detail, because only one of
1152    /// them is a half-finished edit.
1153    #[test]
1154    fn an_empty_charter_reads_differently_from_an_absent_one() {
1155        let cfg = cfg_with_local(262144, Some(true));
1156        let mut f = facts(Some(props(262144, 4, true)));
1157
1158        f.charter = CharterState::Empty;
1159        let empty = step(&plan(&cfg, "local", &f), "charter").clone();
1160        f.charter = CharterState::Absent;
1161        let absent = step(&plan(&cfg, "local", &f), "charter").clone();
1162
1163        assert_eq!(empty.status, Status::Missing);
1164        assert_eq!(absent.status, Status::Missing);
1165        assert_ne!(
1166            empty.detail, absent.detail,
1167            "a template nobody filled in is not the same finding as a fresh install"
1168        );
1169    }
1170
1171    /// A charter that does not load is `Wrong`, not `Missing`: there *is* a
1172    /// document and it disagrees with what a run can load, which is a
1173    /// different thing to do about it — and, unlike the other two, already
1174    /// a `doctor` finding.
1175    #[test]
1176    fn a_charter_that_does_not_load_is_wrong_rather_than_missing() {
1177        let cfg = cfg_with_local(262144, Some(true));
1178        let mut f = facts(Some(props(262144, 4, true)));
1179        f.charter = CharterState::Broken("duplicate id `x`".into());
1180        let charter = step(&plan(&cfg, "local", &f), "charter").clone();
1181        assert_eq!(charter.status, Status::Wrong);
1182        assert!(charter.detail.contains("duplicate id"));
1183        assert!(
1184            charter.detail.contains("un-chartered"),
1185            "say what it costs, not just that it failed: {}",
1186            charter.detail
1187        );
1188    }
1189
1190    /// A decline is remembered, and it is not a fifth shade of "not done".
1191    #[test]
1192    fn a_declined_step_reports_the_decision_rather_than_the_absence() {
1193        let cfg = cfg_with_local(262144, Some(true));
1194        let mut f = facts(Some(props(262144, 4, true)));
1195        f.slack_linked = Some(false);
1196
1197        // Without the decline it is ordinary outstanding work, with a remedy.
1198        let before = step(&plan(&cfg, "local", &f), "slack").clone();
1199        assert_eq!(before.status, Status::Missing);
1200        assert!(before.remedy.is_some());
1201
1202        f.declined.insert("slack".into());
1203        let after = step(&plan(&cfg, "local", &f), "slack").clone();
1204        assert_eq!(after.status, Status::Declined);
1205        assert!(
1206            after.remedy.is_none(),
1207            "a remedy is an offer, and this one has been answered"
1208        );
1209        assert_eq!(
1210            after.detail, before.detail,
1211            "a decline changes whether a step is asked for, never what it says"
1212        );
1213    }
1214
1215    /// The step that blocks every other one carries a **remedy**, not a
1216    /// viewer, when there is something to run.
1217    ///
1218    /// It used to say `anthropic has no usable credential` and offer
1219    /// `mecha config show`, which displays a file and fixes nothing — the one
1220    /// step that makes all the others untestable was the one with no path
1221    /// forward.
1222    #[test]
1223    fn a_running_local_server_turns_the_blocking_step_into_something_runnable() {
1224        let cfg = Config::default();
1225        let mut f = facts(None);
1226        f.provider_credential = false;
1227        f.local_probe = LocalProbe::Found(LocalServer {
1228            base_url: "http://127.0.0.1:8080".into(),
1229            props: props(32768, 4, false),
1230        });
1231
1232        let s = plan(&cfg, "anthropic", &f);
1233        let step = step(&s, "provider-credential");
1234        assert_eq!(
1235            step.remedy.as_ref().map(|r| r.argv.clone()),
1236            Some(vec!["mecha".into(), "setup".into(), "--write".into()]),
1237            "a server is running: writing it down is a thing this tool can do"
1238        );
1239        assert!(
1240            step.detail.contains("127.0.0.1:8080") && step.detail.contains("qwen3.6-35b-a3b"),
1241            "name what was found, so the offer is checkable: {}",
1242            step.detail
1243        );
1244        // Never declinable, however it is phrased — a credential is not a
1245        // feature going unused.
1246        assert!(!step.optional);
1247    }
1248
1249    /// With nothing answering, the fix is a secret — and a secret is the one
1250    /// thing this tool must not write. So the step names the exact variable
1251    /// and says the key never lands in a file, rather than pointing at a
1252    /// command that could not help.
1253    #[test]
1254    fn with_no_server_the_step_names_the_variable_and_promises_not_to_store_it() {
1255        let cfg = Config::default();
1256        let mut f = facts(None);
1257        f.provider_credential = false;
1258        f.local_probe = LocalProbe::NothingAnswered;
1259
1260        let s = plan(&cfg, "anthropic", &f);
1261        let step = step(&s, "provider-credential");
1262        assert!(
1263            step.detail.contains("ANTHROPIC_API_KEY"),
1264            "name the variable rather than describing it: {}",
1265            step.detail
1266        );
1267        assert!(
1268            step.detail.contains("never the key itself"),
1269            "say where the secret does *not* go: {}",
1270            step.detail
1271        );
1272        // Both ways out are named, including the one this project is for.
1273        assert!(step.detail.contains("locally"), "{}", step.detail);
1274        assert!(
1275            step.remedy.is_none(),
1276            "there is no command that can set somebody's environment for them, \
1277             and offering one that only prints is what this replaced"
1278        );
1279    }
1280
1281    /// A provider with no `api_key_env` cannot be fixed by exporting
1282    /// anything, and telling somebody to "set the variable it names" about a
1283    /// provider that names none sends them looking for a typo they did not
1284    /// make.
1285    #[test]
1286    fn a_provider_naming_no_key_variable_is_not_told_to_set_one() {
1287        let mut cfg = Config::default();
1288        cfg.providers.get_mut("anthropic").unwrap().api_key_env = None;
1289        let mut f = facts(None);
1290        f.provider_credential = false;
1291
1292        f.local_probe = LocalProbe::NothingAnswered;
1293        let detail = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1294            .detail
1295            .clone();
1296        assert!(detail.contains("names no `api_key_env`"), "{detail}");
1297        assert!(!detail.contains("export "), "nothing to export: {detail}");
1298    }
1299
1300    /// **A probe that never ran must not be reported as one that found
1301    /// nothing.**
1302    ///
1303    /// The config that hits this: a `[providers.local]` on :8080 with a
1304    /// server running on it, and `default_provider` still pointing at a
1305    /// cloud provider whose key was never exported. Nothing probes (a local
1306    /// provider is configured), so an `Option<LocalServer>` made "never
1307    /// asked" indistinguishable from "asked and heard nothing" — and the
1308    /// person with a running server was told *"Nothing was answering at
1309    /// http://127.0.0.1:8080 when this ran"*. A fact with no observation
1310    /// behind it, which is this module's own header rule inverted.
1311    #[test]
1312    fn an_unattempted_probe_is_never_reported_as_a_failed_one() {
1313        let cfg = Config::default();
1314        let mut f = facts(None);
1315        f.provider_credential = false;
1316
1317        f.local_probe = LocalProbe::NothingAnswered;
1318        let asked = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1319            .detail
1320            .clone();
1321        assert!(
1322            asked.contains("Nothing was answering"),
1323            "a probe that ran may report what it found: {asked}"
1324        );
1325
1326        f.local_probe = LocalProbe::NotAttempted;
1327        let never_asked = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1328            .detail
1329            .clone();
1330        assert!(
1331            !never_asked.contains("Nothing was answering"),
1332            "a probe that never ran must claim nothing about what is there: {never_asked}"
1333        );
1334        // And the rest of the advice survives — this is about dropping one
1335        // unearned sentence, not the route it belongs to.
1336        assert!(never_asked.contains("Run a model locally"), "{never_asked}");
1337    }
1338
1339    /// A configured local provider that simply is not selected gets named,
1340    /// with the one-line fix.
1341    ///
1342    /// Before this branch existed, that install produced a single step
1343    /// telling somebody to serve a model they were already serving, and
1344    /// never mentioned the provider sitting in their own config.
1345    #[test]
1346    fn a_configured_but_unselected_local_provider_is_named_as_the_way_out() {
1347        let mut cfg = Config::default();
1348        let mut local = cfg.providers.get("anthropic").cloned().unwrap();
1349        local.kind = "local".into();
1350        local.base_url = Some("http://127.0.0.1:8080".into());
1351        local.api_key_env = None;
1352        cfg.providers.insert("local".into(), local);
1353
1354        let mut f = facts(None);
1355        f.provider_credential = false;
1356        // Nothing probed, because a local provider exists — which is exactly
1357        // the state that used to produce a false report.
1358        f.local_probe = LocalProbe::NotAttempted;
1359
1360        let detail = step(&plan(&cfg, "anthropic", &f), "provider-credential")
1361            .detail
1362            .clone();
1363        assert!(
1364            detail.contains("`local`") && detail.contains("127.0.0.1:8080"),
1365            "name the provider they already have, and where it points: {detail}"
1366        );
1367        assert!(
1368            detail.contains("default_provider"),
1369            "and the one-line fix: {detail}"
1370        );
1371        assert!(
1372            !detail.contains("Nothing was answering"),
1373            "nothing probed it, so nothing may be claimed about it: {detail}"
1374        );
1375    }
1376
1377    /// **Parsing is not identification.** `Props` defaults every field so a
1378    /// llama-server version bump costs a check rather than a parse failure —
1379    /// so `{}` from any JSON service on :8080 parses perfectly. Without this
1380    /// check it was announced as "already serving (an unnamed model)", and
1381    /// one `y` would repoint `default_provider` at it with no `model` and no
1382    /// `context_window`: the two settings this module exists to stop people
1383    /// getting wrong, both of which degrade quietly.
1384    #[test]
1385    fn a_stranger_answering_200_is_not_a_model_server() {
1386        use crate::provider::preflight::Props;
1387
1388        // The premise, pinned rather than assumed: an empty object really
1389        // does parse. If `Props` ever stops being fully-defaulted this
1390        // assertion fails and the check below can be reconsidered, instead of
1391        // quietly guarding against something that can no longer happen.
1392        let stranger: Props =
1393            serde_json::from_str("{}").expect("Props defaults every field, so `{}` parses");
1394        assert!(
1395            !answers_like_a_model_server(&stranger),
1396            "any JSON service answering 200 would otherwise read as a model server"
1397        );
1398        // A plausible non-model service: valid JSON, unrelated keys, still a
1399        // clean parse because unknown fields are ignored.
1400        let proxy: Props = serde_json::from_str(r#"{"status":"ok","uptime":42}"#)
1401            .expect("unknown fields are ignored");
1402        assert!(!answers_like_a_model_server(&proxy));
1403
1404        // Either field alone identifies a real one — a disjunction on
1405        // purpose, so a build that reports one of them differently is not
1406        // rejected, which is what the tolerance was for.
1407        let named = Props {
1408            model_alias: Some("qwen3-14b".into()),
1409            ..Props::default()
1410        };
1411        assert!(answers_like_a_model_server(&named));
1412        assert!(answers_like_a_model_server(&props(32768, 4, false)));
1413    }
1414
1415    /// A new install is told the config file exists and where — nothing did.
1416    /// `Config::load_global` tolerating its absence is right, and is also why
1417    /// nobody ever learned about it.
1418    #[test]
1419    fn a_missing_config_file_is_offered_and_a_present_one_is_not_mentioned() {
1420        let cfg = Config::default();
1421        let mut f = facts(None);
1422        f.config_file = false;
1423        let s = plan(&cfg, "anthropic", &f);
1424        assert_eq!(
1425            step(&s, "config-file")
1426                .remedy
1427                .as_ref()
1428                .map(|r| r.argv.clone()),
1429            Some(vec!["mecha".into(), "config".into(), "init".into()])
1430        );
1431
1432        f.config_file = true;
1433        assert!(
1434            !plan(&cfg, "anthropic", &f)
1435                .iter()
1436                .any(|s| s.id == "config-file"),
1437            "a file that exists is not a step"
1438        );
1439    }
1440
1441    /// **The step that makes everything else work cannot be declined.**
1442    ///
1443    /// The bug this fails on was found by running the flow rather than by
1444    /// reading it: `declinable` was inferred from `Status::Missing`, and a
1445    /// provider with no credential is missing — so declining every "missing"
1446    /// step reported `Nothing outstanding.` on an install that could not
1447    /// answer a single prompt. Asserted against a *hand-edited* store,
1448    /// because the file is plain JSON and a guarantee that only the prompt
1449    /// enforced would be one anybody could edit around.
1450    #[test]
1451    fn a_step_that_is_not_optional_cannot_be_declined_even_by_editing_the_file() {
1452        let mut cfg = Config::default();
1453        // A provider with no credential and no local server: the one step
1454        // that blocks every other.
1455        let p = cfg.providers.get_mut("anthropic").unwrap();
1456        p.api_key_env = Some("MECHA_TEST_NO_SUCH_KEY".into());
1457        let mut f = facts(None);
1458        f.provider_credential = false;
1459        f.slack_linked = Some(false);
1460
1461        for id in [
1462            "provider-credential",
1463            "mail",
1464            "docs",
1465            "slack",
1466            "graph",
1467            "charter",
1468        ] {
1469            f.declined.insert(id.to_string());
1470        }
1471        let steps = plan(&cfg, "anthropic", &f);
1472
1473        assert_eq!(
1474            step(&steps, "provider-credential").status,
1475            Status::Missing,
1476            "a credential is not a feature somebody can decline"
1477        );
1478        // The integrations, which genuinely are optional, still honour it —
1479        // otherwise this test would pass on a decline that never worked.
1480        assert_eq!(step(&steps, "slack").status, Status::Declined);
1481    }
1482
1483    /// Every declinable step is one where "I don't want this" is a coherent
1484    /// sentence. Asserted over the whole plan rather than per step, so the
1485    /// next step added has to be decided about rather than defaulting into
1486    /// being refusable.
1487    #[test]
1488    fn only_genuinely_optional_things_are_declinable() {
1489        let cfg = Config::default();
1490        let mut f = facts(None);
1491        f.provider_credential = false;
1492        f.mail_accounts = Some(0);
1493        f.docs_accounts = Some(0);
1494        f.slack_linked = Some(false);
1495        f.has_graph_binary = false;
1496        f.charter = CharterState::Absent;
1497        f.trigger_count = 1;
1498        f.scheduler_installed = false;
1499
1500        let steps = plan(&cfg, "anthropic", &f);
1501        let optional: Vec<&str> = steps
1502            .iter()
1503            .filter(|s| s.optional)
1504            .map(|s| s.id.as_str())
1505            .collect();
1506        assert_eq!(optional, ["mail", "docs", "slack", "graph", "charter"]);
1507    }
1508
1509    /// The offer text is one paragraph, not a wrapped source literal.
1510    ///
1511    /// A `\`-continued string that loses its backslash keeps the source's
1512    /// indentation, and the result reads as a bug to the one person least
1513    /// able to tell it is cosmetic — somebody on their first five minutes.
1514    /// Caught by running the command; kept by this.
1515    #[test]
1516    fn no_step_detail_carries_its_source_indentation() {
1517        let cfg = Config::default();
1518        let mut f = facts(None);
1519        f.provider_credential = false;
1520        f.charter = CharterState::Empty;
1521        f.trigger_count = 1;
1522        f.scheduler_installed = false;
1523        for s in plan(&cfg, "anthropic", &f) {
1524            assert!(
1525                !s.detail.contains("   "),
1526                "`{}` carries a run of spaces from its source literal: {:?}",
1527                s.id,
1528                s.detail
1529            );
1530        }
1531    }
1532
1533    /// The fact beats the preference. Declining Slack and then linking it
1534    /// anyway — from the phone, or by running `mecha slack auth` directly —
1535    /// must read as done, or a stale decline hides a working integration
1536    /// from its own owner.
1537    #[test]
1538    fn a_decline_never_overwrites_a_step_that_is_actually_done() {
1539        let cfg = cfg_with_local(262144, Some(true));
1540        let mut f = facts(Some(props(262144, 4, true)));
1541        f.slack_linked = Some(true);
1542        f.declined.insert("slack".into());
1543        assert_eq!(step(&plan(&cfg, "local", &f), "slack").status, Status::Done);
1544    }
1545
1546    /// And it never suppresses a failure. "I don't want mail" is not "I
1547    /// don't want to be told my mail is broken" — a decline that could hide
1548    /// a `Wrong` would be a silently-degrading guard.
1549    #[test]
1550    fn a_decline_cannot_suppress_a_broken_one() {
1551        let cfg = cfg_with_local(262144, Some(true));
1552        let mut f = facts(Some(props(262144, 4, true)));
1553        f.charter = CharterState::Broken("bad toml".into());
1554        f.declined.insert("charter".into());
1555        assert_eq!(
1556            step(&plan(&cfg, "local", &f), "charter").status,
1557            Status::Wrong,
1558            "a decline must not hide a document that stops every run being chartered"
1559        );
1560    }
1561
1562    /// An unknown store is not silently declined either — `Unknown` is
1563    /// already "cannot tell from here" and must not become "answered".
1564    #[test]
1565    fn a_decline_does_not_apply_to_an_unknown_step() {
1566        let cfg = cfg_with_local(262144, Some(true));
1567        let mut f = facts(Some(props(262144, 4, true)));
1568        f.mail_accounts = None;
1569        f.declined.insert("mail".into());
1570        assert_eq!(
1571            step(&plan(&cfg, "local", &f), "mail").status,
1572            Status::Unknown
1573        );
1574    }
1575
1576    /// The store round-trips, is idempotent, and can be undone — a decline
1577    /// is a preference, not a door that locks behind you.
1578    #[test]
1579    fn declines_round_trip_and_can_be_taken_back() {
1580        let home = std::env::temp_dir().join(format!(
1581            "mecha-declined-test-{}-{}",
1582            std::process::id(),
1583            line!()
1584        ));
1585        let _ = std::fs::remove_dir_all(&home);
1586        std::fs::create_dir_all(&home).unwrap();
1587
1588        // An absent file is a confident empty set, never `None` — the same
1589        // rule `count_accounts` keeps for a credential root.
1590        assert_eq!(read_declined(&home), Some(Default::default()));
1591
1592        decline(&home, "slack").unwrap();
1593        decline(&home, "slack").unwrap();
1594        decline(&home, "docs").unwrap();
1595        let set = read_declined(&home).unwrap();
1596        assert_eq!(set.len(), 2, "declining twice declines once");
1597        assert!(set.contains("slack") && set.contains("docs"));
1598
1599        undecline(&home, Some("slack")).unwrap();
1600        assert_eq!(
1601            read_declined(&home)
1602                .unwrap()
1603                .into_iter()
1604                .collect::<Vec<_>>(),
1605            ["docs"]
1606        );
1607        undecline(&home, None).unwrap();
1608        assert!(read_declined(&home).unwrap().is_empty());
1609
1610        // A file that is there but is not a decline store is `None`, so the
1611        // caller can say so — not an empty set, which would read as "you
1612        // have declined nothing" about a document nobody could parse.
1613        std::fs::write(declined_path(&home), "{not json").unwrap();
1614        assert_eq!(read_declined(&home), None);
1615
1616        let _ = std::fs::remove_dir_all(&home);
1617    }
1618
1619    /// **An unreadable store is kept, never overwritten.**
1620    ///
1621    /// The bug this fails on: `decline` collapsed `read_declined`'s `None`
1622    /// to an empty set and then *persisted* it, so somebody with a typo in
1623    /// `setup-declined.json` who answered `never` to one step had the file
1624    /// rewritten with exactly that one id and every earlier answer gone —
1625    /// with no message beyond the "could not be read" line they had already
1626    /// seen and reasonably read as "so nothing is recorded".
1627    #[test]
1628    fn declining_over_an_unreadable_store_keeps_the_old_bytes() {
1629        let home = std::env::temp_dir().join(format!(
1630            "mecha-declined-salvage-{}-{}",
1631            std::process::id(),
1632            line!()
1633        ));
1634        let _ = std::fs::remove_dir_all(&home);
1635        std::fs::create_dir_all(&home).unwrap();
1636
1637        let damaged = r#"{"declined": ["slack", "docs"] "#; // truncated
1638        std::fs::write(declined_path(&home), damaged).unwrap();
1639        assert_eq!(
1640            read_declined(&home),
1641            None,
1642            "the fixture is genuinely unreadable"
1643        );
1644
1645        let salvaged = decline(&home, "mail")
1646            .unwrap()
1647            .salvaged
1648            .expect("the old file is kept");
1649        assert_eq!(
1650            std::fs::read_to_string(&salvaged).unwrap(),
1651            damaged,
1652            "kept byte for byte — a salvage that rewrites is not a salvage"
1653        );
1654
1655        // The new store is well-formed and holds the answer just given.
1656        let now = read_declined(&home).unwrap();
1657        assert_eq!(now.into_iter().collect::<Vec<_>>(), ["mail"]);
1658
1659        // And the ordinary path reports no salvage, so a caller cannot print
1660        // the warning on every decline.
1661        assert_eq!(decline(&home, "slack").unwrap().salvaged, None);
1662
1663        // `undecline` takes the same care: it also writes the whole document.
1664        std::fs::write(declined_path(&home), damaged).unwrap();
1665        assert!(
1666            undecline(&home, Some("slack")).unwrap().salvaged.is_some(),
1667            "the undo path overwrites the same file and must salvage too"
1668        );
1669
1670        let _ = std::fs::remove_dir_all(&home);
1671    }
1672
1673    /// **`changed` is read off the set, never off the argument.**
1674    ///
1675    /// `undecline` discarded `BTreeSet::remove`'s answer, so a typo'd id
1676    /// wrote the set back untouched while the caller announced the restore
1677    /// and exited 0 — the person then met `you said no thanks` on the step
1678    /// they thought they had just brought back.
1679    #[test]
1680    fn a_write_reports_what_changed_rather_than_what_was_asked() {
1681        let home = std::env::temp_dir().join(format!(
1682            "mecha-declined-changed-{}-{}",
1683            std::process::id(),
1684            line!()
1685        ));
1686        let _ = std::fs::remove_dir_all(&home);
1687        std::fs::create_dir_all(&home).unwrap();
1688
1689        assert!(
1690            decline(&home, "slack").unwrap().changed,
1691            "a new decline changes it"
1692        );
1693        assert!(
1694            !decline(&home, "slack").unwrap().changed,
1695            "declining twice is idempotent, and the second one changed nothing"
1696        );
1697
1698        // The finding: an id nobody declined.
1699        assert!(
1700            !undecline(&home, Some("slak")).unwrap().changed,
1701            "a typo restores nothing, and must not report that it did"
1702        );
1703        assert!(
1704            undecline(&home, Some("slack")).unwrap().changed,
1705            "and a real one does"
1706        );
1707
1708        // `all` over an empty set is vacuous rather than false, but it is
1709        // still not a restore — saying so costs a word and saves a wrong
1710        // belief.
1711        assert!(!undecline(&home, None).unwrap().changed);
1712        decline(&home, "docs").unwrap();
1713        assert!(undecline(&home, None).unwrap().changed);
1714
1715        let _ = std::fs::remove_dir_all(&home);
1716    }
1717
1718    /// A value read off a stranger's `/props` cannot produce a config that
1719    /// will not parse.
1720    ///
1721    /// `format!("{alias:?}")` is *Rust* escaping: a control character renders
1722    /// as `\u{1b}`, and TOML's `\u` takes four hex digits with no braces, so
1723    /// the written file is a parse error — and every later `mecha` command
1724    /// then dies at `Config::load_global` with a message pointing at
1725    /// `mecha config init` rather than at what happened.
1726    #[test]
1727    fn a_model_alias_is_escaped_for_toml_rather_than_for_rust() {
1728        use crate::provider::preflight::Props;
1729
1730        for alias in [
1731            "qwen3-14b",
1732            "has \"quotes\"",
1733            "has\\backslash",
1734            // The one that bites: `Debug` writes `\u{1b}`, TOML cannot read it.
1735            "esc\u{1b}ape",
1736            "new\nline",
1737            "tab\there",
1738        ] {
1739            let props = Props {
1740                model_alias: Some(alias.to_string()),
1741                ..Props::default()
1742            };
1743            let rendered = verified_settings(&props)
1744                .into_iter()
1745                .find(|(k, _)| *k == "model")
1746                .expect("a named model is written down")
1747                .1;
1748
1749            // Round-tripped through the parser that will actually read it,
1750            // rather than eyeballed: the question is whether a *run* can load
1751            // the file, so ask the same reader.
1752            let doc: toml::Table = format!("model = {rendered}")
1753                .parse()
1754                .unwrap_or_else(|e| panic!("{alias:?} rendered as {rendered} — unparseable: {e}"));
1755            assert_eq!(
1756                doc["model"].as_str(),
1757                Some(alias),
1758                "value survived the trip"
1759            );
1760        }
1761    }
1762
1763    /// `charter_state` answers the question a run would ask, not "is there
1764    /// a file" — a template nobody filled in loads fine and supplies
1765    /// nothing.
1766    #[test]
1767    fn charter_state_reads_what_a_run_would_get() {
1768        let dir = std::env::temp_dir().join(format!(
1769            "mecha-charter-state-test-{}-{}",
1770            std::process::id(),
1771            line!()
1772        ));
1773        let _ = std::fs::remove_dir_all(&dir);
1774        std::fs::create_dir_all(&dir).unwrap();
1775        let path = dir.join("charter.toml");
1776
1777        assert_eq!(charter_state(&path), CharterState::Absent);
1778
1779        std::fs::write(&path, crate::charter::TEMPLATE).unwrap();
1780        assert_eq!(
1781            charter_state(&path),
1782            CharterState::Empty,
1783            "the shipped template must parse to zero lines, or it is authoring priorities"
1784        );
1785
1786        std::fs::write(&path, "[[line]]\nid = \"a\"\ntext = \"b\"\n").unwrap();
1787        assert_eq!(charter_state(&path), CharterState::Lines(1));
1788
1789        std::fs::write(&path, "[[lines]]\nid = \"a\"\n").unwrap();
1790        assert!(matches!(charter_state(&path), CharterState::Broken(_)));
1791
1792        let _ = std::fs::remove_dir_all(&dir);
1793    }
1794
1795    /// The trap this exists to retire: `context_window` naming `-c` rather
1796    /// than `-c / -np`. The two are the same number until `-np` moves off 1,
1797    /// which is what makes it easy to write down wrong and impossible to
1798    /// notice afterwards.
1799    #[test]
1800    fn a_context_window_that_names_c_rather_than_c_over_np_is_reported_wrong() {
1801        let cfg = cfg_with_local(1048576, Some(true));
1802        let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
1803        let s = step(&steps, "local-server");
1804        assert_eq!(s.status, Status::Wrong);
1805        assert!(s.detail.contains("262144"), "{}", s.detail);
1806        assert!(s.remedy.is_some(), "and it is fixable without hand-editing");
1807    }
1808
1809    /// The bug that hid for months, in the direction nobody looks.
1810    #[test]
1811    fn a_vision_model_nobody_configured_to_use_is_reported_wrong() {
1812        let cfg = cfg_with_local(262144, None); // vision unset → false for local
1813        let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
1814        assert_eq!(step(&steps, "local-server").status, Status::Wrong);
1815    }
1816
1817    /// A server that is simply not running must not be reported as a
1818    /// misconfiguration — there is nothing to compare against yet, and
1819    /// telling someone their config is wrong when it may be fine sends them
1820    /// editing a correct file.
1821    #[test]
1822    fn a_server_that_is_not_up_is_missing_rather_than_wrong() {
1823        let cfg = cfg_with_local(262144, Some(true));
1824        let steps = plan(&cfg, "local", &facts(None));
1825        assert_eq!(step(&steps, "local-server").status, Status::Missing);
1826        assert!(step(&steps, "local-server").remedy.is_none());
1827    }
1828
1829    /// "Cannot tell from here" is not "not done". A person told their mail is
1830    /// unconfigured, when the store is merely unreadable, re-runs an OAuth
1831    /// flow they did not need.
1832    #[test]
1833    fn an_unreadable_store_is_unknown_and_offers_nothing() {
1834        let mut f = facts(Some(props(262144, 4, true)));
1835        f.mail_accounts = None;
1836        let steps = plan(&cfg_with_local(262144, Some(true)), "local", &f);
1837        let s = step(&steps, "mail");
1838        assert_eq!(s.status, Status::Unknown);
1839        assert!(s.remedy.is_none(), "unknown must not propose a fix");
1840    }
1841
1842    /// The boundary that keeps mecha from growing a second way to reach the
1843    /// graph. Its sources belong to that project's CLI, and this one must
1844    /// never spawn them.
1845    #[test]
1846    fn a_graph_step_never_offers_to_run_a_graph_source_command() {
1847        let steps = plan(
1848            &cfg_with_local(262144, Some(true)),
1849            "local",
1850            &facts(Some(props(262144, 4, true))),
1851        );
1852        for s in &steps {
1853            if let Some(r) = &s.remedy {
1854                assert!(
1855                    !r.argv.iter().any(|a| a == "source"),
1856                    "{} would drive the graph's own source CLI: {:?}",
1857                    s.id,
1858                    r.argv
1859                );
1860            }
1861        }
1862    }
1863
1864    /// Nothing schedules anything for anyone. A cron slot on a machine
1865    /// holding your mail is never a default, and the offer is the *runner*,
1866    /// never a schedule.
1867    #[test]
1868    fn a_scheduler_is_only_offered_once_a_trigger_exists() {
1869        let cfg = cfg_with_local(262144, Some(true));
1870        let mut f = facts(Some(props(262144, 4, true)));
1871        f.scheduler_installed = false;
1872
1873        f.trigger_count = 0;
1874        assert!(
1875            !plan(&cfg, "local", &f).iter().any(|s| s.id == "scheduler"),
1876            "no triggers means nothing to run; do not offer a runner"
1877        );
1878
1879        f.trigger_count = 2;
1880        let steps = plan(&cfg, "local", &f);
1881        let s = step(&steps, "scheduler");
1882        assert_eq!(s.status, Status::Missing);
1883        assert!(
1884            !s.remedy.as_ref().unwrap().argv.contains(&"add".to_string()),
1885            "offer the runner, never a schedule"
1886        );
1887    }
1888
1889    /// What gets written comes off the wire, not out of the config.
1890    #[test]
1891    fn verified_settings_are_read_back_from_the_server() {
1892        let got = verified_settings(&props(65536, 4, true));
1893        assert!(got.contains(&("context_window", "65536".into())), "{got:?}");
1894        assert!(got.contains(&("vision", "true".into())), "{got:?}");
1895        assert!(
1896            got.iter().any(|(k, v)| *k == "model" && v.contains("qwen")),
1897            "{got:?}"
1898        );
1899    }
1900
1901    /// An absent directory is a confident zero; an unreadable one is not.
1902    #[test]
1903    fn a_missing_credential_root_counts_zero_rather_than_unknown() {
1904        assert_eq!(count_accounts(Path::new("/no/such/root")), Some(0));
1905    }
1906}