Skip to main content

ai_memory/cli/
doctor.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory doctor` (Phase P7 / R7) — operator-visible health dashboard.
5//!
6//! The doctor reads three v0.6.3.1 surfaces — Capabilities v2 (P1), data
7//! integrity (P2), and recall observability (P3) — plus the v0.6.3 stats /
8//! governance / subscription tables, and produces a human-readable health
9//! report with severity tagging. It also has a `--json` mode for CI usage
10//! and a `--remote <url>` mode that becomes the **fleet doctor** at T3+.
11//!
12//! Exit codes:
13//!   - `0` — healthy (no warnings or critical findings).
14//!   - `1` — at least one warning (and `--fail-on-warn` was passed; without
15//!     the flag, warnings still keep exit 0).
16//!   - `2` — at least one critical finding.
17//!
18//! ## Severity rules (initial)
19//!
20//! - **Critical:** dim_violations > 0; pending_actions older than 24h;
21//!   sync skew > 600s; HNSW evictions > 0.
22//! - **Warning:** silent-degrade flag from Capabilities v2
23//!   (recall_mode != "hybrid" on capable tiers); subscription delivery
24//!   success < 95% over the lifetime of the subscription.
25//! - **Info:** anything else worth reporting.
26//!
27//! ## What is stubbed pending P1/P2/P3
28//!
29//! - **dim_violations** (P2): pre-P2 schemas have no `embedding_dim` column.
30//!   `db::doctor_dim_violations` returns `Ok(None)` and the doctor renders
31//!   "not yet observed (pre-P2 schema)".
32//! - **HNSW evictions** (P3): the eviction counter has no SQL surface today.
33//!   The doctor reports the value as 0 from a NOT_AVAILABLE-tagged section
34//!   until P3 lands the in-memory counter.
35//! - **recall_mode / reranker_used distribution** (P3): no rolling window
36//!   has been wired yet. The doctor consults the Capabilities response
37//!   for the *active* mode at this instant and reports it as the only
38//!   data point.
39//! - **Sync mesh** (T3+): we report `last_pulled_at` skew across
40//!   `sync_state` rows when present, otherwise NOT_AVAILABLE.
41//!
42//! ## Anti-goals (per spec)
43//!
44//! - Do NOT add new monitoring infrastructure (no Prometheus, OTel exporters).
45//! - Do NOT make doctor write to the DB. Read-only.
46//! - Do NOT make doctor block the database. Indexed `COUNT(*)` queries only.
47
48use crate::cli::CliOutput;
49use crate::db;
50use anyhow::{Context, Result};
51use serde::Serialize;
52use serde_json::Value;
53use std::path::Path;
54use std::time::Duration;
55
56/// Severity bucket attached to every doctor finding.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Severity {
60    Info,
61    Warning,
62    Critical,
63    /// The section couldn't be queried in this mode (e.g. raw SQL section
64    /// in remote mode, or P2-dependent section on pre-P2 schema).
65    NotAvailable,
66}
67
68impl Severity {
69    fn label(self) -> &'static str {
70        match self {
71            Severity::Info => "INFO",
72            Severity::Warning => "WARN",
73            Severity::Critical => "CRIT",
74            Severity::NotAvailable => "N/A ",
75        }
76    }
77}
78
79/// One section of the report. `facts` is a list of human-readable
80/// `(key, value)` lines so the JSON output stays structured and the text
81/// output stays scannable.
82#[derive(Debug, Serialize)]
83pub struct ReportSection {
84    pub name: String,
85    pub severity: Severity,
86    pub facts: Vec<(String, String)>,
87    /// Optional one-line explanation when severity != Info.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub note: Option<String>,
90}
91
92/// The full doctor report.
93#[derive(Debug, Serialize)]
94pub struct Report {
95    pub mode: String,
96    pub source: String,
97    pub generated_at: String,
98    pub sections: Vec<ReportSection>,
99    pub overall: Severity,
100}
101
102impl Report {
103    /// Compute the overall severity as the max across sections (CRIT > WARN > INFO > N/A).
104    fn rank(s: Severity) -> u8 {
105        match s {
106            Severity::NotAvailable => 0,
107            Severity::Info => 1,
108            Severity::Warning => 2,
109            Severity::Critical => 3,
110        }
111    }
112
113    fn compute_overall(&mut self) {
114        self.overall = self
115            .sections
116            .iter()
117            .map(|s| s.severity)
118            .max_by_key(|s| Self::rank(*s))
119            .unwrap_or(Severity::Info);
120    }
121}
122
123/// Args from the CLI clap struct. Kept separate so `cli::doctor::run` can
124/// be called directly from tests without going through clap.
125pub struct DoctorArgs {
126    pub remote: Option<String>,
127    pub json: bool,
128    pub fail_on_warn: bool,
129}
130
131/// v0.6.4-004 — Args for `ai-memory doctor --tokens`. Routes to
132/// [`run_tokens`] instead of the regular health pass.
133#[derive(Debug, Default)]
134pub struct TokensArgs {
135    /// Emit structured JSON instead of human-readable.
136    pub json: bool,
137    /// Dump the full per-tool size table (implies `json`).
138    pub raw_table: bool,
139    /// Hypothetical profile to evaluate (defaults to `core` —
140    /// the v0.6.4 default).
141    pub profile: Option<String>,
142}
143
144/// v0.6.4-004 — token-cost report.
145///
146/// Walks `crate::sizes::tool_sizes()`, groups by family via
147/// `crate::profile::Family::for_tool`, rolls up per-profile totals,
148/// and emits either a human-readable table or a JSON document.
149///
150/// Returns 0 on success. Errors when the `--profile` flag is malformed
151/// (the doctor's job is to surface the same diagnostic the MCP server
152/// would, not to crash with a stack trace) — those exit code 2.
153pub fn run_tokens(args: TokensArgs, out: &mut CliOutput<'_>) -> Result<i32> {
154    use crate::profile::{Family, Profile};
155    use crate::sizes;
156
157    // Resolve the hypothetical profile. Default to `core` since that
158    // is what v0.6.4 ships and what the operator wants to see savings
159    // *against*.
160    let profile = match Profile::parse(args.profile.as_deref().unwrap_or("core")) {
161        Ok(p) => p,
162        Err(e) => {
163            writeln!(out.stderr, "ai-memory doctor --tokens: {e}")?;
164            return Ok(2);
165        }
166    };
167
168    let table = sizes::tool_sizes();
169    let full_total: usize = table.iter().map(|t| t.total_tokens).sum();
170    let active_total: usize = table
171        .iter()
172        .filter(|t| profile.loads(&t.name))
173        .map(|t| t.total_tokens)
174        .sum();
175    let savings = full_total.saturating_sub(active_total);
176    let pct = if full_total == 0 {
177        0.0
178    } else {
179        (f64::from(u32::try_from(savings).unwrap_or(u32::MAX))
180            / f64::from(u32::try_from(full_total).unwrap_or(u32::MAX)))
181            * 100.0
182    };
183
184    // Per-family rollup. Includes "always-on" pseudo bucket for tools
185    // that load regardless of profile (today: just memory_capabilities).
186    let mut family_totals: Vec<(String, usize, usize)> = Family::all()
187        .iter()
188        .map(|f| {
189            let mut tool_count = 0usize;
190            let mut sum = 0usize;
191            for entry in table {
192                if Family::for_tool(&entry.name) == Some(*f) {
193                    tool_count += 1;
194                    sum += entry.total_tokens;
195                }
196            }
197            (f.name().to_string(), tool_count, sum)
198        })
199        .collect();
200    family_totals.sort_by_key(|(_, _, sum)| std::cmp::Reverse(*sum));
201
202    if args.json || args.raw_table {
203        // Always include the full per-tool table when --raw-table is
204        // set; --json gives the rolled-up view.
205        let payload = serde_json::json!({
206            "schema_version": "v0.6.4-tokens-1",
207            "tokenizer": "cl100k_base",
208            "active_profile": profile.families().iter().map(|f| f.name()).collect::<Vec<_>>(),
209            "active_total_tokens": active_total,
210            "full_profile_total_tokens": full_total,
211            "savings_tokens": savings,
212            "savings_pct": format!("{pct:.1}"),
213            "families": family_totals.iter().map(|(name, count, sum)| {
214                // Resolve family enum from the name to ask whether
215                // it is loaded under the active profile.
216                let fam = Family::all()
217                    .iter()
218                    .find(|f| f.name() == name)
219                    .copied()
220                    .unwrap_or(Family::Other);
221                serde_json::json!({
222                    "name": name,
223                    "tool_count": count,
224                    "tokens": sum,
225                    "loaded": profile.includes(fam),
226                })
227            }).collect::<Vec<_>>(),
228            "tools": if args.raw_table {
229                serde_json::Value::Array(
230                    table.iter().map(|t| serde_json::json!({
231                        "name": t.name,
232                        "tokens": t.total_tokens,
233                        "family": Family::for_tool(&t.name).map(|f| f.name()),
234                        "loaded_under_active_profile": profile.loads(&t.name),
235                    })).collect()
236                )
237            } else {
238                serde_json::Value::Null
239            },
240        });
241        writeln!(out.stdout, "{}", serde_json::to_string_pretty(&payload)?)?;
242        return Ok(0);
243    }
244
245    // Human-readable.
246    writeln!(out.stdout, "ai-memory doctor --tokens")?;
247    writeln!(
248        out.stdout,
249        "  Tokenizer: cl100k_base (Claude / GPT input accounting)"
250    )?;
251    writeln!(
252        out.stdout,
253        "  Active profile: {}",
254        profile
255            .families()
256            .iter()
257            .map(|f| f.name())
258            .collect::<Vec<_>>()
259            .join(",")
260    )?;
261    writeln!(out.stdout)?;
262    writeln!(out.stdout, "  Tool surface cost:")?;
263    writeln!(
264        out.stdout,
265        "    Active ({:>2} tools loaded): {:>6} tokens",
266        table.iter().filter(|t| profile.loads(&t.name)).count(),
267        active_total
268    )?;
269    writeln!(
270        out.stdout,
271        "    Full   ({:>2} tools loaded): {:>6} tokens",
272        table.len(),
273        full_total
274    )?;
275    writeln!(
276        out.stdout,
277        "    Savings vs full:           {:>6} tokens ({pct:.1}%)",
278        savings
279    )?;
280    writeln!(out.stdout)?;
281    writeln!(out.stdout, "  Per-family breakdown (sorted by total cost):")?;
282    for (name, count, sum) in &family_totals {
283        writeln!(
284            out.stdout,
285            "    {name:<12} {count:>2} tools  {sum:>6} tokens",
286        )?;
287    }
288    Ok(0)
289}
290
291/// Entry point. Returns the process exit code as a `i32` (0/1/2). The
292/// caller (daemon_runtime) must `std::process::exit(code)` after the WAL
293/// checkpoint has been skipped (doctor never writes).
294///
295/// # Errors
296///
297/// Returns `Err` only when the report itself cannot be written to the
298/// output stream — DB / HTTP errors are folded into NOT_AVAILABLE
299/// sections so a partial report still renders.
300pub fn run(db_path: &Path, args: &DoctorArgs, out: &mut CliOutput<'_>) -> Result<i32> {
301    let mut report = if let Some(url) = &args.remote {
302        run_remote(url, db_path)
303    } else {
304        run_local(db_path)
305    };
306    report.compute_overall();
307
308    if args.json {
309        writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
310    } else {
311        render_text(&report, out)?;
312    }
313
314    let code = match report.overall {
315        Severity::Critical => 2,
316        Severity::Warning if args.fail_on_warn => 1,
317        _ => 0,
318    };
319    Ok(code)
320}
321
322// ---------------------------------------------------------------------------
323// Local (--db) mode
324// ---------------------------------------------------------------------------
325
326fn run_local(db_path: &Path) -> Report {
327    let mut sections = Vec::with_capacity(7);
328
329    // Open the connection once; failures bubble into a single Critical
330    // section and the rest of the report is N/A.
331    let conn = match db::open(db_path) {
332        Ok(c) => c,
333        Err(e) => {
334            sections.push(ReportSection {
335                name: "Storage".into(),
336                severity: Severity::Critical,
337                facts: vec![("error".into(), e.to_string())],
338                note: Some(format!(
339                    "could not open database at {} — every other section is N/A",
340                    db_path.display()
341                )),
342            });
343            return Report {
344                mode: "local".into(),
345                source: db_path.display().to_string(),
346                generated_at: chrono::Utc::now().to_rfc3339(),
347                sections,
348                overall: Severity::Critical,
349            };
350        }
351    };
352
353    sections.push(section_storage(&conn, db_path));
354    sections.push(section_index(&conn));
355    sections.push(section_recall_local());
356    sections.push(section_governance(&conn));
357    sections.push(section_sync(&conn));
358    sections.push(section_webhook(&conn));
359    sections.push(section_capabilities_local());
360
361    Report {
362        mode: "local".into(),
363        source: db_path.display().to_string(),
364        generated_at: chrono::Utc::now().to_rfc3339(),
365        sections,
366        overall: Severity::Info,
367    }
368}
369
370fn section_storage(conn: &rusqlite::Connection, db_path: &Path) -> ReportSection {
371    let mut facts = Vec::new();
372    let mut severity = Severity::Info;
373    let mut note: Option<String> = None;
374
375    match db::stats(conn, db_path) {
376        Ok(stats) => {
377            facts.push(("total_memories".into(), stats.total.to_string()));
378            facts.push(("expiring_within_1h".into(), stats.expiring_soon.to_string()));
379            facts.push(("links".into(), stats.links_count.to_string()));
380            facts.push(("db_size_bytes".into(), stats.db_size_bytes.to_string()));
381            for tc in &stats.by_tier {
382                facts.push((format!("tier::{}", tc.tier), tc.count.to_string()));
383            }
384            for nc in stats.by_namespace.iter().take(10) {
385                facts.push((format!("ns::{}", nc.namespace), nc.count.to_string()));
386            }
387        }
388        Err(e) => {
389            severity = Severity::Warning;
390            facts.push(("stats_error".into(), e.to_string()));
391        }
392    }
393
394    // dim_violations (P2 surface). Pre-P2: Ok(None) -> render N/A line, no severity bump.
395    match db::doctor_dim_violations(conn) {
396        Ok(Some(0)) => {
397            facts.push(("dim_violations".into(), "0".into()));
398        }
399        Ok(Some(n)) => {
400            facts.push(("dim_violations".into(), n.to_string()));
401            severity = Severity::Critical;
402            note = Some(format!(
403                "{n} memories have an embedding dim that disagrees with their namespace's modal dim"
404            ));
405        }
406        Ok(None) => {
407            facts.push((
408                "dim_violations".into(),
409                "not_observed (pre-P2 schema)".into(),
410            ));
411        }
412        Err(e) => {
413            facts.push(("dim_violations_error".into(), e.to_string()));
414        }
415    }
416
417    ReportSection {
418        name: "Storage".into(),
419        severity,
420        facts,
421        note,
422    }
423}
424
425fn section_index(conn: &rusqlite::Connection) -> ReportSection {
426    let mut facts = Vec::new();
427    let mut severity = Severity::Info;
428    let mut note: Option<String> = None;
429
430    // HNSW size proxy: count of memories with an embedding (the in-memory
431    // index is rebuilt from this on startup).
432    let hnsw_size: i64 = conn
433        .query_row(
434            "SELECT COUNT(*) FROM memories WHERE embedding IS NOT NULL",
435            [],
436            |r| r.get(0),
437        )
438        .unwrap_or(0);
439    facts.push(("hnsw_size_estimate".into(), hnsw_size.to_string()));
440
441    // Cold-start cost: rough estimate of the time to rebuild HNSW on
442    // daemon restart, derived from the canonical-workload measured rate
443    // (~50k inserts/sec). Surfaced as a sanity-check signal, not a budget.
444    let cold_start_secs = (hnsw_size as f64) / 50_000.0;
445    facts.push((
446        "cold_start_rebuild_secs_estimate".into(),
447        format!("{cold_start_secs:.2}"),
448    ));
449
450    // Eviction counter (P3). Until P3 wires the in-memory counter into a
451    // queryable surface, render NOT_AVAILABLE without a severity bump.
452    facts.push((
453        "index_evictions_total".into(),
454        "not_observed (pre-P3 surface)".into(),
455    ));
456
457    // P3-aware path: when MAX_ENTRIES (100_000) is approached, advise the
458    // operator. This is a forward-leaning hint that becomes accurate once
459    // P3 lands the counter.
460    if hnsw_size >= 95_000 {
461        severity = Severity::Warning;
462        note = Some(format!(
463            "HNSW is at {hnsw_size} embeddings, within 5% of the 100k MAX_ENTRIES cap; \
464             P3 will start emitting eviction events"
465        ));
466    }
467
468    ReportSection {
469        name: "Index".into(),
470        severity,
471        facts,
472        note,
473    }
474}
475
476fn section_recall_local() -> ReportSection {
477    // Without P3's rolling window, the local doctor can only report the
478    // tier configuration that *would* drive recall today. The remote
479    // doctor (--remote) gets the live `recall_mode_active` from the v2
480    // capabilities endpoint when P1 lands.
481    ReportSection {
482        name: "Recall".into(),
483        severity: Severity::Info,
484        facts: vec![
485            (
486                "recall_mode_distribution".into(),
487                "not_observed (pre-P3 rolling counter)".into(),
488            ),
489            (
490                "reranker_used_distribution".into(),
491                "not_observed (pre-P3 rolling counter)".into(),
492            ),
493            (
494                "hint".into(),
495                "use --remote to read the live capabilities endpoint".into(),
496            ),
497        ],
498        note: None,
499    }
500}
501
502fn section_governance(conn: &rusqlite::Connection) -> ReportSection {
503    let mut facts = Vec::new();
504    let mut severity = Severity::Info;
505    let mut note: Option<String> = None;
506
507    let (with, without) = db::doctor_governance_coverage(conn).unwrap_or((0, 0));
508    facts.push(("namespaces_with_policy".into(), with.to_string()));
509    facts.push(("namespaces_without_policy".into(), without.to_string()));
510
511    let dist = db::doctor_governance_depth_distribution(conn).unwrap_or_default();
512    let depth_summary: String = dist
513        .iter()
514        .enumerate()
515        .filter(|(_, n)| **n > 0)
516        .map(|(d, n)| format!("d{d}={n}"))
517        .collect::<Vec<_>>()
518        .join(",");
519    facts.push((
520        "inheritance_depth".into(),
521        if depth_summary.is_empty() {
522            "empty".into()
523        } else {
524            depth_summary
525        },
526    ));
527
528    match db::doctor_oldest_pending_age_secs(conn) {
529        Ok(Some(age)) => {
530            facts.push(("oldest_pending_age_secs".into(), age.to_string()));
531            if age > 86_400 {
532                severity = Severity::Critical;
533                note = Some(format!(
534                    "oldest pending action is {age}s old (>{} threshold = 24h)",
535                    86_400
536                ));
537            }
538        }
539        Ok(None) => {
540            facts.push(("oldest_pending_age_secs".into(), "queue_empty".into()));
541        }
542        Err(e) => {
543            facts.push(("pending_query_error".into(), e.to_string()));
544        }
545    }
546
547    let pending_count = db::count_pending_actions_by_status(conn, "pending").unwrap_or(0);
548    facts.push(("pending_actions_total".into(), pending_count.to_string()));
549
550    ReportSection {
551        name: "Governance".into(),
552        severity,
553        facts,
554        note,
555    }
556}
557
558fn section_sync(conn: &rusqlite::Connection) -> ReportSection {
559    let mut facts = Vec::new();
560    let mut severity = Severity::Info;
561    let mut note: Option<String> = None;
562
563    let peer_count: i64 = conn
564        .query_row("SELECT COUNT(*) FROM sync_state", [], |r| r.get(0))
565        .unwrap_or(0);
566    facts.push(("peer_count".into(), peer_count.to_string()));
567
568    if peer_count == 0 {
569        facts.push((
570            "max_skew_secs".into(),
571            "not_observed (no peers registered)".into(),
572        ));
573        return ReportSection {
574            name: "Sync".into(),
575            severity: Severity::NotAvailable,
576            facts,
577            note: Some("no sync_state rows — single-node deployment or T3+ not yet enabled".into()),
578        };
579    }
580
581    match db::doctor_max_sync_skew_secs(conn) {
582        Ok(Some(skew)) => {
583            facts.push(("max_skew_secs".into(), skew.to_string()));
584            if skew > 600 {
585                severity = Severity::Critical;
586                note = Some(format!(
587                    "max sync skew is {skew}s (>600s threshold) — peer mesh is drifting"
588                ));
589            }
590        }
591        Ok(None) => {
592            facts.push(("max_skew_secs".into(), "not_observed".into()));
593        }
594        Err(e) => {
595            facts.push(("sync_query_error".into(), e.to_string()));
596        }
597    }
598
599    ReportSection {
600        name: "Sync".into(),
601        severity,
602        facts,
603        note,
604    }
605}
606
607fn section_webhook(conn: &rusqlite::Connection) -> ReportSection {
608    let mut facts = Vec::new();
609    let mut severity = Severity::Info;
610    let mut note: Option<String> = None;
611
612    let sub_count = db::count_subscriptions(conn).unwrap_or(0);
613    facts.push(("subscription_count".into(), sub_count.to_string()));
614
615    let (dispatched, failed) = db::doctor_webhook_delivery_totals(conn).unwrap_or((0, 0));
616    facts.push(("dispatched_total".into(), dispatched.to_string()));
617    facts.push(("failed_total".into(), failed.to_string()));
618
619    if dispatched > 0 {
620        let success_rate = ((dispatched.saturating_sub(failed)) as f64 / dispatched as f64) * 100.0;
621        facts.push(("success_rate_pct".into(), format!("{success_rate:.2}")));
622        // 95% lifetime success threshold. P5 will refine this to a
623        // rolling-1h window when the dispatch table grows a timestamp
624        // log; for now we use the lifetime totals already present in
625        // `subscriptions.dispatch_count` / `failure_count`.
626        if success_rate < 95.0 {
627            severity = Severity::Warning;
628            note = Some(format!(
629                "lifetime delivery success {success_rate:.2}% < 95% threshold"
630            ));
631        }
632    } else {
633        facts.push(("success_rate_pct".into(), "no_deliveries_yet".into()));
634    }
635
636    ReportSection {
637        name: "Webhook".into(),
638        severity,
639        facts,
640        note,
641    }
642}
643
644fn section_capabilities_local() -> ReportSection {
645    // The local doctor doesn't construct a TierConfig (would require
646    // loading user config). Surface the capability state via the remote
647    // mode against `--remote http://localhost:9077` instead. This local
648    // section just documents the gap.
649    ReportSection {
650        name: "Capabilities".into(),
651        severity: Severity::NotAvailable,
652        facts: vec![(
653            "capabilities".into(),
654            "use --remote <url> to query the live capabilities endpoint".into(),
655        )],
656        note: None,
657    }
658}
659
660// ---------------------------------------------------------------------------
661// Remote (--remote) mode
662// ---------------------------------------------------------------------------
663
664fn run_remote(url: &str, db_path: &Path) -> Report {
665    let mut sections = Vec::with_capacity(2);
666
667    let base = url.trim_end_matches('/');
668    let cap_url = format!("{base}/api/v1/capabilities");
669    let stats_url = format!("{base}/api/v1/stats");
670
671    sections.push(section_capabilities_remote(&cap_url));
672    sections.push(section_recall_remote(&cap_url));
673    sections.push(section_storage_remote(&stats_url));
674    sections.push(ReportSection {
675        name: "Index".into(),
676        severity: Severity::NotAvailable,
677        facts: vec![(
678            "hint".into(),
679            "raw SQL section — only available in --db mode".into(),
680        )],
681        note: None,
682    });
683    sections.push(ReportSection {
684        name: "Governance".into(),
685        severity: Severity::NotAvailable,
686        facts: vec![(
687            "hint".into(),
688            "raw SQL section — only available in --db mode".into(),
689        )],
690        note: None,
691    });
692    sections.push(ReportSection {
693        name: "Sync".into(),
694        severity: Severity::NotAvailable,
695        facts: vec![(
696            "hint".into(),
697            "raw SQL section — only available in --db mode".into(),
698        )],
699        note: None,
700    });
701    sections.push(ReportSection {
702        name: "Webhook".into(),
703        severity: Severity::NotAvailable,
704        facts: vec![(
705            "hint".into(),
706            "raw SQL section — only available in --db mode".into(),
707        )],
708        note: None,
709    });
710
711    Report {
712        mode: "remote".into(),
713        source: format!("{base} (local db reference: {})", db_path.display()),
714        generated_at: chrono::Utc::now().to_rfc3339(),
715        sections,
716        overall: Severity::Info,
717    }
718}
719
720/// Fetch a JSON document from `url` with a short timeout. Returns `Err`
721/// on transport failure or non-2xx status.
722fn http_get_json(url: &str) -> Result<Value> {
723    let client = reqwest::blocking::Client::builder()
724        .timeout(Duration::from_secs(5))
725        .build()
726        .context("constructing HTTP client")?;
727    let resp = client.get(url).send().context("HTTP GET")?;
728    let status = resp.status();
729    if !status.is_success() {
730        anyhow::bail!("HTTP {status} from {url}");
731    }
732    resp.json::<Value>().context("decoding JSON response")
733}
734
735fn section_capabilities_remote(url: &str) -> ReportSection {
736    let mut facts = Vec::new();
737    let mut severity = Severity::Info;
738    let mut note: Option<String> = None;
739
740    match http_get_json(url) {
741        Ok(v) => {
742            // schema_version: "1" (legacy v0.6.3) or "2" (post-P1).
743            let schema = v
744                .get("schema_version")
745                .and_then(Value::as_str)
746                .unwrap_or("unknown");
747            facts.push(("schema_version".into(), schema.to_string()));
748
749            // P1 v2 fields — best-effort lookup. The legacy v1 shape
750            // doesn't carry these; we render the missing ones as
751            // "not_in_response" rather than failing.
752            let recall_mode = v
753                .get("features")
754                .and_then(|f| f.get("recall_mode_active"))
755                .and_then(Value::as_str)
756                .unwrap_or("not_in_response");
757            facts.push(("recall_mode_active".into(), recall_mode.to_string()));
758
759            let reranker = v
760                .get("features")
761                .and_then(|f| f.get("reranker_active"))
762                .and_then(Value::as_str)
763                .unwrap_or("not_in_response");
764            facts.push(("reranker_active".into(), reranker.to_string()));
765
766            // Severity hints. recall_mode in {"degraded", "disabled",
767            // "keyword_only"} bumps to Warning when the tier is supposed
768            // to support hybrid (semantic / smart / autonomous).
769            if matches!(recall_mode, "degraded" | "disabled" | "keyword_only") {
770                let tier = v.get("feature_tier").and_then(Value::as_str).unwrap_or("");
771                if matches!(tier, "semantic" | "smart" | "autonomous") {
772                    severity = Severity::Warning;
773                    note = Some(format!(
774                        "tier={tier} but recall_mode_active={recall_mode} — silent degradation"
775                    ));
776                }
777            }
778        }
779        Err(e) => {
780            severity = Severity::Critical;
781            facts.push(("error".into(), e.to_string()));
782            note = Some(format!("could not reach {url}"));
783        }
784    }
785
786    ReportSection {
787        name: "Capabilities".into(),
788        severity,
789        facts,
790        note,
791    }
792}
793
794fn section_recall_remote(cap_url: &str) -> ReportSection {
795    let mut facts = Vec::new();
796    let severity = Severity::Info;
797
798    if let Ok(v) = http_get_json(cap_url) {
799        let recall_mode = v
800            .get("features")
801            .and_then(|f| f.get("recall_mode_active"))
802            .and_then(Value::as_str)
803            .unwrap_or("not_in_response");
804        facts.push(("active_recall_mode".into(), recall_mode.to_string()));
805        let reranker = v
806            .get("features")
807            .and_then(|f| f.get("reranker_active"))
808            .and_then(Value::as_str)
809            .unwrap_or("not_in_response");
810        facts.push(("active_reranker".into(), reranker.to_string()));
811        facts.push((
812            "recall_mode_distribution".into(),
813            "not_observed (pre-P3 rolling counter)".into(),
814        ));
815    } else {
816        facts.push(("error".into(), "could not fetch capabilities".into()));
817    }
818
819    ReportSection {
820        name: "Recall".into(),
821        severity,
822        facts,
823        note: None,
824    }
825}
826
827fn section_storage_remote(stats_url: &str) -> ReportSection {
828    let mut facts = Vec::new();
829    let severity = Severity::Info;
830
831    match http_get_json(stats_url) {
832        Ok(v) => {
833            if let Some(total) = v.get("total").and_then(Value::as_u64) {
834                facts.push(("total_memories".into(), total.to_string()));
835            }
836            if let Some(exp) = v.get("expiring_soon").and_then(Value::as_u64) {
837                facts.push(("expiring_within_1h".into(), exp.to_string()));
838            }
839            if let Some(links) = v.get("links_count").and_then(Value::as_u64) {
840                facts.push(("links".into(), links.to_string()));
841            }
842            facts.push((
843                "dim_violations".into(),
844                "not_in_remote_response (P2 surface lands at /api/v1/stats)".into(),
845            ));
846        }
847        Err(e) => {
848            facts.push(("error".into(), e.to_string()));
849        }
850    }
851
852    ReportSection {
853        name: "Storage".into(),
854        severity,
855        facts,
856        note: None,
857    }
858}
859
860// ---------------------------------------------------------------------------
861// Text rendering
862// ---------------------------------------------------------------------------
863
864fn render_text(report: &Report, out: &mut CliOutput<'_>) -> Result<()> {
865    writeln!(out.stdout, "ai-memory doctor — {} mode", report.mode)?;
866    writeln!(out.stdout, "  source:       {}", report.source)?;
867    writeln!(out.stdout, "  generated_at: {}", report.generated_at)?;
868    writeln!(out.stdout, "  overall:      {}", report.overall.label())?;
869    writeln!(out.stdout)?;
870    for section in &report.sections {
871        writeln!(
872            out.stdout,
873            "[{}] {}",
874            section.severity.label(),
875            section.name
876        )?;
877        for (k, v) in &section.facts {
878            writeln!(out.stdout, "    {k:<32} {v}")?;
879        }
880        if let Some(note) = &section.note {
881            writeln!(out.stdout, "    note: {note}")?;
882        }
883        writeln!(out.stdout)?;
884    }
885    Ok(())
886}
887
888// ---------------------------------------------------------------------------
889// Tests (unit-level — full integration tests live in tests/doctor_cli.rs)
890// ---------------------------------------------------------------------------
891
892#[cfg(test)]
893#[allow(clippy::too_many_lines, clippy::similar_names)]
894mod tests {
895    use super::*;
896    use crate::cli::CliOutput;
897    use crate::cli::test_utils::{TestEnv, seed_memory};
898    use rusqlite::params;
899
900    // -------------------------------------------------------------------
901    // Severity / Report helpers (pure, no DB)
902    // -------------------------------------------------------------------
903
904    #[test]
905    fn severity_rank_orders_critical_highest() {
906        assert!(Report::rank(Severity::Critical) > Report::rank(Severity::Warning));
907        assert!(Report::rank(Severity::Warning) > Report::rank(Severity::Info));
908        assert!(Report::rank(Severity::Info) > Report::rank(Severity::NotAvailable));
909    }
910
911    #[test]
912    fn severity_label_renders_for_every_variant() {
913        assert_eq!(Severity::Info.label(), "INFO");
914        assert_eq!(Severity::Warning.label(), "WARN");
915        assert_eq!(Severity::Critical.label(), "CRIT");
916        assert_eq!(Severity::NotAvailable.label(), "N/A ");
917    }
918
919    #[test]
920    fn severity_serializes_lowercase_and_round_trips() {
921        // The Serialize derive uses `rename_all = "lowercase"`. We don't
922        // derive Deserialize, so we round-trip via the JSON Value form.
923        let s = serde_json::to_value(Severity::Critical).unwrap();
924        assert_eq!(s, serde_json::Value::String("critical".into()));
925        let s = serde_json::to_value(Severity::NotAvailable).unwrap();
926        assert_eq!(s, serde_json::Value::String("notavailable".into()));
927    }
928
929    fn mk_section(name: &str, severity: Severity) -> ReportSection {
930        ReportSection {
931            name: name.into(),
932            severity,
933            facts: vec![("k".into(), "v".into())],
934            note: None,
935        }
936    }
937
938    fn mk_report(sections: Vec<ReportSection>) -> Report {
939        Report {
940            mode: "local".into(),
941            source: ":memory:".into(),
942            generated_at: "now".into(),
943            sections,
944            overall: Severity::Info,
945        }
946    }
947
948    #[test]
949    fn compute_overall_picks_critical_when_present() {
950        let mut r = mk_report(vec![
951            mk_section("A", Severity::Info),
952            mk_section("B", Severity::Critical),
953            mk_section("C", Severity::Warning),
954        ]);
955        r.compute_overall();
956        assert_eq!(r.overall, Severity::Critical);
957    }
958
959    #[test]
960    fn compute_overall_picks_warning_when_no_critical() {
961        let mut r = mk_report(vec![
962            mk_section("A", Severity::Info),
963            mk_section("B", Severity::Warning),
964        ]);
965        r.compute_overall();
966        assert_eq!(r.overall, Severity::Warning);
967    }
968
969    #[test]
970    fn compute_overall_picks_info_when_no_warnings_or_critical() {
971        let mut r = mk_report(vec![
972            mk_section("A", Severity::NotAvailable),
973            mk_section("B", Severity::Info),
974        ]);
975        r.compute_overall();
976        assert_eq!(r.overall, Severity::Info);
977    }
978
979    #[test]
980    fn compute_overall_handles_empty_sections() {
981        let mut r = mk_report(vec![]);
982        r.compute_overall();
983        // unwrap_or fallback path — empty iterator collapses to Info.
984        assert_eq!(r.overall, Severity::Info);
985    }
986
987    #[test]
988    fn compute_overall_only_n_a_yields_n_a() {
989        let mut r = mk_report(vec![
990            mk_section("A", Severity::NotAvailable),
991            mk_section("B", Severity::NotAvailable),
992        ]);
993        r.compute_overall();
994        assert_eq!(r.overall, Severity::NotAvailable);
995    }
996
997    // -------------------------------------------------------------------
998    // ReportSection / Report serde shape
999    // -------------------------------------------------------------------
1000
1001    #[test]
1002    fn report_section_serializes_with_expected_keys() {
1003        let section = ReportSection {
1004            name: "Storage".into(),
1005            severity: Severity::Warning,
1006            facts: vec![("total".into(), "5".into())],
1007            note: Some("hello".into()),
1008        };
1009        let v = serde_json::to_value(&section).unwrap();
1010        assert_eq!(v["name"], "Storage");
1011        assert_eq!(v["severity"], "warning");
1012        // Facts is a list of 2-tuples encoded as JSON arrays.
1013        assert!(v["facts"].is_array());
1014        assert_eq!(v["facts"][0][0], "total");
1015        assert_eq!(v["facts"][0][1], "5");
1016        assert_eq!(v["note"], "hello");
1017    }
1018
1019    #[test]
1020    fn report_section_skips_note_when_none() {
1021        let section = ReportSection {
1022            name: "Recall".into(),
1023            severity: Severity::Info,
1024            facts: vec![],
1025            note: None,
1026        };
1027        let v = serde_json::to_value(&section).unwrap();
1028        assert!(
1029            v.get("note").is_none(),
1030            "note=None must be skipped per #[serde(skip_serializing_if)]"
1031        );
1032    }
1033
1034    #[test]
1035    fn report_top_level_serialization_has_all_fields() {
1036        let r = mk_report(vec![mk_section("S", Severity::Info)]);
1037        let v = serde_json::to_value(&r).unwrap();
1038        for k in ["mode", "source", "generated_at", "sections", "overall"] {
1039            assert!(v.get(k).is_some(), "expected key {k} in JSON");
1040        }
1041        assert_eq!(v["sections"].as_array().unwrap().len(), 1);
1042    }
1043
1044    // -------------------------------------------------------------------
1045    // Local-DB mode — basic happy path
1046    // -------------------------------------------------------------------
1047
1048    fn run_local_collect(db_path: &Path) -> Report {
1049        let mut report = run_local(db_path);
1050        report.compute_overall();
1051        report
1052    }
1053
1054    fn find<'a>(report: &'a Report, name: &str) -> &'a ReportSection {
1055        report
1056            .sections
1057            .iter()
1058            .find(|s| s.name == name)
1059            .unwrap_or_else(|| panic!("section {name} not found"))
1060    }
1061
1062    fn fact<'a>(section: &'a ReportSection, key: &str) -> &'a str {
1063        section
1064            .facts
1065            .iter()
1066            .find(|(k, _)| k == key)
1067            .map(|(_, v)| v.as_str())
1068            .unwrap_or_else(|| panic!("fact {key} not found in section {}", section.name))
1069    }
1070
1071    #[test]
1072    fn local_run_on_empty_db_produces_seven_sections() {
1073        let env = TestEnv::fresh();
1074        let report = run_local_collect(&env.db_path);
1075        assert_eq!(report.mode, "local");
1076        assert_eq!(report.sections.len(), 7);
1077        let names: Vec<&str> = report.sections.iter().map(|s| s.name.as_str()).collect();
1078        assert_eq!(
1079            names,
1080            vec![
1081                "Storage",
1082                "Index",
1083                "Recall",
1084                "Governance",
1085                "Sync",
1086                "Webhook",
1087                "Capabilities"
1088            ]
1089        );
1090    }
1091
1092    #[test]
1093    fn local_run_empty_db_storage_section_is_info() {
1094        let env = TestEnv::fresh();
1095        let report = run_local_collect(&env.db_path);
1096        let storage = find(&report, "Storage");
1097        assert_eq!(storage.severity, Severity::Info);
1098        assert_eq!(fact(storage, "total_memories"), "0");
1099        // Pre-P2 schema (current release) has no `embedding_dim` column —
1100        // `db::doctor_dim_violations` returns Ok(None), rendered as
1101        // "not_observed (pre-P2 schema)".
1102        let dim = fact(storage, "dim_violations");
1103        assert!(
1104            dim.contains("not_observed") || dim == "0",
1105            "unexpected dim_violations value: {dim}"
1106        );
1107    }
1108
1109    #[test]
1110    fn local_run_with_seeded_memory_reports_total() {
1111        let env = TestEnv::fresh();
1112        seed_memory(&env.db_path, "ns-a", "title-1", "content one");
1113        seed_memory(&env.db_path, "ns-a", "title-2", "content two");
1114        seed_memory(&env.db_path, "ns-b", "title-3", "content three");
1115        let report = run_local_collect(&env.db_path);
1116        let storage = find(&report, "Storage");
1117        assert_eq!(fact(storage, "total_memories"), "3");
1118        // Tier breakdown — seed_memory inserts at tier=mid.
1119        let tier_mid = storage
1120            .facts
1121            .iter()
1122            .find(|(k, _)| k == "tier::mid")
1123            .map(|(_, v)| v.as_str());
1124        assert_eq!(tier_mid, Some("3"));
1125        // Namespace breakdown caps at 10 entries; 2 namespaces fit.
1126        let ns_a = storage
1127            .facts
1128            .iter()
1129            .find(|(k, _)| k == "ns::ns-a")
1130            .map(|(_, v)| v.as_str());
1131        let ns_b = storage
1132            .facts
1133            .iter()
1134            .find(|(k, _)| k == "ns::ns-b")
1135            .map(|(_, v)| v.as_str());
1136        assert_eq!(ns_a, Some("2"));
1137        assert_eq!(ns_b, Some("1"));
1138    }
1139
1140    #[test]
1141    fn local_run_index_section_reports_hnsw_estimate() {
1142        let env = TestEnv::fresh();
1143        seed_memory(&env.db_path, "ns", "t1", "c1");
1144        let report = run_local_collect(&env.db_path);
1145        let index = find(&report, "Index");
1146        // seed_memory does not write an embedding so hnsw_size_estimate=0.
1147        assert_eq!(fact(index, "hnsw_size_estimate"), "0");
1148        // Cold-start estimate is rendered with two decimals.
1149        let cs = fact(index, "cold_start_rebuild_secs_estimate");
1150        assert!(
1151            cs.contains('.'),
1152            "cold_start_secs_estimate should be float-like, got {cs}"
1153        );
1154        assert_eq!(index.severity, Severity::Info);
1155    }
1156
1157    #[test]
1158    fn local_run_recall_section_documents_pre_p3_state() {
1159        let env = TestEnv::fresh();
1160        let report = run_local_collect(&env.db_path);
1161        let recall = find(&report, "Recall");
1162        assert_eq!(recall.severity, Severity::Info);
1163        assert!(fact(recall, "recall_mode_distribution").contains("pre-P3"));
1164        assert!(fact(recall, "reranker_used_distribution").contains("pre-P3"));
1165        // Hint nudges the operator toward --remote for the live feed.
1166        assert!(fact(recall, "hint").contains("--remote"));
1167    }
1168
1169    #[test]
1170    fn local_run_sync_section_n_a_when_no_peers() {
1171        let env = TestEnv::fresh();
1172        let report = run_local_collect(&env.db_path);
1173        let sync = find(&report, "Sync");
1174        // Empty sync_state => NotAvailable + note.
1175        assert_eq!(sync.severity, Severity::NotAvailable);
1176        assert_eq!(fact(sync, "peer_count"), "0");
1177        assert!(sync.note.is_some());
1178    }
1179
1180    #[test]
1181    fn local_run_capabilities_local_section_n_a() {
1182        let env = TestEnv::fresh();
1183        let report = run_local_collect(&env.db_path);
1184        let cap = find(&report, "Capabilities");
1185        assert_eq!(cap.severity, Severity::NotAvailable);
1186        assert!(fact(cap, "capabilities").contains("--remote"));
1187    }
1188
1189    #[test]
1190    fn local_run_governance_section_empty_is_info() {
1191        let env = TestEnv::fresh();
1192        let report = run_local_collect(&env.db_path);
1193        let gov = find(&report, "Governance");
1194        assert_eq!(gov.severity, Severity::Info);
1195        assert_eq!(fact(gov, "namespaces_with_policy"), "0");
1196        assert_eq!(fact(gov, "namespaces_without_policy"), "0");
1197        assert_eq!(fact(gov, "inheritance_depth"), "empty");
1198        assert_eq!(fact(gov, "oldest_pending_age_secs"), "queue_empty");
1199        assert_eq!(fact(gov, "pending_actions_total"), "0");
1200    }
1201
1202    #[test]
1203    fn local_run_webhook_section_empty_no_deliveries() {
1204        let env = TestEnv::fresh();
1205        let report = run_local_collect(&env.db_path);
1206        let wh = find(&report, "Webhook");
1207        assert_eq!(wh.severity, Severity::Info);
1208        assert_eq!(fact(wh, "subscription_count"), "0");
1209        assert_eq!(fact(wh, "dispatched_total"), "0");
1210        assert_eq!(fact(wh, "failed_total"), "0");
1211        assert_eq!(fact(wh, "success_rate_pct"), "no_deliveries_yet");
1212    }
1213
1214    // -------------------------------------------------------------------
1215    // Severity rule cases — DB-backed
1216    // -------------------------------------------------------------------
1217
1218    #[test]
1219    fn governance_section_critical_when_pending_older_than_24h() {
1220        let env = TestEnv::fresh();
1221        // Open the DB once to materialize schema, then write a pending row.
1222        {
1223            let conn = crate::db::open(&env.db_path).unwrap();
1224            let twenty_five_hours_ago =
1225                (chrono::Utc::now() - chrono::Duration::hours(25)).to_rfc3339();
1226            conn.execute(
1227                "INSERT INTO pending_actions \
1228                 (id, action_type, namespace, payload, requested_by, requested_at, status) \
1229                 VALUES ('p1', 'store', 'ns', '{}', 'agent', ?1, 'pending')",
1230                params![twenty_five_hours_ago],
1231            )
1232            .unwrap();
1233        }
1234        let report = run_local_collect(&env.db_path);
1235        let gov = find(&report, "Governance");
1236        assert_eq!(gov.severity, Severity::Critical);
1237        assert!(gov.note.as_ref().unwrap().contains("24h"));
1238        // pending_actions_total reflects the row.
1239        assert_eq!(fact(gov, "pending_actions_total"), "1");
1240        // overall picks the Critical from Governance.
1241        assert_eq!(report.overall, Severity::Critical);
1242    }
1243
1244    #[test]
1245    fn governance_section_info_when_pending_younger_than_24h() {
1246        let env = TestEnv::fresh();
1247        {
1248            let conn = crate::db::open(&env.db_path).unwrap();
1249            let one_hour_ago = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
1250            conn.execute(
1251                "INSERT INTO pending_actions \
1252                 (id, action_type, namespace, payload, requested_by, requested_at, status) \
1253                 VALUES ('p2', 'store', 'ns', '{}', 'agent', ?1, 'pending')",
1254                params![one_hour_ago],
1255            )
1256            .unwrap();
1257        }
1258        let report = run_local_collect(&env.db_path);
1259        let gov = find(&report, "Governance");
1260        // 1h pending — under the 24h threshold; Info, no critical bump.
1261        assert_eq!(gov.severity, Severity::Info);
1262        assert_eq!(fact(gov, "pending_actions_total"), "1");
1263        // The age fact is set to a numeric string, not "queue_empty".
1264        let age_str = fact(gov, "oldest_pending_age_secs");
1265        assert!(
1266            age_str.parse::<i64>().is_ok(),
1267            "expected numeric age, got {age_str}"
1268        );
1269    }
1270
1271    #[test]
1272    fn sync_section_critical_when_skew_exceeds_600s() {
1273        let env = TestEnv::fresh();
1274        {
1275            let conn = crate::db::open(&env.db_path).unwrap();
1276            // last_seen_at = now, last_pulled_at = 1 hour ago → 3600s skew.
1277            let now = chrono::Utc::now();
1278            let now_s = now.to_rfc3339();
1279            let earlier = (now - chrono::Duration::seconds(3600)).to_rfc3339();
1280            conn.execute(
1281                "INSERT INTO sync_state (agent_id, peer_id, last_seen_at, last_pulled_at) \
1282                 VALUES ('me', 'peer-1', ?1, ?2)",
1283                params![now_s, earlier],
1284            )
1285            .unwrap();
1286        }
1287        let report = run_local_collect(&env.db_path);
1288        let sync = find(&report, "Sync");
1289        assert_eq!(sync.severity, Severity::Critical);
1290        assert!(sync.note.as_ref().unwrap().contains("600s"));
1291        assert_eq!(fact(sync, "peer_count"), "1");
1292        assert_eq!(report.overall, Severity::Critical);
1293    }
1294
1295    #[test]
1296    fn sync_section_info_when_skew_under_threshold() {
1297        let env = TestEnv::fresh();
1298        {
1299            let conn = crate::db::open(&env.db_path).unwrap();
1300            let now = chrono::Utc::now();
1301            let now_s = now.to_rfc3339();
1302            let close = (now - chrono::Duration::seconds(60)).to_rfc3339();
1303            conn.execute(
1304                "INSERT INTO sync_state (agent_id, peer_id, last_seen_at, last_pulled_at) \
1305                 VALUES ('me', 'peer-1', ?1, ?2)",
1306                params![now_s, close],
1307            )
1308            .unwrap();
1309        }
1310        let report = run_local_collect(&env.db_path);
1311        let sync = find(&report, "Sync");
1312        assert_eq!(sync.severity, Severity::Info);
1313        // peer_count=1, skew column rendered as a numeric string.
1314        assert_eq!(fact(sync, "peer_count"), "1");
1315        let skew = fact(sync, "max_skew_secs");
1316        assert!(
1317            skew.parse::<i64>().is_ok(),
1318            "expected numeric skew, got {skew}"
1319        );
1320    }
1321
1322    #[test]
1323    fn webhook_section_warning_when_success_rate_below_95() {
1324        let env = TestEnv::fresh();
1325        {
1326            let conn = crate::db::open(&env.db_path).unwrap();
1327            // 100 dispatches, 10 failures = 90% success → < 95% threshold.
1328            let now = chrono::Utc::now().to_rfc3339();
1329            conn.execute(
1330                "INSERT INTO subscriptions \
1331                 (id, url, events, created_at, dispatch_count, failure_count) \
1332                 VALUES ('s1', 'http://example/x', '*', ?1, 100, 10)",
1333                params![now],
1334            )
1335            .unwrap();
1336        }
1337        let report = run_local_collect(&env.db_path);
1338        let wh = find(&report, "Webhook");
1339        assert_eq!(wh.severity, Severity::Warning);
1340        assert!(wh.note.as_ref().unwrap().contains("95%"));
1341        assert_eq!(fact(wh, "subscription_count"), "1");
1342        assert_eq!(fact(wh, "dispatched_total"), "100");
1343        assert_eq!(fact(wh, "failed_total"), "10");
1344        assert_eq!(fact(wh, "success_rate_pct"), "90.00");
1345    }
1346
1347    #[test]
1348    fn webhook_section_info_when_success_rate_at_or_above_95() {
1349        let env = TestEnv::fresh();
1350        {
1351            let conn = crate::db::open(&env.db_path).unwrap();
1352            let now = chrono::Utc::now().to_rfc3339();
1353            // 100 dispatches, 3 failures = 97% success.
1354            conn.execute(
1355                "INSERT INTO subscriptions \
1356                 (id, url, events, created_at, dispatch_count, failure_count) \
1357                 VALUES ('s1', 'http://example/x', '*', ?1, 100, 3)",
1358                params![now],
1359            )
1360            .unwrap();
1361        }
1362        let report = run_local_collect(&env.db_path);
1363        let wh = find(&report, "Webhook");
1364        assert_eq!(wh.severity, Severity::Info);
1365        assert!(wh.note.is_none());
1366        assert_eq!(fact(wh, "success_rate_pct"), "97.00");
1367    }
1368
1369    #[test]
1370    fn governance_section_with_namespace_chain_reports_depths() {
1371        let env = TestEnv::fresh();
1372        {
1373            let conn = crate::db::open(&env.db_path).unwrap();
1374            let now = chrono::Utc::now().to_rfc3339();
1375            for (ns, parent) in [
1376                ("root", None::<&str>),
1377                ("a", Some("root")),
1378                ("a/b", Some("a")),
1379            ] {
1380                conn.execute(
1381                    "INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) \
1382                     VALUES (?1, ?2, ?3)",
1383                    params![ns, parent, now],
1384                )
1385                .unwrap();
1386            }
1387        }
1388        let report = run_local_collect(&env.db_path);
1389        let gov = find(&report, "Governance");
1390        assert_eq!(gov.severity, Severity::Info);
1391        let depth = fact(gov, "inheritance_depth");
1392        assert!(depth.contains("d0=") && depth.contains("d1=") && depth.contains("d2="));
1393        assert_eq!(fact(gov, "namespaces_without_policy"), "3");
1394    }
1395
1396    // -------------------------------------------------------------------
1397    // run() entry point — JSON / text / exit code branches
1398    // -------------------------------------------------------------------
1399
1400    #[test]
1401    fn run_emits_json_when_json_flag_set() {
1402        let mut env = TestEnv::fresh();
1403        let db_path = env.db_path.clone();
1404        let mut out = env.output();
1405        let exit = run(
1406            &db_path,
1407            &DoctorArgs {
1408                remote: None,
1409                json: true,
1410                fail_on_warn: false,
1411            },
1412            &mut out,
1413        )
1414        .unwrap();
1415        // Healthy fresh DB → exit 0.
1416        assert_eq!(exit, 0);
1417        let s = env.stdout_str();
1418        let v: serde_json::Value = serde_json::from_str(s).expect("JSON output must parse");
1419        assert_eq!(v["mode"], "local");
1420        assert!(v["sections"].is_array());
1421        assert!(v["overall"].is_string());
1422    }
1423
1424    #[test]
1425    fn run_emits_text_by_default() {
1426        let mut env = TestEnv::fresh();
1427        let db_path = env.db_path.clone();
1428        let mut out = env.output();
1429        let exit = run(
1430            &db_path,
1431            &DoctorArgs {
1432                remote: None,
1433                json: false,
1434                fail_on_warn: false,
1435            },
1436            &mut out,
1437        )
1438        .unwrap();
1439        assert_eq!(exit, 0);
1440        let s = env.stdout_str();
1441        // Header + section labels.
1442        assert!(s.contains("ai-memory doctor — local mode"));
1443        assert!(s.contains("[INFO] Storage"));
1444        assert!(s.contains("[INFO] Index"));
1445        assert!(s.contains("[N/A ] Capabilities"));
1446        // The label-prefixed fact key column is left-padded to 32 chars
1447        // (smoke check that the format string compiles).
1448        assert!(s.contains("total_memories"));
1449    }
1450
1451    #[test]
1452    fn run_returns_exit_2_on_critical() {
1453        let mut env = TestEnv::fresh();
1454        // Inject a 25h-old pending action → Governance CRIT → overall CRIT.
1455        {
1456            let conn = crate::db::open(&env.db_path).unwrap();
1457            let twenty_five_hours_ago =
1458                (chrono::Utc::now() - chrono::Duration::hours(25)).to_rfc3339();
1459            conn.execute(
1460                "INSERT INTO pending_actions \
1461                 (id, action_type, namespace, payload, requested_by, requested_at, status) \
1462                 VALUES ('p1', 'store', 'ns', '{}', 'agent', ?1, 'pending')",
1463                params![twenty_five_hours_ago],
1464            )
1465            .unwrap();
1466        }
1467        let db_path = env.db_path.clone();
1468        let mut out = env.output();
1469        let exit = run(
1470            &db_path,
1471            &DoctorArgs {
1472                remote: None,
1473                json: true,
1474                fail_on_warn: false,
1475            },
1476            &mut out,
1477        )
1478        .unwrap();
1479        assert_eq!(exit, 2);
1480        // JSON overall is "critical".
1481        let v: serde_json::Value = serde_json::from_str(env.stdout_str()).unwrap();
1482        assert_eq!(v["overall"], "critical");
1483    }
1484
1485    #[test]
1486    fn run_warning_keeps_exit_0_without_fail_on_warn() {
1487        let mut env = TestEnv::fresh();
1488        {
1489            let conn = crate::db::open(&env.db_path).unwrap();
1490            let now = chrono::Utc::now().to_rfc3339();
1491            conn.execute(
1492                "INSERT INTO subscriptions \
1493                 (id, url, events, created_at, dispatch_count, failure_count) \
1494                 VALUES ('s1', 'http://x', '*', ?1, 10, 5)",
1495                params![now],
1496            )
1497            .unwrap();
1498        }
1499        let db_path = env.db_path.clone();
1500        let mut out = env.output();
1501        let exit = run(
1502            &db_path,
1503            &DoctorArgs {
1504                remote: None,
1505                json: false,
1506                fail_on_warn: false,
1507            },
1508            &mut out,
1509        )
1510        .unwrap();
1511        assert_eq!(exit, 0, "warning without --fail-on-warn must keep exit 0");
1512        assert!(env.stdout_str().contains("[WARN] Webhook"));
1513    }
1514
1515    #[test]
1516    fn run_warning_returns_exit_1_with_fail_on_warn() {
1517        let mut env = TestEnv::fresh();
1518        {
1519            let conn = crate::db::open(&env.db_path).unwrap();
1520            let now = chrono::Utc::now().to_rfc3339();
1521            conn.execute(
1522                "INSERT INTO subscriptions \
1523                 (id, url, events, created_at, dispatch_count, failure_count) \
1524                 VALUES ('s1', 'http://x', '*', ?1, 10, 5)",
1525                params![now],
1526            )
1527            .unwrap();
1528        }
1529        let db_path = env.db_path.clone();
1530        let mut out = env.output();
1531        let exit = run(
1532            &db_path,
1533            &DoctorArgs {
1534                remote: None,
1535                json: false,
1536                fail_on_warn: true,
1537            },
1538            &mut out,
1539        )
1540        .unwrap();
1541        assert_eq!(exit, 1, "--fail-on-warn must promote warning to exit 1");
1542    }
1543
1544    #[test]
1545    fn run_critical_is_exit_2_even_without_fail_on_warn() {
1546        let mut env = TestEnv::fresh();
1547        {
1548            let conn = crate::db::open(&env.db_path).unwrap();
1549            let twenty_five_hours_ago =
1550                (chrono::Utc::now() - chrono::Duration::hours(25)).to_rfc3339();
1551            conn.execute(
1552                "INSERT INTO pending_actions \
1553                 (id, action_type, namespace, payload, requested_by, requested_at, status) \
1554                 VALUES ('p1', 'store', 'ns', '{}', 'agent', ?1, 'pending')",
1555                params![twenty_five_hours_ago],
1556            )
1557            .unwrap();
1558        }
1559        let db_path = env.db_path.clone();
1560        let mut out = env.output();
1561        let exit = run(
1562            &db_path,
1563            &DoctorArgs {
1564                remote: None,
1565                json: false,
1566                fail_on_warn: false,
1567            },
1568            &mut out,
1569        )
1570        .unwrap();
1571        assert_eq!(exit, 2);
1572    }
1573
1574    // -------------------------------------------------------------------
1575    // run() — corrupt DB path: db::open() fails → CRITICAL Storage section.
1576    // -------------------------------------------------------------------
1577
1578    #[test]
1579    fn local_run_on_unopenable_db_returns_critical_storage_only() {
1580        let tmp = tempfile::tempdir().unwrap();
1581        let bad = tmp.path().join("not-a-db.db");
1582        // Write garbage so SQLite refuses to open it.
1583        std::fs::write(&bad, b"this is not a sqlite database, it's just text").unwrap();
1584        let report = run_local_collect(&bad);
1585        // The error path appends a single Storage section and returns.
1586        assert_eq!(report.sections.len(), 1);
1587        let storage = &report.sections[0];
1588        assert_eq!(storage.name, "Storage");
1589        assert_eq!(storage.severity, Severity::Critical);
1590        // overall is computed from the single section.
1591        assert_eq!(report.overall, Severity::Critical);
1592        assert!(storage.note.as_ref().unwrap().contains("could not open"));
1593    }
1594
1595    // -------------------------------------------------------------------
1596    // Render helpers
1597    // -------------------------------------------------------------------
1598
1599    #[test]
1600    fn render_text_emits_section_note_when_present() {
1601        let r = mk_report(vec![ReportSection {
1602            name: "Sync".into(),
1603            severity: Severity::Critical,
1604            facts: vec![("max_skew_secs".into(), "9999".into())],
1605            note: Some("peer mesh is drifting".into()),
1606        }]);
1607        let mut stdout = Vec::<u8>::new();
1608        let mut stderr = Vec::<u8>::new();
1609        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1610        render_text(&r, &mut out).unwrap();
1611        let s = String::from_utf8(stdout).unwrap();
1612        assert!(s.contains("[CRIT] Sync"));
1613        assert!(s.contains("note: peer mesh is drifting"));
1614        assert!(s.contains("max_skew_secs"));
1615        assert!(s.contains("9999"));
1616    }
1617
1618    // -------------------------------------------------------------------
1619    // Remote (--remote) mode — wiremock-driven HTTP fixtures
1620    // -------------------------------------------------------------------
1621
1622    /// Helper: run `run_remote` from a multi-thread tokio test by spawning
1623    /// the blocking reqwest call onto the spawn_blocking pool.
1624    async fn run_remote_in_blocking(url: String, db_path: PathBuf) -> Report {
1625        tokio::task::spawn_blocking(move || {
1626            let mut r = run_remote(&url, &db_path);
1627            r.compute_overall();
1628            r
1629        })
1630        .await
1631        .unwrap()
1632    }
1633
1634    use std::path::PathBuf;
1635
1636    #[tokio::test(flavor = "multi_thread")]
1637    async fn remote_section_capabilities_parses_v2_fields() {
1638        use wiremock::matchers::{method, path};
1639        use wiremock::{Mock, MockServer, ResponseTemplate};
1640        let server = MockServer::start().await;
1641        Mock::given(method("GET"))
1642            .and(path("/api/v1/capabilities"))
1643            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1644                "schema_version": "2",
1645                "feature_tier": "smart",
1646                "features": {
1647                    "recall_mode_active": "hybrid",
1648                    "reranker_active": "cross_encoder"
1649                }
1650            })))
1651            .mount(&server)
1652            .await;
1653        Mock::given(method("GET"))
1654            .and(path("/api/v1/stats"))
1655            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1656                "total": 42,
1657                "expiring_soon": 1,
1658                "links_count": 3
1659            })))
1660            .mount(&server)
1661            .await;
1662
1663        let env = TestEnv::fresh();
1664        let report = run_remote_in_blocking(server.uri(), env.db_path.clone()).await;
1665        assert_eq!(report.mode, "remote");
1666        assert!(report.source.starts_with(&server.uri()));
1667        // Sections: 7 total — Capabilities, Recall, Storage, Index, Governance, Sync, Webhook.
1668        assert_eq!(report.sections.len(), 7);
1669
1670        let cap = find(&report, "Capabilities");
1671        assert_eq!(cap.severity, Severity::Info);
1672        assert_eq!(fact(cap, "schema_version"), "2");
1673        assert_eq!(fact(cap, "recall_mode_active"), "hybrid");
1674        assert_eq!(fact(cap, "reranker_active"), "cross_encoder");
1675
1676        let recall = find(&report, "Recall");
1677        assert_eq!(fact(recall, "active_recall_mode"), "hybrid");
1678        assert_eq!(fact(recall, "active_reranker"), "cross_encoder");
1679
1680        let storage = find(&report, "Storage");
1681        assert_eq!(fact(storage, "total_memories"), "42");
1682        assert_eq!(fact(storage, "expiring_within_1h"), "1");
1683        assert_eq!(fact(storage, "links"), "3");
1684
1685        // Raw-SQL sections must be NotAvailable in remote mode.
1686        for raw in ["Index", "Governance", "Sync", "Webhook"] {
1687            let s = find(&report, raw);
1688            assert_eq!(s.severity, Severity::NotAvailable);
1689            assert!(fact(s, "hint").contains("--db mode"));
1690        }
1691    }
1692
1693    #[tokio::test(flavor = "multi_thread")]
1694    async fn remote_capabilities_silent_degrade_warns_on_capable_tier() {
1695        use wiremock::matchers::{method, path};
1696        use wiremock::{Mock, MockServer, ResponseTemplate};
1697        let server = MockServer::start().await;
1698        Mock::given(method("GET"))
1699            .and(path("/api/v1/capabilities"))
1700            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1701                "schema_version": "2",
1702                "feature_tier": "semantic",
1703                "features": {
1704                    "recall_mode_active": "keyword_only",
1705                    "reranker_active": "none"
1706                }
1707            })))
1708            .mount(&server)
1709            .await;
1710        // /api/v1/stats not mocked → 404 → Storage carries an error fact
1711        // but no severity bump (severity stays Info per the code path).
1712        let env = TestEnv::fresh();
1713        let report = run_remote_in_blocking(server.uri(), env.db_path.clone()).await;
1714        let cap = find(&report, "Capabilities");
1715        assert_eq!(cap.severity, Severity::Warning);
1716        assert!(cap.note.as_ref().unwrap().contains("silent degradation"));
1717    }
1718
1719    #[tokio::test(flavor = "multi_thread")]
1720    async fn remote_capabilities_degraded_on_keyword_tier_does_not_warn() {
1721        // recall_mode=degraded but feature_tier=keyword → no silent-degrade
1722        // (keyword tier was never expected to run hybrid in the first place).
1723        use wiremock::matchers::{method, path};
1724        use wiremock::{Mock, MockServer, ResponseTemplate};
1725        let server = MockServer::start().await;
1726        Mock::given(method("GET"))
1727            .and(path("/api/v1/capabilities"))
1728            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1729                "schema_version": "2",
1730                "feature_tier": "keyword",
1731                "features": {
1732                    "recall_mode_active": "keyword_only",
1733                    "reranker_active": "none"
1734                }
1735            })))
1736            .mount(&server)
1737            .await;
1738        let env = TestEnv::fresh();
1739        let report = run_remote_in_blocking(server.uri(), env.db_path.clone()).await;
1740        let cap = find(&report, "Capabilities");
1741        assert_eq!(cap.severity, Severity::Info);
1742        assert!(cap.note.is_none());
1743    }
1744
1745    #[tokio::test(flavor = "multi_thread")]
1746    async fn remote_capabilities_unreachable_endpoint_is_critical() {
1747        // Reserve a free port and immediately drop the listener so the
1748        // connection refusal is deterministic. Doctor's HTTP timeout is
1749        // 5s; the kernel rejects almost immediately so the test stays
1750        // well under the per-test timeout.
1751        use std::net::TcpListener;
1752        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1753        let port = listener.local_addr().unwrap().port();
1754        drop(listener);
1755        let url = format!("http://127.0.0.1:{port}");
1756
1757        let env = TestEnv::fresh();
1758        let report = run_remote_in_blocking(url, env.db_path.clone()).await;
1759        let cap = find(&report, "Capabilities");
1760        assert_eq!(cap.severity, Severity::Critical);
1761        assert!(cap.note.as_ref().unwrap().contains("could not reach"));
1762        assert_eq!(report.overall, Severity::Critical);
1763    }
1764
1765    #[tokio::test(flavor = "multi_thread")]
1766    async fn remote_capabilities_legacy_v1_renders_not_in_response() {
1767        // Legacy v0.6.3 capabilities responses don't carry the v2 fields.
1768        use wiremock::matchers::{method, path};
1769        use wiremock::{Mock, MockServer, ResponseTemplate};
1770        let server = MockServer::start().await;
1771        Mock::given(method("GET"))
1772            .and(path("/api/v1/capabilities"))
1773            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1774                "schema_version": "1"
1775            })))
1776            .mount(&server)
1777            .await;
1778        let env = TestEnv::fresh();
1779        let report = run_remote_in_blocking(server.uri(), env.db_path.clone()).await;
1780        let cap = find(&report, "Capabilities");
1781        // Legacy v1 → no severity bump, but missing fields are rendered.
1782        assert_eq!(cap.severity, Severity::Info);
1783        assert_eq!(fact(cap, "schema_version"), "1");
1784        assert_eq!(fact(cap, "recall_mode_active"), "not_in_response");
1785        assert_eq!(fact(cap, "reranker_active"), "not_in_response");
1786    }
1787
1788    #[tokio::test(flavor = "multi_thread")]
1789    async fn remote_run_via_run_entry_uses_remote_mode_string() {
1790        use wiremock::matchers::{method, path};
1791        use wiremock::{Mock, MockServer, ResponseTemplate};
1792        let server = MockServer::start().await;
1793        Mock::given(method("GET"))
1794            .and(path("/api/v1/capabilities"))
1795            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1796                "schema_version": "2",
1797                "feature_tier": "semantic",
1798                "features": {
1799                    "recall_mode_active": "hybrid",
1800                    "reranker_active": "none"
1801                }
1802            })))
1803            .mount(&server)
1804            .await;
1805        Mock::given(method("GET"))
1806            .and(path("/api/v1/stats"))
1807            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1808                "total": 0
1809            })))
1810            .mount(&server)
1811            .await;
1812
1813        let env_db = TestEnv::fresh().db_path;
1814        let url = server.uri();
1815        let (exit, stdout) = tokio::task::spawn_blocking(move || {
1816            let mut stdout = Vec::<u8>::new();
1817            let mut stderr = Vec::<u8>::new();
1818            let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1819            let exit = run(
1820                &env_db,
1821                &DoctorArgs {
1822                    remote: Some(url),
1823                    json: true,
1824                    fail_on_warn: false,
1825                },
1826                &mut out,
1827            )
1828            .unwrap();
1829            (exit, stdout)
1830        })
1831        .await
1832        .unwrap();
1833        assert_eq!(exit, 0);
1834        let v: serde_json::Value = serde_json::from_slice(&stdout).unwrap();
1835        assert_eq!(v["mode"], "remote");
1836        // Trailing slash on the URL must be normalized.
1837    }
1838
1839    #[tokio::test(flavor = "multi_thread")]
1840    async fn remote_url_trailing_slash_is_trimmed() {
1841        use wiremock::matchers::{method, path};
1842        use wiremock::{Mock, MockServer, ResponseTemplate};
1843        let server = MockServer::start().await;
1844        Mock::given(method("GET"))
1845            .and(path("/api/v1/capabilities"))
1846            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1847                "schema_version": "2",
1848                "features": {}
1849            })))
1850            .mount(&server)
1851            .await;
1852        Mock::given(method("GET"))
1853            .and(path("/api/v1/stats"))
1854            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1855            .mount(&server)
1856            .await;
1857        let env = TestEnv::fresh();
1858        // Append a trailing slash; format!("{base}/api/v1/...") would
1859        // otherwise produce a `//api/v1/` path that wiremock would 404.
1860        let report =
1861            run_remote_in_blocking(format!("{}/", server.uri()), env.db_path.clone()).await;
1862        let cap = find(&report, "Capabilities");
1863        assert_eq!(cap.severity, Severity::Info);
1864    }
1865
1866    #[tokio::test(flavor = "multi_thread")]
1867    async fn remote_storage_500_renders_error_without_severity_bump() {
1868        use wiremock::matchers::{method, path};
1869        use wiremock::{Mock, MockServer, ResponseTemplate};
1870        let server = MockServer::start().await;
1871        Mock::given(method("GET"))
1872            .and(path("/api/v1/capabilities"))
1873            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1874                "schema_version": "2",
1875                "features": {}
1876            })))
1877            .mount(&server)
1878            .await;
1879        Mock::given(method("GET"))
1880            .and(path("/api/v1/stats"))
1881            .respond_with(ResponseTemplate::new(500))
1882            .mount(&server)
1883            .await;
1884        let env = TestEnv::fresh();
1885        let report = run_remote_in_blocking(server.uri(), env.db_path.clone()).await;
1886        let storage = find(&report, "Storage");
1887        // Storage section preserves Info severity even on 5xx — by spec
1888        // (remote storage is best-effort; sql truth is the local mode).
1889        assert_eq!(storage.severity, Severity::Info);
1890        let err = fact(storage, "error");
1891        assert!(
1892            err.contains("HTTP 500"),
1893            "expected HTTP 500 message, got {err}"
1894        );
1895    }
1896
1897    // ---- v0.6.4-004 — `--tokens` reporter ----
1898
1899    fn run_tokens_capture(args: TokensArgs) -> (i32, String, String) {
1900        let mut stdout = Vec::<u8>::new();
1901        let mut stderr = Vec::<u8>::new();
1902        let exit;
1903        {
1904            let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1905            exit = run_tokens(args, &mut out).expect("run_tokens");
1906        }
1907        (
1908            exit,
1909            String::from_utf8(stdout).unwrap(),
1910            String::from_utf8(stderr).unwrap(),
1911        )
1912    }
1913
1914    #[test]
1915    fn run_tokens_human_default_profile_is_core() {
1916        let (exit, stdout, _stderr) = run_tokens_capture(TokensArgs::default());
1917        assert_eq!(exit, 0);
1918        assert!(
1919            stdout.contains("Active profile: core"),
1920            "default profile should be core; got: {stdout}"
1921        );
1922        assert!(
1923            stdout.contains("Full   (43 tools loaded)"),
1924            "report should include full-profile baseline"
1925        );
1926        assert!(
1927            stdout.contains("Tokenizer: cl100k_base"),
1928            "report should call out the tokenizer"
1929        );
1930    }
1931
1932    #[test]
1933    fn run_tokens_json_emits_structured_payload() {
1934        let args = TokensArgs {
1935            json: true,
1936            raw_table: false,
1937            profile: Some("graph".to_string()),
1938        };
1939        let (exit, stdout, _) = run_tokens_capture(args);
1940        assert_eq!(exit, 0);
1941        let v: serde_json::Value =
1942            serde_json::from_str(&stdout).expect("--json must emit valid JSON");
1943        assert_eq!(v["schema_version"], "v0.6.4-tokens-1");
1944        assert_eq!(v["tokenizer"], "cl100k_base");
1945        // Token count grows as schemas evolve. Assert the honest
1946        // cl100k_base range from sizes.rs (5K-8K) rather than an
1947        // exact value; the exact-figure invariant lives in
1948        // `sizes::tests::full_profile_total_in_honest_measured_range`.
1949        let total = v["full_profile_total_tokens"].as_u64().unwrap();
1950        assert!(
1951            (5_000..=8_000).contains(&total),
1952            "full_profile_total_tokens out of honest range: {total}"
1953        );
1954        assert!(v["active_total_tokens"].as_u64().unwrap() > 0);
1955        // graph profile loads core + graph; both flags true on those rows.
1956        let families = v["families"].as_array().unwrap();
1957        let core_row = families.iter().find(|r| r["name"] == "core").unwrap();
1958        assert_eq!(core_row["loaded"], true);
1959        let graph_row = families.iter().find(|r| r["name"] == "graph").unwrap();
1960        assert_eq!(graph_row["loaded"], true);
1961        let archive_row = families.iter().find(|r| r["name"] == "archive").unwrap();
1962        assert_eq!(archive_row["loaded"], false);
1963    }
1964
1965    #[test]
1966    fn run_tokens_raw_table_includes_per_tool_rows() {
1967        let args = TokensArgs {
1968            json: false,
1969            raw_table: true,
1970            profile: None,
1971        };
1972        let (exit, stdout, _) = run_tokens_capture(args);
1973        assert_eq!(exit, 0);
1974        let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
1975        let tools = v["tools"].as_array().unwrap();
1976        assert_eq!(
1977            tools.len(),
1978            43,
1979            "raw_table must include all 43 baseline tools"
1980        );
1981        // memory_store is in core and must be loaded under the default
1982        // (core) profile.
1983        let store = tools
1984            .iter()
1985            .find(|t| t["name"] == "memory_store")
1986            .expect("memory_store row");
1987        assert_eq!(store["family"], "core");
1988        assert_eq!(store["loaded_under_active_profile"], true);
1989    }
1990
1991    #[test]
1992    fn run_tokens_invalid_profile_exits_2_with_diagnostic() {
1993        let args = TokensArgs {
1994            json: false,
1995            raw_table: false,
1996            profile: Some("Core".to_string()),
1997        };
1998        let (exit, _stdout, stderr) = run_tokens_capture(args);
1999        assert_eq!(exit, 2, "malformed profile must exit 2");
2000        assert!(
2001            stderr.contains("case-sensitive lowercase"),
2002            "diagnostic should mention case rule; got: {stderr}"
2003        );
2004    }
2005}