1use std::env;
2use std::path::{Path, PathBuf};
3
4use rusqlite::{Connection, OpenFlags};
5use serde::Serialize;
6
7use crate::core::db::CURRENT_SCHEMA_VERSION;
8
9pub const REQUIRED_MCP_TOOLS: &[&str] = &[
10 "mempal_context",
11 "mempal_brief",
12 "mempal_phase3",
13 "mempal_session_peek",
14 "mempal_cowork_bus",
15];
16
17pub const PHASE3_ACTIONS: &[&str] = &[
18 "guidance",
19 "instrumentation_policy",
20 "prepare_record",
21 "capture",
22 "evaluator_advise",
23 "default_proposal",
24 "rollback_control",
25 "check_record",
26 "record_checked",
27 "review",
28 "readiness",
29 "analytics",
30 "record",
31 "list",
32 "stats",
33 "gate",
34 "research_validate_plan",
35 "research_ingest_plan",
36];
37
38pub const COWORK_BUS_ACTIONS: &[&str] = &[
39 "register",
40 "list",
41 "send",
42 "broadcast",
43 "drain",
44 "events",
45 "deliveries",
46 "ack",
47 "heartbeat",
48 "channel_set",
49 "channel_list",
50 "channel_send",
51 "tmux_peek",
52 "doctor",
53 "session_create",
54 "session_list",
55 "session_status",
56 "session_close",
57 "handoff",
58 "capture",
59];
60
61#[derive(Debug, Clone, Serialize)]
62pub struct DoctorReport {
63 pub current_version: String,
64 pub supported_schema_version: u32,
65 pub db: DoctorDbReport,
66 pub install: DoctorInstallReport,
67 pub warnings: Vec<String>,
68 pub recommendations: Vec<String>,
69}
70
71#[derive(Debug, Clone, Serialize)]
72pub struct DoctorDbReport {
73 pub path: String,
74 pub exists: bool,
75 pub schema_version: Option<u32>,
76 pub compatible: bool,
77 pub error: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize)]
81pub struct DoctorInstallReport {
82 pub current_exe: Option<String>,
83 pub path_mempal: Option<String>,
84 pub path_matches_current_exe: Option<bool>,
85}
86
87pub fn build_doctor_report(db_path: &Path) -> DoctorReport {
88 let db = inspect_db(db_path);
89 let install = inspect_install();
90 let mut warnings = Vec::new();
91 let mut recommendations = vec![
92 "Run `mempal doctor --format json` after installing or upgrading mempal.".to_string(),
93 "If PATH points at an older binary, restart the MCP client after updating PATH."
94 .to_string(),
95 ];
96
97 if db.exists && !db.compatible {
98 warnings.push(format!(
99 "database schema is not compatible with this binary: found {:?}, supported {}",
100 db.schema_version, CURRENT_SCHEMA_VERSION
101 ));
102 recommendations
103 .push("Install a mempal binary that supports this palace.db schema.".to_string());
104 }
105 if let Some(error) = db.error.as_deref() {
106 warnings.push(format!("database schema could not be inspected: {error}"));
107 }
108 if install.path_matches_current_exe == Some(false) {
109 warnings
110 .push("PATH resolves mempal to a different executable than this process".to_string());
111 recommendations
112 .push("Check `which mempal` and restart long-lived MCP clients.".to_string());
113 }
114 if install.path_mempal.is_none() {
115 warnings.push("PATH does not contain a mempal executable".to_string());
116 }
117
118 DoctorReport {
119 current_version: env!("CARGO_PKG_VERSION").to_string(),
120 supported_schema_version: CURRENT_SCHEMA_VERSION,
121 db,
122 install,
123 warnings,
124 recommendations,
125 }
126}
127
128fn inspect_db(db_path: &Path) -> DoctorDbReport {
129 if !db_path.exists() {
130 return DoctorDbReport {
131 path: db_path.display().to_string(),
132 exists: false,
133 schema_version: None,
134 compatible: true,
135 error: None,
136 };
137 }
138
139 match read_schema_version_read_only(db_path) {
140 Ok(schema_version) => DoctorDbReport {
141 path: db_path.display().to_string(),
142 exists: true,
143 schema_version: Some(schema_version),
144 compatible: schema_version <= CURRENT_SCHEMA_VERSION,
145 error: None,
146 },
147 Err(error) => DoctorDbReport {
148 path: db_path.display().to_string(),
149 exists: true,
150 schema_version: None,
151 compatible: false,
152 error: Some(error),
153 },
154 }
155}
156
157fn read_schema_version_read_only(db_path: &Path) -> Result<u32, String> {
158 let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
159 .map_err(|error| error.to_string())?;
160 conn.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
161 .map_err(|error| error.to_string())
162}
163
164fn inspect_install() -> DoctorInstallReport {
165 let current_exe = env::current_exe().ok();
166 let path_mempal = find_path_executable("mempal");
167 let path_matches_current_exe = match (current_exe.as_ref(), path_mempal.as_ref()) {
168 (Some(current), Some(path_mempal)) => Some(paths_match(current, path_mempal)),
169 (_, Some(_)) => None,
170 _ => None,
171 };
172
173 DoctorInstallReport {
174 current_exe: current_exe.map(|path| path.display().to_string()),
175 path_mempal: path_mempal.map(|path| path.display().to_string()),
176 path_matches_current_exe,
177 }
178}
179
180fn find_path_executable(name: &str) -> Option<PathBuf> {
181 let path = env::var_os("PATH")?;
182 env::split_paths(&path)
183 .map(|dir| dir.join(name))
184 .find(|candidate| candidate.is_file())
185}
186
187fn paths_match(left: &Path, right: &Path) -> bool {
188 let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
189 let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
190 left == right
191}