use std::path::PathBuf;
use tftio_lib::{DoctorCheck, DoctorChecks, RepoInfo};
use crate::config::load_config;
pub struct Doctor {
config_path: Option<PathBuf>,
current_dir: PathBuf,
home: Option<PathBuf>,
}
impl Doctor {
#[must_use]
pub const fn new(
config_path: Option<PathBuf>,
current_dir: PathBuf,
home: Option<PathBuf>,
) -> Self {
Self {
config_path,
current_dir,
home,
}
}
}
impl DoctorChecks for Doctor {
fn repo_info() -> RepoInfo {
RepoInfo::new("tftio-stuff", "clanker")
}
fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn tool_checks(&self) -> Vec<DoctorCheck> {
let mut checks = self.config_path.as_ref().map_or_else(
|| {
vec![DoctorCheck::fail(
"Clanker config",
"$HOME is not set and CLANKER_CONFIG was not provided",
)]
},
|path| match load_config(path) {
Ok(loaded) => {
let mut checks = vec![DoctorCheck::pass(format!(
"Config file: {}",
loaded.base_path.display()
))];
if let Some(overlay) = loaded.overlay_path {
checks.push(DoctorCheck::pass(format!(
"Local overlay: {}",
overlay.display()
)));
}
checks.push(DoctorCheck::pass("Runtime config schema and invariants"));
if let Some(home) = &self.home {
let filesystem_issues = loaded.config.filesystem_issues(home);
if filesystem_issues.is_empty() {
checks.push(DoctorCheck::pass("Skill bundle and configured skills"));
} else {
checks.extend(
filesystem_issues.into_iter().map(|issue| {
DoctorCheck::fail("Skill bundle", issue.to_string())
}),
);
}
} else {
checks.push(DoctorCheck::fail(
"Skill bundle",
"$HOME is not set; cannot resolve defaults.skills_bundle",
));
}
checks
}
Err(error) => vec![DoctorCheck::fail("Clanker config", error.to_string())],
},
);
let legacy_context = self.current_dir.join(".context");
if legacy_context.exists() {
checks.push(DoctorCheck::fail(
"Legacy context file",
format!(
"{} exists; replace it with .clanker TOML",
legacy_context.display()
),
));
} else {
checks.push(DoctorCheck::pass("No legacy .context file in cwd"));
}
checks
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use tftio_lib::DoctorChecks;
const VALID_CONFIG: &str = r#"
[defaults]
domain = "eng"
contexts = ["personal"]
context_fallback = "personal"
context_by_hostname = {}
shim_path = "~/.local/clankers/bin"
sandbox_wrapper = "~/unused"
prompter_bundle = "~/.local/prompter"
skills_bundle = "~/.local/clanker/skills"
prompt_cache = "~/.cache/clanker"
prompt_cache_ttl_seconds = 1
[harness.claude]
bin = "claude"
family = "claude"
default_args = []
[harness.claude.injection]
kind = "arg-text"
args = ["{text}"]
[model]
[domain.eng]
profiles = ["core.base"]
skills = ["current-session"]
env = {}
"#;
fn write_skill(home: &std::path::Path) {
let skill = home.join(".local/clanker/skills/current-session");
fs::create_dir_all(&skill).unwrap();
fs::write(skill.join("SKILL.md"), "x").unwrap();
}
fn write_config(dir: &std::path::Path, body: &str) -> PathBuf {
let path = dir.join("config.toml");
fs::write(&path, body).unwrap();
path
}
#[test]
fn valid_config_with_overlay_passes_every_check() {
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
write_skill(&home);
let config_path = write_config(temp.path(), VALID_CONFIG);
fs::write(
temp.path().join("local.toml"),
"[defaults]\ncontext_fallback = \"personal\"\n",
)
.unwrap();
let cwd = temp.path().join("cwd");
fs::create_dir_all(&cwd).unwrap();
let checks = Doctor::new(Some(config_path), cwd, Some(home)).tool_checks();
assert!(checks.iter().all(|check| check.passed));
assert!(
checks
.iter()
.any(|check| check.name.contains("Local overlay"))
);
assert!(checks.iter().any(|check| check.name.contains("No legacy")));
}
#[test]
fn missing_skill_bundle_fails_the_skill_check() {
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
fs::create_dir_all(&home).unwrap();
let config_path = write_config(temp.path(), VALID_CONFIG);
let checks =
Doctor::new(Some(config_path), temp.path().join("cwd"), Some(home)).tool_checks();
assert!(
checks
.iter()
.any(|check| check.name == "Skill bundle" && !check.passed)
);
}
#[test]
fn absent_home_fails_the_skill_check() {
let temp = TempDir::new().unwrap();
let config_path = write_config(temp.path(), VALID_CONFIG);
let checks = Doctor::new(Some(config_path), temp.path().join("cwd"), None).tool_checks();
assert!(checks.iter().any(|check| {
check.name == "Skill bundle"
&& check
.message
.as_deref()
.is_some_and(|message| message.contains("$HOME is not set"))
}));
}
#[test]
fn absent_config_path_fails_closed() {
let temp = TempDir::new().unwrap();
let checks = Doctor::new(None, temp.path().join("cwd"), None).tool_checks();
assert!(
checks
.iter()
.any(|check| check.name == "Clanker config" && !check.passed)
);
}
#[test]
fn unparsable_config_fails_closed() {
let temp = TempDir::new().unwrap();
let config_path = write_config(temp.path(), "this is = not valid = toml");
let checks = Doctor::new(
Some(config_path),
temp.path().join("cwd"),
Some(temp.path().join("home")),
)
.tool_checks();
assert!(
checks
.iter()
.any(|check| check.name == "Clanker config" && !check.passed)
);
}
#[test]
fn legacy_context_file_is_reported() {
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
write_skill(&home);
let config_path = write_config(temp.path(), VALID_CONFIG);
let cwd = temp.path().join("cwd");
fs::create_dir_all(&cwd).unwrap();
fs::write(cwd.join(".context"), "work\n").unwrap();
let checks = Doctor::new(Some(config_path), cwd, Some(home)).tool_checks();
assert!(
checks
.iter()
.any(|check| check.name == "Legacy context file" && !check.passed)
);
}
}