tftio-clanker 0.2.4

Launch AI harnesses with runtime-configured context, domains, models, and prompts
Documentation
//! Runtime-configuration doctor integration.

use std::path::PathBuf;

use tftio_lib::{DoctorCheck, DoctorChecks, RepoInfo};

use crate::config::load_config;

/// Doctor checks bound to one runtime configuration path.
pub struct Doctor {
    config_path: Option<PathBuf>,
    current_dir: PathBuf,
    home: Option<PathBuf>,
}

impl Doctor {
    /// Create doctor checks for an optional resolved config path.
    #[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)
        );
    }
}