1use crate::config::Config;
23use crate::doctor::Remedy;
24use serde::Serialize;
25use std::path::Path;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Status {
34 Done,
35 Missing,
36 Wrong,
38 Unknown,
39}
40
41#[derive(Debug, Clone, Serialize)]
43pub struct Step {
44 pub id: String,
46 pub title: String,
47 pub status: Status,
48 pub detail: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
52 pub remedy: Option<Remedy>,
53}
54
55impl Step {
56 fn new(id: &str, title: &str, status: Status, detail: impl Into<String>) -> Self {
57 Step {
58 id: id.into(),
59 title: title.into(),
60 status,
61 detail: detail.into(),
62 remedy: None,
63 }
64 }
65 fn with(mut self, description: &str, argv: &[&str], needs_terminal: bool) -> Self {
66 self.remedy = Some(Remedy {
67 description: description.into(),
68 argv: argv.iter().map(|s| s.to_string()).collect(),
69 needs_terminal,
70 });
71 self
72 }
73}
74
75#[derive(Debug, Clone, Default)]
81pub struct Facts {
82 pub has_mail_binary: bool,
86 pub has_docs_binary: bool,
87 pub has_graph_binary: bool,
88 pub mail_accounts: Option<usize>,
91 pub docs_accounts: Option<usize>,
92 pub slack_linked: Option<bool>,
93 pub props: Option<crate::provider::preflight::Props>,
96 pub provider_credential: bool,
98 pub scheduler_installed: bool,
100 pub trigger_count: usize,
101}
102
103pub fn plan(cfg: &Config, provider_name: &str, facts: &Facts) -> Vec<Step> {
110 let mut steps = Vec::new();
111 let local = cfg
112 .providers
113 .get(provider_name)
114 .filter(|p| p.kind == "local");
115
116 if !facts.provider_credential && local.is_none() {
118 steps.push(
119 Step::new(
120 "provider-credential",
121 "A provider that can answer",
122 Status::Missing,
123 format!(
124 "`{provider_name}` has no usable credential. Set the environment variable \
125 its `api_key_env` names, or configure a local server instead."
126 ),
127 )
128 .with(
129 "Show which providers are configured and what each is missing.",
130 &["mecha", "config", "show"],
131 false,
132 ),
133 );
134 }
135
136 if let Some(pcfg) = local {
138 match &facts.props {
139 None => steps.push(Step::new(
140 "local-server",
141 "The local server is reachable",
142 Status::Missing,
143 format!(
144 "Nothing answered at {}. Start the server before the rest of this can be \
145 checked — every value below is read back from it rather than guessed.",
146 pcfg.base_url.as_deref().unwrap_or("(no base_url)")
147 ),
148 )),
149 Some(props) => {
150 let mismatches =
151 crate::provider::preflight::disagreements(provider_name, pcfg, props);
152 if mismatches.is_empty() {
153 steps.push(Step::new(
154 "local-server",
155 "The local server agrees with the config",
156 Status::Done,
157 format!(
158 "serving {}, {} tokens per slot, vision {}",
159 props.model_alias.as_deref().unwrap_or("(unnamed)"),
160 props
161 .default_generation_settings
162 .n_ctx
163 .map(|n| n.to_string())
164 .unwrap_or_else(|| "?".into()),
165 if props.modalities.vision { "on" } else { "off" },
166 ),
167 ));
168 } else {
169 steps.push(
170 Step::new(
171 "local-server",
172 "The config disagrees with what is served",
173 Status::Wrong,
174 mismatches.join("\n\n"),
175 )
176 .with(
177 "Rewrite these from what the server reports, rather than editing \
178 them by hand.",
179 &["mecha", "setup", "--write"],
180 false,
181 ),
182 );
183 }
184 }
185 }
186 }
187
188 steps.extend(integration_steps(facts));
189
190 if !facts.scheduler_installed && facts.trigger_count > 0 {
197 steps.push(
198 Step::new(
199 "scheduler",
200 "Something to fire the triggers",
201 Status::Missing,
202 format!(
203 "{} trigger(s) are defined and nothing is running them. Being due is a \
204 function of the ledger and the clock, so any of a systemd timer, a \
205 crontab line running `mecha trigger tick`, or `mecha trigger daemon` \
206 will do.",
207 facts.trigger_count
208 ),
209 )
210 .with(
211 "Print a systemd user unit for the daemon, to review before installing.",
212 &["mecha", "trigger", "daemon", "--print-unit"],
213 false,
214 ),
215 );
216 }
217
218 steps
219}
220
221fn integration_steps(facts: &Facts) -> Vec<Step> {
231 let mut steps = Vec::new();
232
233 steps.push(match (facts.has_mail_binary, facts.mail_accounts) {
234 (false, _) => Step::new(
235 "mail",
236 "Mail and calendar",
237 Status::Missing,
238 "`mecha-mail` is not on PATH. It is a separate crate, and optional — nothing else \
239 needs it.",
240 )
241 .with(
242 "Install the mail and calendar MCP servers.",
243 &["cargo", "install", "mecha-mail", "--locked"],
244 false,
245 ),
246 (true, Some(0)) => Step::new(
247 "mail",
248 "Mail and calendar",
249 Status::Missing,
250 "`mecha-mail` is installed with no accounts authorised. The model names an \
251 *account*, never a provider, so add one per mailbox.",
252 )
253 .with(
254 "Authorise a mailbox. Needs a browser, or `--paste` over SSH.",
255 &["mecha-mail", "auth", "personal", "--provider", "google"],
256 true,
257 ),
258 (true, Some(n)) => Step::new(
259 "mail",
260 "Mail and calendar",
261 Status::Done,
262 format!("{n} account(s) authorised"),
263 ),
264 (true, None) => Step::new(
265 "mail",
266 "Mail and calendar",
267 Status::Unknown,
268 "`mecha-mail` is installed; its credential store could not be read from here.",
269 ),
270 });
271
272 steps.push(match (facts.has_docs_binary, facts.docs_accounts) {
273 (false, _) => Step::new(
274 "docs",
275 "Google Docs, Sheets and Slides",
276 Status::Missing,
277 "`mecha-docs` ships with the mail crate. Under `drive.file` it reaches only files \
278 it created or you handed it in Google's own picker — which is the reason to want \
279 it, and no instruction inside a run can widen that.",
280 )
281 .with(
282 "Install the documents server (same crate as mail).",
283 &["cargo", "install", "mecha-mail", "--locked"],
284 false,
285 ),
286 (true, Some(0)) => Step::new(
287 "docs",
288 "Google Docs, Sheets and Slides",
289 Status::Missing,
290 "`mecha-docs` is installed with no account authorised.",
291 )
292 .with(
293 "Authorise Drive access. Use `--paste` if there is no browser here.",
294 &["mecha-docs", "auth", "personal"],
295 true,
296 ),
297 (true, Some(n)) => Step::new(
298 "docs",
299 "Google Docs, Sheets and Slides",
300 Status::Done,
301 format!("{n} account(s) authorised"),
302 ),
303 (true, None) => Step::new(
304 "docs",
305 "Google Docs, Sheets and Slides",
306 Status::Unknown,
307 "installed; the credential store could not be read from here.",
308 ),
309 });
310
311 steps.push(match facts.slack_linked {
312 Some(true) => Step::new(
313 "slack",
314 "Slack as a remote control",
315 Status::Done,
316 "linked to a workspace",
317 ),
318 Some(false) => Step::new(
319 "slack",
320 "Slack as a remote control",
321 Status::Missing,
322 "Watch a run from a phone, approve what it wants to send, and hand files in and \
323 out. The owner is bound by a nonce printed on this machine, so proving shell \
324 access here is what claims it.",
325 )
326 .with(
327 "Start the Slack setup, which prints the binding nonce.",
328 &["mecha", "slack", "auth"],
329 true,
330 ),
331 None => Step::new(
332 "slack",
333 "Slack as a remote control",
334 Status::Unknown,
335 "the binding store could not be read from here.",
336 ),
337 });
338
339 steps.push(if facts.has_graph_binary {
340 Step::new(
341 "graph",
342 "The personal knowledge graph",
343 Status::Done,
344 "`mecha-graph-mcp` is on PATH. Its own sources — ambient conversations, a \
345 calendar ICS feed, Slack, messages, mail — are configured with `mecha-graph \
346 source`, in that project. mecha reaches the graph only through its MCP tools \
347 and deliberately knows nothing else about it.",
348 )
349 } else {
350 Step::new(
351 "graph",
352 "The personal knowledge graph",
353 Status::Missing,
354 "Memory: who people are, what happened when. A separate project, wired in as an \
355 MCP server whose reads are marked untrusted — a graph fed by mail and messages \
356 holds third-party text by construction.",
357 )
358 .with(
359 "Install the graph's MCP server.",
360 &["cargo", "install", "mecha-graph-mcp", "--locked"],
361 false,
362 )
363 });
364
365 steps
366}
367
368pub fn verified_settings(props: &crate::provider::preflight::Props) -> Vec<(&'static str, String)> {
374 let mut out = Vec::new();
375 if let Some(alias) = &props.model_alias {
376 out.push(("model", format!("{alias:?}")));
377 }
378 if let Some(n) = props.default_generation_settings.n_ctx {
381 out.push(("context_window", n.to_string()));
382 }
383 out.push(("vision", props.modalities.vision.to_string()));
384 out
385}
386
387pub fn count_accounts(root: &Path) -> Option<usize> {
393 match std::fs::read_dir(root) {
394 Ok(entries) => Some(
395 entries
396 .flatten()
397 .filter(|e| e.path().join("oauth.json").is_file())
398 .count(),
399 ),
400 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
401 Err(_) => None,
402 }
403}
404
405pub fn on_path(name: &str) -> bool {
407 let Some(path) = std::env::var_os("PATH") else {
408 return false;
409 };
410 std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::provider::preflight::{GenerationSettings, Modalities, Props};
417
418 fn cfg_with_local(context_window: u64, vision: Option<bool>) -> Config {
419 let mut cfg = Config::default();
420 let mut p = cfg.providers.get("anthropic").cloned().unwrap();
421 p.kind = "local".into();
422 p.model = Some("qwen3.6-35b-a3b".into());
423 p.base_url = Some("http://127.0.0.1:8080".into());
424 p.api_key_env = None;
425 p.context_window = Some(context_window);
426 p.vision = vision;
427 cfg.providers.insert("local".into(), p);
428 cfg
429 }
430
431 fn props(n_ctx: u64, slots: u64, vision: bool) -> Props {
432 Props {
433 model_alias: Some("qwen3.6-35b-a3b".into()),
434 total_slots: Some(slots),
435 modalities: Modalities { vision },
436 default_generation_settings: GenerationSettings { n_ctx: Some(n_ctx) },
437 }
438 }
439
440 fn facts(props: Option<Props>) -> Facts {
441 Facts {
442 provider_credential: true,
443 props,
444 mail_accounts: Some(1),
445 docs_accounts: Some(1),
446 slack_linked: Some(true),
447 has_mail_binary: true,
448 has_docs_binary: true,
449 has_graph_binary: true,
450 scheduler_installed: true,
451 trigger_count: 0,
452 }
453 }
454
455 fn step<'a>(steps: &'a [Step], id: &str) -> &'a Step {
456 steps.iter().find(|s| s.id == id).expect("step missing")
457 }
458
459 #[test]
461 fn everything_configured_and_agreeing_reports_no_work() {
462 let cfg = cfg_with_local(262144, Some(true));
463 let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
464 assert!(
465 steps.iter().all(|s| s.status == Status::Done),
466 "unexpected work: {:?}",
467 steps
468 .iter()
469 .filter(|s| s.status != Status::Done)
470 .map(|s| &s.id)
471 .collect::<Vec<_>>()
472 );
473 }
474
475 #[test]
480 fn a_context_window_that_names_c_rather_than_c_over_np_is_reported_wrong() {
481 let cfg = cfg_with_local(1048576, Some(true));
482 let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
483 let s = step(&steps, "local-server");
484 assert_eq!(s.status, Status::Wrong);
485 assert!(s.detail.contains("262144"), "{}", s.detail);
486 assert!(s.remedy.is_some(), "and it is fixable without hand-editing");
487 }
488
489 #[test]
491 fn a_vision_model_nobody_configured_to_use_is_reported_wrong() {
492 let cfg = cfg_with_local(262144, None); let steps = plan(&cfg, "local", &facts(Some(props(262144, 4, true))));
494 assert_eq!(step(&steps, "local-server").status, Status::Wrong);
495 }
496
497 #[test]
502 fn a_server_that_is_not_up_is_missing_rather_than_wrong() {
503 let cfg = cfg_with_local(262144, Some(true));
504 let steps = plan(&cfg, "local", &facts(None));
505 assert_eq!(step(&steps, "local-server").status, Status::Missing);
506 assert!(step(&steps, "local-server").remedy.is_none());
507 }
508
509 #[test]
513 fn an_unreadable_store_is_unknown_and_offers_nothing() {
514 let mut f = facts(Some(props(262144, 4, true)));
515 f.mail_accounts = None;
516 let steps = plan(&cfg_with_local(262144, Some(true)), "local", &f);
517 let s = step(&steps, "mail");
518 assert_eq!(s.status, Status::Unknown);
519 assert!(s.remedy.is_none(), "unknown must not propose a fix");
520 }
521
522 #[test]
526 fn a_graph_step_never_offers_to_run_a_graph_source_command() {
527 let steps = plan(
528 &cfg_with_local(262144, Some(true)),
529 "local",
530 &facts(Some(props(262144, 4, true))),
531 );
532 for s in &steps {
533 if let Some(r) = &s.remedy {
534 assert!(
535 !r.argv.iter().any(|a| a == "source"),
536 "{} would drive the graph's own source CLI: {:?}",
537 s.id,
538 r.argv
539 );
540 }
541 }
542 }
543
544 #[test]
548 fn a_scheduler_is_only_offered_once_a_trigger_exists() {
549 let cfg = cfg_with_local(262144, Some(true));
550 let mut f = facts(Some(props(262144, 4, true)));
551 f.scheduler_installed = false;
552
553 f.trigger_count = 0;
554 assert!(
555 !plan(&cfg, "local", &f).iter().any(|s| s.id == "scheduler"),
556 "no triggers means nothing to run; do not offer a runner"
557 );
558
559 f.trigger_count = 2;
560 let steps = plan(&cfg, "local", &f);
561 let s = step(&steps, "scheduler");
562 assert_eq!(s.status, Status::Missing);
563 assert!(
564 !s.remedy.as_ref().unwrap().argv.contains(&"add".to_string()),
565 "offer the runner, never a schedule"
566 );
567 }
568
569 #[test]
571 fn verified_settings_are_read_back_from_the_server() {
572 let got = verified_settings(&props(65536, 4, true));
573 assert!(got.contains(&("context_window", "65536".into())), "{got:?}");
574 assert!(got.contains(&("vision", "true".into())), "{got:?}");
575 assert!(
576 got.iter().any(|(k, v)| *k == "model" && v.contains("qwen")),
577 "{got:?}"
578 );
579 }
580
581 #[test]
583 fn a_missing_credential_root_counts_zero_rather_than_unknown() {
584 assert_eq!(count_accounts(Path::new("/no/such/root")), Some(0));
585 }
586}