use std::fs;
use std::path::Path;
use std::process::Command;
use super::util::check_line;
use super::{CheckLevel, CheckLine};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ShimHealthReport {
pub checks: Vec<CheckLine>,
}
pub(super) fn build_shim_checks(project_root: &Path, fix: bool) -> ShimHealthReport {
let mut checks = vec![
check_pty_creation(),
check_socketpair(),
check_vt100_sanity(),
check_shim_log_dir(project_root, fix),
check_shim_binary(),
];
checks.extend(check_agent_classifiers());
ShimHealthReport { checks }
}
fn check_pty_creation() -> CheckLine {
use portable_pty::{PtySize, native_pty_system};
let pty_system = native_pty_system();
match pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
}) {
Ok(_pair) => check_line(
CheckLevel::Pass,
"PTY creation: portable-pty opened successfully",
),
Err(error) => check_line(
CheckLevel::Fail,
format!("PTY creation: failed to open PTY pair: {error}"),
),
}
}
fn check_socketpair() -> CheckLine {
match std::os::unix::net::UnixStream::pair() {
Ok(_pair) => check_line(
CheckLevel::Pass,
"socketpair: SOCK_STREAM Unix socketpair created successfully",
),
Err(error) => check_line(
CheckLevel::Fail,
format!("socketpair: failed to create Unix socketpair: {error}"),
),
}
}
fn check_vt100_sanity() -> CheckLine {
let parser = vt100::Parser::new(50, 220, 0);
let screen = parser.screen();
let size = screen.size();
if size == (50, 220) {
check_line(
CheckLevel::Pass,
"vt100 parser: initialized correctly (50x220)",
)
} else {
check_line(
CheckLevel::Fail,
format!(
"vt100 parser: unexpected dimensions {:?}, expected (50, 220)",
size
),
)
}
}
fn check_shim_log_dir(project_root: &Path, fix: bool) -> CheckLine {
let shim_logs = project_root.join(".batty").join("shim-logs");
if shim_logs.is_dir() {
let probe = shim_logs.join(".batty-doctor-probe");
match fs::write(&probe, b"probe") {
Ok(()) => {
let _ = fs::remove_file(&probe);
check_line(
CheckLevel::Pass,
"shim-logs directory: exists and is writable",
)
}
Err(error) => check_line(
CheckLevel::Fail,
format!("shim-logs directory: exists but not writable: {error}"),
),
}
} else if fix {
match fs::create_dir_all(&shim_logs) {
Ok(()) => check_line(CheckLevel::Pass, "shim-logs directory: created by --fix"),
Err(error) => check_line(
CheckLevel::Fail,
format!("shim-logs directory: --fix failed to create: {error}"),
),
}
} else {
check_line(
CheckLevel::Warn,
"shim-logs directory: missing (run with --fix to create)",
)
}
}
fn check_shim_binary() -> CheckLine {
let binary = std::env::current_exe().unwrap_or_else(|_| "batty".into());
match Command::new(&binary).args(["shim", "--help"]).output() {
Ok(output) if output.status.success() => check_line(
CheckLevel::Pass,
"shim binary: `batty shim --help` executed successfully",
),
Ok(output) => check_line(
CheckLevel::Fail,
format!(
"shim binary: `batty shim --help` exited with {}",
output.status
),
),
Err(error) => check_line(
CheckLevel::Fail,
format!("shim binary: failed to execute `batty shim --help`: {error}"),
),
}
}
fn check_agent_classifiers() -> Vec<CheckLine> {
use crate::shim::classifier::{AgentType, ScreenVerdict, classify};
let test_cases: &[(AgentType, &str, ScreenVerdict)] = &[
(
AgentType::Claude,
"Some output\n\u{276F}\n bypass permissions on (shift+tab to cycle)",
ScreenVerdict::AgentIdle,
),
(
AgentType::Codex,
"Done.\n\n\u{203A} ",
ScreenVerdict::AgentIdle,
),
(AgentType::Kiro, "Result\nKiro> ", ScreenVerdict::AgentIdle),
(
AgentType::Generic,
"user@host:~$ ",
ScreenVerdict::AgentIdle,
),
];
test_cases
.iter()
.map(|(agent_type, content, expected)| {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(content.as_bytes());
let verdict = classify(*agent_type, parser.screen());
if verdict == *expected {
check_line(
CheckLevel::Pass,
format!(
"classifier {agent_type}: recognized known prompt pattern as {expected:?}"
),
)
} else {
check_line(
CheckLevel::Fail,
format!("classifier {agent_type}: expected {expected:?} but got {verdict:?}"),
)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_pty_creation_passes() {
let result = check_pty_creation();
assert_eq!(result.level, CheckLevel::Pass);
assert!(result.message.contains("PTY creation"));
}
#[test]
fn check_socketpair_passes() {
let result = check_socketpair();
assert_eq!(result.level, CheckLevel::Pass);
assert!(result.message.contains("socketpair"));
}
#[test]
fn check_vt100_sanity_passes() {
let result = check_vt100_sanity();
assert_eq!(result.level, CheckLevel::Pass);
assert!(result.message.contains("vt100 parser"));
}
#[test]
fn check_shim_log_dir_warns_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = check_shim_log_dir(tmp.path(), false);
assert_eq!(result.level, CheckLevel::Warn);
assert!(result.message.contains("missing"));
assert!(result.message.contains("--fix"));
}
#[test]
fn check_shim_log_dir_passes_when_exists() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".batty").join("shim-logs")).unwrap();
let result = check_shim_log_dir(tmp.path(), false);
assert_eq!(result.level, CheckLevel::Pass);
assert!(result.message.contains("writable"));
}
#[test]
fn check_shim_log_dir_fix_creates_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = check_shim_log_dir(tmp.path(), true);
assert_eq!(result.level, CheckLevel::Pass);
assert!(result.message.contains("created by --fix"));
assert!(tmp.path().join(".batty").join("shim-logs").is_dir());
}
#[test]
fn check_agent_classifiers_all_pass() {
let results = check_agent_classifiers();
assert_eq!(results.len(), 4);
for result in &results {
assert_eq!(result.level, CheckLevel::Pass, "failed: {}", result.message);
}
}
#[test]
fn build_shim_checks_returns_all_checks() {
let tmp = tempfile::tempdir().unwrap();
let report = build_shim_checks(tmp.path(), false);
assert!(
report.checks.len() >= 8,
"expected at least 8 checks, got {}",
report.checks.len()
);
}
#[test]
fn build_shim_checks_with_fix_creates_dirs() {
let tmp = tempfile::tempdir().unwrap();
let report = build_shim_checks(tmp.path(), true);
assert!(tmp.path().join(".batty").join("shim-logs").is_dir());
let log_check = report
.checks
.iter()
.find(|c| c.message.contains("shim-logs"))
.unwrap();
assert_eq!(log_check.level, CheckLevel::Pass);
}
}