use std::path::Path;
use serde_yaml::Value as YamlValue;
use crate::agent_def::{AgentDef, AgentRegistry};
use crate::config::Config;
use crate::encrypted_store::EncryptedStore;
use crate::medic;
use crate::project_def::{ProjectDef, ProjectRegistry, TaskBoard};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone)]
pub struct Check {
pub category: String,
pub item: String,
pub status: CheckStatus,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct DoctorReport {
pub checks: Vec<Check>,
}
impl DoctorReport {
pub fn has_failures(&self) -> bool {
self.checks.iter().any(|c| c.status == CheckStatus::Fail)
}
pub fn has_warnings(&self) -> bool {
self.checks.iter().any(|c| c.status == CheckStatus::Warn)
}
fn add(&mut self, category: &str, item: &str, status: CheckStatus, message: &str) {
self.checks.push(Check {
category: category.to_string(),
item: item.to_string(),
status,
message: message.to_string(),
});
}
}
pub fn run_checks(home: &Path) -> DoctorReport {
let mut report = DoctorReport::default();
check_home_dirs(home, &mut report);
check_global_config(home, &mut report);
check_server_config(home, &mut report);
check_providers_yaml(home, &mut report);
check_channels_yaml(home, &mut report);
check_tools_yaml(home, &mut report);
check_cron_yaml(home, &mut report);
check_mcp_yaml(home, &mut report);
check_a2a_yaml(home, &mut report);
check_skills_yaml(home, &mut report);
check_context_yaml(home, &mut report);
check_memory_yaml(home, &mut report);
check_hooks_yaml(home, &mut report);
check_commands_dir(home, &mut report);
check_plugins_dir(home, &mut report);
check_enact_md(home, &mut report);
check_agents(home, &mut report);
check_projects(home, &mut report);
check_taskboards(home, &mut report);
check_providers(home, &mut report);
check_secrets(home, &mut report);
check_state(home, &mut report);
check_boundaries(home, &mut report);
report
}
fn check_nested_channel_configs(
user_val: &YamlValue,
allowed_keys: &std::collections::HashSet<String>,
path_str: &str,
report: &mut DoctorReport,
) {
let channels = ["telegram", "whatsapp", "teams"];
for channel in &channels {
if let Some(channel_config) = user_val.get(channel) {
if let Some(nested_map) = channel_config.as_mapping() {
for (key, _) in nested_map {
if let Some(key_str) = key.as_str() {
let full_path = format!("{}.{}", channel, key_str);
if !allowed_keys.contains(&full_path) {
report.add(
"schema_boundary",
path_str,
CheckStatus::Warn,
&format!(
"extra key in {} config (not in reference): {}",
channel, key_str
),
);
}
}
}
}
}
}
}
fn validate_agent_channel_configs(agent_def: &AgentDef, path_str: &str, report: &mut DoctorReport) {
if let Some(ref telegram) = agent_def.telegram {
if let Some(ref token_env) = telegram.bot_token {
if token_env.is_empty() {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
"telegram.bot_token is empty",
);
} else if !is_valid_env_var_name(token_env) {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
&format!(
"telegram.bot_token '{}' is not a valid environment variable name",
token_env
),
);
}
}
if let Some(ref bot_name) = telegram.bot_name {
if bot_name.is_empty() {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
"telegram.bot_name is empty",
);
}
}
}
if let Some(ref whatsapp) = agent_def.whatsapp {
if let Some(ref token_env) = whatsapp.bot_token {
if token_env.is_empty() {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
"whatsapp.bot_token is empty",
);
} else if !is_valid_env_var_name(token_env) {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
&format!(
"whatsapp.bot_token '{}' is not a valid environment variable name",
token_env
),
);
}
}
if let Some(ref bot_name) = whatsapp.bot_name {
if bot_name.is_empty() {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
"whatsapp.bot_name is empty",
);
}
}
}
if let Some(ref teams) = agent_def.teams {
if let Some(ref token_env) = teams.bot_token {
if token_env.is_empty() {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
"teams.bot_token is empty",
);
} else if !is_valid_env_var_name(token_env) {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
&format!(
"teams.bot_token '{}' is not a valid environment variable name",
token_env
),
);
}
}
if let Some(ref bot_name) = teams.bot_name {
if bot_name.is_empty() {
report.add(
"agent_config",
path_str,
CheckStatus::Warn,
"teams.bot_name is empty",
);
}
}
}
}
fn is_valid_env_var_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let first = name.chars().next().unwrap();
if !first.is_alphabetic() && first != '_' {
return false;
}
name.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn check_boundaries(home: &Path, report: &mut DoctorReport) {
for filename in medic::REFERENCE_FILES {
if *filename == "agent.yaml" || *filename == "workflow.yaml" {
continue;
}
let path = home.join(filename);
if !path.exists() {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let user_val: YamlValue = match serde_yaml::from_str(&content) {
Ok(v) => v,
Err(_) => continue,
};
let reference_str = match medic::reference_yaml(filename) {
Some(s) => s,
None => continue,
};
let reference_val: YamlValue = match serde_yaml::from_str(reference_str) {
Ok(v) => v,
Err(_) => continue,
};
let extra_top = medic::disallowed_top_level_keys(&user_val, &reference_val);
if !extra_top.is_empty() {
report.add(
"schema_boundary",
path.to_string_lossy().as_ref(),
CheckStatus::Warn,
&format!(
"extra top-level keys (not in reference): {}",
extra_top.join(", ")
),
);
}
if *filename != "providers.yaml" {
let allowed_nested_keys = medic::allowed_key_paths_shallow(&reference_val);
check_nested_keys_recursive(
&user_val,
&allowed_nested_keys,
"",
path.to_string_lossy().as_ref(),
report,
);
}
}
}
fn check_nested_keys_recursive(
user_val: &YamlValue,
allowed_keys: &std::collections::HashSet<String>,
prefix: &str,
path_str: &str,
report: &mut DoctorReport,
) {
if let Some(user_map) = user_val.as_mapping() {
for (key, value) in user_map {
if let Some(key_str) = key.as_str() {
let full_path = if prefix.is_empty() {
key_str.to_string()
} else {
format!("{}.{}", prefix, key_str)
};
if !prefix.is_empty() {
let parent_allowed = prefix.is_empty() || allowed_keys.contains(prefix);
let exact_allowed = allowed_keys.contains(&full_path);
if !parent_allowed && !exact_allowed {
report.add(
"schema_boundary",
path_str,
CheckStatus::Warn,
&format!("extra key (not in reference): {}", full_path),
);
}
}
if prefix.split('.').count() < 2 && value.as_mapping().is_some() {
check_nested_keys_recursive(value, allowed_keys, &full_path, path_str, report);
}
}
}
}
}
fn check_home_dirs(home: &Path, report: &mut DoctorReport) {
if !home.exists() {
report.add(
"home_dir",
home.to_string_lossy().as_ref(),
CheckStatus::Fail,
"ENACT_HOME directory does not exist",
);
return;
}
if !home.is_dir() {
report.add(
"home_dir",
home.to_string_lossy().as_ref(),
CheckStatus::Fail,
"ENACT_HOME is not a directory",
);
return;
}
for sub in &["agents", "projects", "state", "logs"] {
let p = home.join(sub);
if p.exists() && p.is_dir() {
report.add("home_dir", sub, CheckStatus::Pass, "ok");
} else {
report.add(
"home_dir",
sub,
CheckStatus::Fail,
"missing or not a directory",
);
}
}
for sub in &["commands", "plugins", "skills"] {
let p = home.join(sub);
if p.exists() && p.is_dir() {
report.add("home_dir", sub, CheckStatus::Pass, "ok");
} else {
report.add(
"home_dir",
sub,
CheckStatus::Warn,
"missing — run `enact doctor` or any enact command to create it",
);
}
}
}
fn check_global_config(home: &Path, report: &mut DoctorReport) {
let path = home.join("config.yaml");
if !path.exists() {
report.add(
"global_config",
"config.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match Config::load_from_yaml_path(&path) {
Ok(_) => report.add("global_config", "config.yaml", CheckStatus::Pass, "valid"),
Err(e) => report.add(
"global_config",
"config.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_server_config(home: &Path, report: &mut DoctorReport) {
let path = home.join("config.yaml");
match Config::load_from_yaml_path(&path) {
Ok(config) => {
let port = config.server.port;
let host = &config.server.host;
let grpc_port = config.server.grpc_port;
report.add(
"server",
"config.yaml",
CheckStatus::Pass,
&format!("host={host} port={port} grpc_port={grpc_port}"),
);
}
Err(_) => {
report.add(
"server",
"config.yaml",
CheckStatus::Pass,
"using defaults (host=0.0.0.0 port=8080 grpc_port=50051)",
);
}
}
}
fn check_providers_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("providers.yaml");
if !path.exists() {
report.add(
"providers_yaml",
"providers.yaml",
CheckStatus::Warn,
"missing — copy crates/enact-providers/providers.yaml to ~/.enact/",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let count = val
.get("models")
.and_then(|m| m.as_mapping())
.map(|m| m.len())
.unwrap_or(0);
report.add(
"providers_yaml",
"providers.yaml",
CheckStatus::Pass,
&format!("{} models loaded", count),
);
}
Err(e) => report.add(
"providers_yaml",
"providers.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_agents(home: &Path, report: &mut DoctorReport) {
let agents_dir = home.join("agents");
if !agents_dir.exists() || !agents_dir.is_dir() {
return;
}
let names = match AgentRegistry::list(home) {
Ok(n) => n,
Err(e) => {
report.add(
"agents",
"agents/",
CheckStatus::Fail,
&format!("list error: {}", e),
);
return;
}
};
let agent_ref =
medic::reference_yaml("agent.yaml").and_then(|s| serde_yaml::from_str::<YamlValue>(s).ok());
let allowed_nested_keys: std::collections::HashSet<String> = agent_ref
.as_ref()
.map(medic::allowed_key_paths_shallow)
.unwrap_or_default();
for name in &names {
let path = AgentDef::agent_yaml_path(home, name);
match AgentDef::load(home, name) {
Ok(Some(def)) => {
if def.name == *name {
report.add(
"agents",
path.to_string_lossy().as_ref(),
CheckStatus::Pass,
"valid",
);
} else {
report.add(
"agents",
path.to_string_lossy().as_ref(),
CheckStatus::Fail,
&format!("name '{}' does not match directory '{}'", def.name, name),
);
}
if let (Ok(content), Some(ref_val)) =
(std::fs::read_to_string(&path), agent_ref.as_ref())
{
if let Ok(user_val) = serde_yaml::from_str::<YamlValue>(&content) {
let extra = medic::disallowed_top_level_keys(&user_val, ref_val);
if !extra.is_empty() {
report.add(
"schema_boundary",
path.to_string_lossy().as_ref(),
CheckStatus::Warn,
&format!(
"extra top-level keys (not in reference): {}",
extra.join(", ")
),
);
}
check_nested_channel_configs(
&user_val,
&allowed_nested_keys,
path.to_string_lossy().as_ref(),
report,
);
}
}
validate_agent_channel_configs(&def, path.to_string_lossy().as_ref(), report);
}
Ok(None) => {}
Err(e) => report.add(
"agents",
path.to_string_lossy().as_ref(),
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
}
fn check_projects(home: &Path, report: &mut DoctorReport) {
let slugs = match ProjectRegistry::list(home) {
Ok(s) => s,
Err(e) => {
report.add(
"projects",
"projects/",
CheckStatus::Fail,
&format!("list error: {}", e),
);
return;
}
};
let agent_names: std::collections::HashSet<_> = AgentRegistry::list(home)
.unwrap_or_default()
.into_iter()
.collect();
for slug in &slugs {
let path = ProjectDef::project_yaml_path(home, slug);
match ProjectDef::load(home, slug) {
Ok(Some(def)) => {
if def.slug != *slug {
report.add(
"projects",
path.to_string_lossy().as_ref(),
CheckStatus::Fail,
&format!("slug '{}' does not match directory '{}'", def.slug, slug),
);
} else {
report.add(
"projects",
path.to_string_lossy().as_ref(),
CheckStatus::Pass,
"valid",
);
for agent in &def.agents {
if !agent_names.contains(agent) {
report.add(
"projects",
path.to_string_lossy().as_ref(),
CheckStatus::Warn,
&format!("referenced agent '{}' not found", agent),
);
}
}
}
}
Ok(None) => {}
Err(e) => report.add(
"projects",
path.to_string_lossy().as_ref(),
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
}
fn check_taskboards(home: &Path, report: &mut DoctorReport) {
let slugs = match ProjectRegistry::list(home) {
Ok(s) => s,
Err(_) => return,
};
for slug in &slugs {
let path = ProjectDef::taskboard_path(home, slug);
if !path.exists() {
continue;
}
match TaskBoard::load(home, slug) {
Ok(_) => report.add(
"taskboards",
path.to_string_lossy().as_ref(),
CheckStatus::Pass,
"valid",
),
Err(e) => report.add(
"taskboards",
path.to_string_lossy().as_ref(),
CheckStatus::Warn,
&format!("parse error: {}", e),
),
}
}
}
fn check_providers(home: &Path, report: &mut DoctorReport) {
let has_env_key = std::env::var("AZURE_API_KEY").is_ok()
|| std::env::var("OPENAI_API_KEY").is_ok()
|| std::env::var("AZURE_OPENAI_API_KEY").is_ok();
if has_env_key {
report.add("providers", "env", CheckStatus::Pass, "API key set via env");
return;
}
let config_path = home.join("config.yaml");
if config_path.exists() {
if let Ok(config) = Config::load_from_yaml_path(&config_path) {
let has_azure = config
.providers
.azure
.as_ref()
.and_then(|a| a.api_key.as_deref())
.is_some_and(|k| !k.is_empty());
let has_openai = config
.providers
.openai
.as_ref()
.and_then(|a| a.api_key.as_deref())
.is_some_and(|k| !k.is_empty());
if has_azure || has_openai {
report.add(
"providers",
"config.yaml",
CheckStatus::Pass,
"API key in config",
);
return;
}
}
}
report.add(
"providers",
"env/config",
CheckStatus::Warn,
"No API key found (set AZURE_API_KEY, OPENAI_API_KEY, or AZURE_OPENAI_API_KEY, or add to config.yaml)",
);
}
fn check_secrets(home: &Path, report: &mut DoctorReport) {
let path = home.join("config.encrypted");
if !path.exists() {
report.add(
"secrets",
"config.encrypted",
CheckStatus::Pass,
"not present (optional)",
);
return;
}
match EncryptedStore::new(&path) {
Ok(store) => {
if store.load().is_ok() {
report.add("secrets", "config.encrypted", CheckStatus::Pass, "readable");
} else {
report.add(
"secrets",
"config.encrypted",
CheckStatus::Warn,
"exists but decryption failed (check ENACT_CONFIG_ENCRYPTION_KEY)",
);
}
}
Err(e) => report.add(
"secrets",
"config.encrypted",
CheckStatus::Warn,
&format!("open error: {}", e),
),
}
}
fn check_state(home: &Path, report: &mut DoctorReport) {
let pid_file = home.join("state").join("daemon.pid");
if !pid_file.exists() {
report.add(
"state",
"state/daemon.pid",
CheckStatus::Pass,
"not present",
);
return;
}
let content = match std::fs::read_to_string(&pid_file) {
Ok(c) => c,
Err(e) => {
report.add(
"state",
"state/daemon.pid",
CheckStatus::Warn,
&format!("read error: {}", e),
);
return;
}
};
let pid: u32 = match content.trim().parse() {
Ok(p) => p,
Err(_) => {
report.add(
"state",
"state/daemon.pid",
CheckStatus::Warn,
"invalid PID",
);
return;
}
};
if !is_process_running(pid) {
report.add(
"state",
"state/daemon.pid",
CheckStatus::Warn,
"stale PID file (process not running)",
);
} else {
report.add(
"state",
"state/daemon.pid",
CheckStatus::Pass,
"daemon running",
);
}
}
fn check_hooks_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("hooks.yaml");
if !path.exists() {
report.add(
"hooks_yaml",
"hooks.yaml",
CheckStatus::Pass,
"not present (optional — no global lifecycle hooks configured)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let count = val
.get("hooks")
.and_then(|h| h.as_sequence())
.map(|s| s.len())
.unwrap_or(0);
report.add(
"hooks_yaml",
"hooks.yaml",
CheckStatus::Pass,
&format!("{} hook(s) configured", count),
);
}
Err(e) => report.add(
"hooks_yaml",
"hooks.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_commands_dir(home: &Path, report: &mut DoctorReport) {
let dir = home.join("commands");
if !dir.exists() {
return;
}
let count = std::fs::read_dir(&dir)
.map(|rd| {
rd.flatten()
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
.count()
})
.unwrap_or(0);
report.add(
"commands_dir",
"commands/",
CheckStatus::Pass,
&format!("{} slash command(s) available", count),
);
}
fn check_plugins_dir(home: &Path, report: &mut DoctorReport) {
let dir = home.join("plugins");
if !dir.exists() {
return;
}
let count = std::fs::read_dir(&dir)
.map(|rd| {
rd.flatten()
.filter(|e| {
e.path().is_dir() && e.path().join(".enact-plugin").join("plugin.json").exists()
})
.count()
})
.unwrap_or(0);
report.add(
"plugins_dir",
"plugins/",
CheckStatus::Pass,
&format!("{} plugin(s) installed", count),
);
}
fn check_enact_md(home: &Path, report: &mut DoctorReport) {
let path = home.join("ENACT.md");
if !path.exists() {
report.add(
"enact_md",
"ENACT.md",
CheckStatus::Pass,
"not present (optional — global agent system prompt context)",
);
return;
}
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
report.add(
"enact_md",
"ENACT.md",
CheckStatus::Pass,
&format!(
"present ({} bytes) — injected into system prompt at session start",
size
),
);
}
fn check_channels_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("channels.yaml");
if !path.exists() {
report.add(
"channels_yaml",
"channels.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let channels = val
.get("channels")
.and_then(|c| c.as_mapping())
.map(|m| m.len())
.unwrap_or(0);
report.add(
"channels_yaml",
"channels.yaml",
CheckStatus::Pass,
&format!("{} channel(s) configured", channels),
);
}
Err(e) => report.add(
"channels_yaml",
"channels.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_tools_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("tools.yaml");
if !path.exists() {
report.add(
"tools_yaml",
"tools.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let sections: Vec<&str> = ["shell", "file", "http", "security", "git"]
.iter()
.filter(|&&k| val.get(k).is_some())
.copied()
.collect();
report.add(
"tools_yaml",
"tools.yaml",
CheckStatus::Pass,
&format!("sections: {}", sections.join(", ")),
);
}
Err(e) => report.add(
"tools_yaml",
"tools.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_cron_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("cron.yaml");
if !path.exists() {
report.add(
"cron_yaml",
"cron.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let db_path = val
.get("store")
.and_then(|s| s.get("db_path"))
.and_then(|p| p.as_str())
.unwrap_or("(default)");
report.add(
"cron_yaml",
"cron.yaml",
CheckStatus::Pass,
&format!("db_path={}", db_path),
);
}
Err(e) => report.add(
"cron_yaml",
"cron.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_mcp_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("mcp.yaml");
if !path.exists() {
report.add(
"mcp_yaml",
"mcp.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let servers = val
.get("servers")
.and_then(|s| s.as_sequence())
.map(|s| s.len())
.unwrap_or(0);
let protocol = val
.get("client")
.and_then(|c| c.get("protocol_version"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
report.add(
"mcp_yaml",
"mcp.yaml",
CheckStatus::Pass,
&format!("{} server(s), protocol={}", servers, protocol),
);
}
Err(e) => report.add(
"mcp_yaml",
"mcp.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_a2a_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("a2a.yaml");
if !path.exists() {
report.add(
"a2a_yaml",
"a2a.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let provider = val
.get("default_provider")
.and_then(|p| p.as_str())
.unwrap_or("(default)");
let model = val
.get("default_model")
.and_then(|m| m.as_str())
.unwrap_or("(default)");
report.add(
"a2a_yaml",
"a2a.yaml",
CheckStatus::Pass,
&format!("provider={} model={}", provider, model),
);
}
Err(e) => report.add(
"a2a_yaml",
"a2a.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_skills_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("skills.yaml");
if !path.exists() {
report.add(
"skills_yaml",
"skills.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let enabled = val.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true);
let repo = val
.get("open_skills_repo_url")
.and_then(|r| r.as_str())
.is_some();
report.add(
"skills_yaml",
"skills.yaml",
CheckStatus::Pass,
&format!("enabled={} repo_configured={}", enabled, repo),
);
}
Err(e) => report.add(
"skills_yaml",
"skills.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_context_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("context.yaml");
if !path.exists() {
report.add(
"context_yaml",
"context.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let preset = val
.get("default_preset")
.and_then(|p| p.as_str())
.unwrap_or("(default)");
let total = val
.get("budget")
.and_then(|b| b.get("total_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0);
report.add(
"context_yaml",
"context.yaml",
CheckStatus::Pass,
&format!("preset={} budget={} tokens", preset, total),
);
}
Err(e) => report.add(
"context_yaml",
"context.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
fn check_memory_yaml(home: &Path, report: &mut DoctorReport) {
let path = home.join("memory.yaml");
if !path.exists() {
report.add(
"memory_yaml",
"memory.yaml",
CheckStatus::Warn,
"missing (using defaults)",
);
return;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|s| serde_yaml::from_str::<YamlValue>(&s).map_err(|e| e.to_string()))
{
Ok(val) => {
let backend = val
.get("backend")
.and_then(|b| b.as_str())
.unwrap_or("(default)");
let db_path = val
.get("db_path")
.and_then(|p| p.as_str())
.unwrap_or("(default)");
report.add(
"memory_yaml",
"memory.yaml",
CheckStatus::Pass,
&format!("backend={} db_path={}", backend, db_path),
);
}
Err(e) => report.add(
"memory_yaml",
"memory.yaml",
CheckStatus::Fail,
&format!("parse error: {}", e),
),
}
}
#[cfg(unix)]
fn is_process_running(pid: u32) -> bool {
use std::process::Command;
let out = Command::new("kill").args(["-0", &pid.to_string()]).output();
out.map(|o| o.status.success()).unwrap_or(false)
}
#[cfg(not(unix))]
fn is_process_running(_pid: u32) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_has_failures() {
let mut r = DoctorReport::default();
assert!(!r.has_failures());
r.add("a", "b", CheckStatus::Warn, "w");
assert!(!r.has_failures());
r.add("a", "c", CheckStatus::Fail, "f");
assert!(r.has_failures());
}
#[test]
fn run_checks_on_nonexistent_dir() {
let report = run_checks(Path::new("/nonexistent_enact_home_12345"));
assert!(report.has_failures());
assert!(report
.checks
.iter()
.any(|c| c.category == "home_dir" && c.status == CheckStatus::Fail));
}
}