1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Severity {
60 Info,
61 Warning,
62 Critical,
63 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#[derive(Debug, Serialize)]
83pub struct ReportSection {
84 pub name: String,
85 pub severity: Severity,
86 pub facts: Vec<(String, String)>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub note: Option<String>,
90}
91
92#[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 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
123pub struct DoctorArgs {
126 pub remote: Option<String>,
127 pub json: bool,
128 pub fail_on_warn: bool,
129}
130
131#[derive(Debug, Default)]
134pub struct TokensArgs {
135 pub json: bool,
137 pub raw_table: bool,
139 pub profile: Option<String>,
142}
143
144pub fn run_tokens(args: TokensArgs, out: &mut CliOutput<'_>) -> Result<i32> {
154 use crate::profile::{Family, Profile};
155 use crate::sizes;
156
157 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 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 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 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 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
291pub 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
322fn run_local(db_path: &Path) -> Report {
327 let mut sections = Vec::with_capacity(7);
328
329 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 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 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 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 facts.push((
453 "index_evictions_total".into(),
454 "not_observed (pre-P3 surface)".into(),
455 ));
456
457 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 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 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 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
660fn 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
720fn 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 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 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 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
860fn 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 §ion.facts {
878 writeln!(out.stdout, " {k:<32} {v}")?;
879 }
880 if let Some(note) = §ion.note {
881 writeln!(out.stdout, " note: {note}")?;
882 }
883 writeln!(out.stdout)?;
884 }
885 Ok(())
886}
887
888#[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 #[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 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 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 #[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(§ion).unwrap();
1010 assert_eq!(v["name"], "Storage");
1011 assert_eq!(v["severity"], "warning");
1012 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(§ion).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 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 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 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 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 assert_eq!(fact(index, "hnsw_size_estimate"), "0");
1148 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 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 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 #[test]
1219 fn governance_section_critical_when_pending_older_than_24h() {
1220 let env = TestEnv::fresh();
1221 {
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 assert_eq!(fact(gov, "pending_actions_total"), "1");
1240 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 assert_eq!(gov.severity, Severity::Info);
1262 assert_eq!(fact(gov, "pending_actions_total"), "1");
1263 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 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 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 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 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 #[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 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 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 assert!(s.contains("total_memories"));
1449 }
1450
1451 #[test]
1452 fn run_returns_exit_2_on_critical() {
1453 let mut env = TestEnv::fresh();
1454 {
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 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 #[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 std::fs::write(&bad, b"this is not a sqlite database, it's just text").unwrap();
1584 let report = run_local_collect(&bad);
1585 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 assert_eq!(report.overall, Severity::Critical);
1592 assert!(storage.note.as_ref().unwrap().contains("could not open"));
1593 }
1594
1595 #[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 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 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 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 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 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 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 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 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 }
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 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 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 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 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 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 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}