1use std::ffi::OsStr;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7pub const MEERKAT_REPO_ENV: &str = "MEERKAT_REPO";
8pub const REQUIRED_MEERKAT_SYMBOLS: &[&str] = &[
9 "MobEventRouter",
10 "send_message(id, msg)",
11 "subscribe_agent_events(id)",
12 "subscribe_all_agent_events()",
13 "SpawnPolicy trait",
14 "respawn(id, msg)",
15 "AttributedEvent",
16 "Roster::session_id(id)",
17 "Roster::find_by_label(k, v)",
18 "SessionBuildOptions.app_context",
19 "SessionBuildOptions.additional_instructions",
20 "CreateSessionRequest.labels",
21 "RosterEntry.labels",
22 "SpawnMemberSpec.resume_session_id",
23];
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct BaselineVerificationReport {
27 pub repo_root: PathBuf,
28 pub missing_symbols: Vec<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum BaselineVerificationError {
33 RepoNotConfigured { env_var: &'static str },
34 RepoMissing(PathBuf),
35 RepoUnreadable(PathBuf),
36 MissingSymbols(BaselineVerificationReport),
37}
38
39impl std::fmt::Display for BaselineVerificationError {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 Self::RepoNotConfigured { env_var } => write!(
43 f,
44 "Meerkat repository is not configured; pass an explicit repository path or set {env_var}"
45 ),
46 Self::RepoMissing(path) => write!(f, "repo missing: {}", path.display()),
47 Self::RepoUnreadable(path) => write!(f, "repo unreadable: {}", path.display()),
48 Self::MissingSymbols(report) => {
49 write!(
50 f,
51 "missing symbols in {}: {}",
52 report.repo_root.display(),
53 report.missing_symbols.join(", ")
54 )
55 }
56 }
57 }
58}
59
60impl std::error::Error for BaselineVerificationError {}
61
62pub fn verify_meerkat_baseline_symbols(
63 explicit_repo_root: Option<&Path>,
64) -> Result<BaselineVerificationReport, BaselineVerificationError> {
65 let configured_repo_root = std::env::var_os(MEERKAT_REPO_ENV);
66 let repo_root = resolve_meerkat_repo_root(explicit_repo_root, configured_repo_root.as_deref())?;
67
68 if !repo_root.exists() {
69 return Err(BaselineVerificationError::RepoMissing(repo_root));
70 }
71 if !repo_root.is_dir() {
72 return Err(BaselineVerificationError::RepoUnreadable(repo_root));
73 }
74
75 let mut missing: Vec<String> = REQUIRED_MEERKAT_SYMBOLS
76 .iter()
77 .map(std::string::ToString::to_string)
78 .collect();
79
80 scan_dir_for_symbols(&repo_root, &mut missing)
81 .map_err(|_| BaselineVerificationError::RepoUnreadable(repo_root.clone()))?;
82
83 let report = BaselineVerificationReport {
84 repo_root,
85 missing_symbols: missing,
86 };
87
88 if report.missing_symbols.is_empty() {
89 Ok(report)
90 } else {
91 Err(BaselineVerificationError::MissingSymbols(report))
92 }
93}
94
95fn resolve_meerkat_repo_root(
96 explicit_repo_root: Option<&Path>,
97 configured_repo_root: Option<&OsStr>,
98) -> Result<PathBuf, BaselineVerificationError> {
99 let configured_path = match explicit_repo_root {
100 Some(path) if !path.as_os_str().is_empty() => path,
101 Some(_) => {
102 return Err(BaselineVerificationError::RepoNotConfigured {
103 env_var: MEERKAT_REPO_ENV,
104 });
105 }
106 None => configured_repo_root
107 .map(Path::new)
108 .filter(|path| !path.as_os_str().is_empty())
109 .ok_or(BaselineVerificationError::RepoNotConfigured {
110 env_var: MEERKAT_REPO_ENV,
111 })?,
112 };
113
114 Ok(configured_path.to_path_buf())
115}
116
117fn scan_dir_for_symbols(path: &Path, missing: &mut Vec<String>) -> std::io::Result<()> {
118 if missing.is_empty() {
119 return Ok(());
120 }
121
122 for entry in fs::read_dir(path)? {
123 let entry = entry?;
124 let entry_path = entry.path();
125 let file_type = entry.file_type()?;
126 if file_type.is_dir() {
127 if should_skip_dir(&entry_path) {
128 continue;
129 }
130 scan_dir_for_symbols(&entry_path, missing)?;
131 continue;
132 }
133 if !file_type.is_file() {
134 continue;
135 }
136 if should_skip_file(&entry_path) {
137 continue;
138 }
139
140 let content = match fs::read_to_string(&entry_path) {
141 Ok(content) => content,
142 Err(_) => continue,
143 };
144
145 missing.retain(|symbol| !contains_symbol(&content, symbol));
146 if missing.is_empty() {
147 return Ok(());
148 }
149 }
150
151 Ok(())
152}
153
154fn should_skip_dir(path: &Path) -> bool {
155 matches!(
156 path.file_name().and_then(|name| name.to_str()),
157 Some(".git" | "target" | "node_modules" | ".next" | ".turbo")
158 )
159}
160
161fn should_skip_file(path: &Path) -> bool {
162 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
163 return false;
164 };
165 matches!(
166 extension,
167 "png" | "jpg" | "jpeg" | "gif" | "pdf" | "wasm" | "lock"
168 )
169}
170
171fn contains_symbol(content: &str, symbol: &str) -> bool {
172 if content.contains(symbol) {
173 return true;
174 }
175
176 match symbol {
177 "MobEventRouter" => content.contains("MobEventRouter"),
178 "subscribe_agent_events(id)" => content.contains("subscribe_agent_events"),
179 "subscribe_all_agent_events()" => content.contains("subscribe_all_agent_events"),
180 "SpawnPolicy trait" => content.contains("trait SpawnPolicy"),
181 "SessionBuildOptions.app_context" => {
182 content.contains("SessionBuildOptions")
183 && (content.contains("app_context") || content.contains(".app_context"))
184 }
185 "SessionBuildOptions.additional_instructions" => {
186 content.contains("SessionBuildOptions")
187 && (content.contains("additional_instructions")
188 || content.contains(".additional_instructions"))
189 }
190 "CreateSessionRequest.labels" => {
191 content.contains("CreateSessionRequest")
192 && (content.contains("labels") || content.contains(".labels"))
193 }
194 "RosterEntry.labels" => {
195 content.contains("RosterEntry")
196 && (content.contains("labels") || content.contains(".labels"))
197 }
198 "SpawnMemberSpec.resume_session_id" => {
199 content.contains("SpawnMemberSpec")
200 && (content.contains("resume_session_id") || content.contains(".resume_session_id"))
201 }
202 "Roster::session_id(id)" => {
203 content.contains("Roster::session_id")
204 || content.contains("fn session_id")
205 || content.contains(".session_id(")
206 }
207 "Roster::find_by_label(k, v)" => {
208 content.contains("Roster::find_by_label")
209 || content.contains("fn find_by_label")
210 || content.contains(".find_by_label(")
211 }
212 "send_message(id, msg)" => content.contains("send_message"),
213 "respawn(id, msg)" => content.contains("respawn("),
214 _ => false,
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn repo_root_requires_explicit_path_or_environment_configuration() {
224 assert_eq!(
225 resolve_meerkat_repo_root(None, None),
226 Err(BaselineVerificationError::RepoNotConfigured {
227 env_var: MEERKAT_REPO_ENV,
228 })
229 );
230 assert_eq!(
231 resolve_meerkat_repo_root(Some(Path::new("")), Some(OsStr::new("/configured"))),
232 Err(BaselineVerificationError::RepoNotConfigured {
233 env_var: MEERKAT_REPO_ENV,
234 })
235 );
236 }
237
238 #[test]
239 fn repo_root_prefers_an_explicit_path_and_otherwise_uses_environment_configuration() {
240 assert_eq!(
241 resolve_meerkat_repo_root(
242 Some(Path::new("/explicit")),
243 Some(OsStr::new("/configured"))
244 ),
245 Ok(PathBuf::from("/explicit"))
246 );
247 assert_eq!(
248 resolve_meerkat_repo_root(None, Some(OsStr::new("/configured"))),
249 Ok(PathBuf::from("/configured"))
250 );
251 }
252
253 #[test]
254 fn explicit_repo_root_is_verified_without_a_machine_specific_default()
255 -> Result<(), Box<dyn std::error::Error>> {
256 let repo = tempfile::tempdir()?;
257 fs::write(
258 repo.path().join("baseline-symbols.rs"),
259 REQUIRED_MEERKAT_SYMBOLS.join("\n"),
260 )?;
261
262 let report = verify_meerkat_baseline_symbols(Some(repo.path()))?;
263 assert_eq!(report.repo_root, repo.path());
264 assert!(report.missing_symbols.is_empty());
265 Ok(())
266 }
267}