Skip to main content

meerkat_mobkit/
baseline.rs

1//! Baseline runtime configuration and module bootstrapping.
2
3use 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}