netsky-prompts 0.2.0

netsky prompts: prompt rendering and skill material
Documentation
//! Prompt loading, templating, and addendum layering.

use std::path::Path;

pub use crate::layers::{PromptAgent, PromptAgentKind, PromptContext, PromptError};
use crate::layers::{compose, resolve_layers};

const STARTUP_DEFAULT: &str = include_str!("../prompts/startup.md");
const STARTUP_AGENTINFINITY: &str = include_str!("../prompts/startup-agentinfinity.md");

pub const BUNDLED_PROMPT_FILES: &[(&str, &str)] = &[
    ("base.md", include_str!("../prompts/base.md")),
    ("agent0.md", include_str!("../prompts/agent0.md")),
    ("clone.md", include_str!("../prompts/clone.md")),
    (
        "agentinfinity.md",
        include_str!("../prompts/agentinfinity.md"),
    ),
    ("startup.md", STARTUP_DEFAULT),
    ("startup-agentinfinity.md", STARTUP_AGENTINFINITY),
];

pub fn startup_prompt_for<A: PromptAgent>(agent: A) -> &'static str {
    match agent.prompt_agent_kind() {
        PromptAgentKind::Agentinfinity => STARTUP_AGENTINFINITY,
        PromptAgentKind::Agent0 | PromptAgentKind::Clone => STARTUP_DEFAULT,
    }
}

pub fn render_prompt<A: PromptAgent>(
    ctx: PromptContext<A>,
    cwd: &Path,
) -> Result<String, PromptError> {
    compose(&resolve_layers(ctx, cwd, &[])?)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::runtime_config;
    use std::sync::{Mutex, MutexGuard, OnceLock};
    use tempfile::TempDir;

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    enum TestAgent {
        Agent0,
        Clone(u32),
        Agentinfinity,
    }

    impl std::fmt::Display for TestAgent {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            match self {
                Self::Agent0 => f.write_str("agent0"),
                Self::Clone(n) => write!(f, "agent{n}"),
                Self::Agentinfinity => f.write_str("agentinfinity"),
            }
        }
    }

    impl PromptAgent for TestAgent {
        fn prompt_agent_kind(self) -> PromptAgentKind {
            match self {
                Self::Agent0 => PromptAgentKind::Agent0,
                Self::Clone(_) => PromptAgentKind::Clone,
                Self::Agentinfinity => PromptAgentKind::Agentinfinity,
            }
        }

        fn prompt_agent_name(self) -> String {
            match self {
                Self::Agent0 => "agent0".to_string(),
                Self::Clone(n) => format!("agent{n}"),
                Self::Agentinfinity => "agentinfinity".to_string(),
            }
        }

        fn prompt_agent_env_n(self) -> String {
            match self {
                Self::Agent0 => "0".to_string(),
                Self::Clone(n) => n.to_string(),
                Self::Agentinfinity => "infinity".to_string(),
            }
        }
    }

    struct PromptTestEnv {
        _tmp: TempDir,
        _guard: MutexGuard<'static, ()>,
        prior_xdg: Option<String>,
        prior_machine_type: Option<String>,
    }

    impl PromptTestEnv {
        fn new() -> Self {
            let guard = test_lock().lock().unwrap_or_else(|err| err.into_inner());
            let tmp = TempDir::new().unwrap();
            let prior_xdg = std::env::var("XDG_CONFIG_HOME").ok();
            let prior_machine_type = std::env::var("MACHINE_TYPE").ok();
            unsafe {
                std::env::set_var("XDG_CONFIG_HOME", tmp.path());
                std::env::remove_var("MACHINE_TYPE");
            }
            std::fs::create_dir_all(runtime_config::config_dir()).unwrap();
            Self {
                _tmp: tmp,
                _guard: guard,
                prior_xdg,
                prior_machine_type,
            }
        }
    }

    fn test_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    impl Drop for PromptTestEnv {
        fn drop(&mut self) {
            unsafe {
                match &self.prior_xdg {
                    Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
                    None => std::env::remove_var("XDG_CONFIG_HOME"),
                }
                match &self.prior_machine_type {
                    Some(value) => std::env::set_var("MACHINE_TYPE", value),
                    None => std::env::remove_var("MACHINE_TYPE"),
                }
            }
        }
    }

    fn ctx_for(agent: TestAgent) -> PromptContext<TestAgent> {
        PromptContext::new(agent, "/tmp/netsky-test")
    }

    #[test]
    fn renders_all_agents_without_addendum() {
        let _env = PromptTestEnv::new();
        let nowhere = std::path::PathBuf::from("/dev/null/does-not-exist");
        for agent in [
            TestAgent::Agent0,
            TestAgent::Clone(1),
            TestAgent::Clone(8),
            TestAgent::Agentinfinity,
        ] {
            let out = render_prompt(ctx_for(agent), &nowhere).unwrap();
            assert!(!out.is_empty(), "empty prompt for {agent}");
            assert!(out.contains("---"), "missing separator for {agent}");
            assert!(!out.contains("{{"), "unsubstituted placeholder for {agent}");
        }
    }

    #[test]
    fn clone_prompt_substitutes_n() {
        let nowhere = std::path::PathBuf::from("/dev/null/does-not-exist");
        let out = render_prompt(ctx_for(TestAgent::Clone(5)), &nowhere).unwrap();
        assert!(out.contains("agent5"));
        assert!(!out.contains("{{ n }}"));
    }

    #[test]
    fn cwd_addendum_is_appended() {
        let _env = PromptTestEnv::new();
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("0.md"), "USER POLICY HERE").unwrap();
        let out = render_prompt(ctx_for(TestAgent::Agent0), tmp.path()).unwrap();
        assert!(out.contains("USER POLICY HERE"));
    }

    #[test]
    fn netsky_toml_addendum_overrides_default_path() {
        let _env = PromptTestEnv::new();
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("0.md"), "OLD POLICY").unwrap();
        std::fs::create_dir_all(tmp.path().join("addenda")).unwrap();
        std::fs::write(tmp.path().join("addenda/0-personal.md"), "NEW POLICY").unwrap();
        std::fs::write(
            tmp.path().join("netsky.toml"),
            "schema_version = 1\n[addendum]\nagent0 = \"addenda/0-personal.md\"\n",
        )
        .unwrap();

        let out = render_prompt(ctx_for(TestAgent::Agent0), tmp.path()).unwrap();
        assert!(out.contains("NEW POLICY"));
        assert!(!out.contains("OLD POLICY"));
    }

    #[test]
    fn missing_netsky_toml_falls_back_to_legacy_addendum() {
        let _env = PromptTestEnv::new();
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("0.md"), "LEGACY ADDENDUM").unwrap();
        let out = render_prompt(ctx_for(TestAgent::Agent0), tmp.path()).unwrap();
        assert!(out.contains("LEGACY ADDENDUM"));
    }

    #[test]
    fn netsky_toml_without_addendum_section_falls_back() {
        let _env = PromptTestEnv::new();
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("0.md"), "FALLBACK POLICY").unwrap();
        std::fs::write(
            tmp.path().join("netsky.toml"),
            "schema_version = 1\n[owner]\nname = \"Alice\"\n",
        )
        .unwrap();
        let out = render_prompt(ctx_for(TestAgent::Agent0), tmp.path()).unwrap();
        assert!(out.contains("FALLBACK POLICY"));
    }

    #[test]
    fn netsky_toml_addendum_absolute_path_used_as_is() {
        let _env = PromptTestEnv::new();
        let tmp = tempfile::tempdir().unwrap();
        let abs_addendum = tmp.path().join("absolute-addendum.md");
        std::fs::write(&abs_addendum, "ABSOLUTE POLICY").unwrap();
        std::fs::write(
            tmp.path().join("netsky.toml"),
            format!(
                "schema_version = 1\n[addendum]\nagent0 = \"{}\"\n",
                abs_addendum.display()
            ),
        )
        .unwrap();
        let out = render_prompt(ctx_for(TestAgent::Agent0), tmp.path()).unwrap();
        assert!(out.contains("ABSOLUTE POLICY"));
    }

    #[test]
    fn runtime_addendum_layers_append_after_cwd_addendum() {
        let _env = PromptTestEnv::new();
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("0.md"), "CWD POLICY").unwrap();
        std::fs::write(runtime_config::owner_path(), "github_username = \"cody\"\n").unwrap();
        std::fs::write(runtime_config::addendum_path(), "BASE POLICY\n").unwrap();
        std::fs::write(runtime_config::active_host_path(), "work\n").unwrap();
        std::fs::write(runtime_config::host_addendum_path("work"), "WORK POLICY\n").unwrap();

        let out = render_prompt(ctx_for(TestAgent::Agent0), tmp.path()).unwrap();
        let cwd = out.find("CWD POLICY").unwrap();
        let base = out.find("BASE POLICY").unwrap();
        let host = out.find("WORK POLICY").unwrap();
        assert!(cwd < base);
        assert!(base < host);
    }

    #[test]
    fn machine_type_env_overrides_active_host_cache() {
        let _env = PromptTestEnv::new();
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(runtime_config::owner_path(), "github_username = \"cody\"\n").unwrap();
        std::fs::write(runtime_config::active_host_path(), "personal\n").unwrap();
        std::fs::write(
            runtime_config::host_addendum_path("personal"),
            "PERSONAL POLICY\n",
        )
        .unwrap();
        std::fs::write(runtime_config::host_addendum_path("work"), "WORK POLICY\n").unwrap();
        unsafe {
            std::env::set_var("MACHINE_TYPE", "work");
        }

        let out = render_prompt(ctx_for(TestAgent::Agent0), tmp.path()).unwrap();
        assert!(out.contains("WORK POLICY"));
        assert!(!out.contains("PERSONAL POLICY"));
    }

    #[test]
    fn startup_prompt_switches_for_watchdog() {
        assert_eq!(startup_prompt_for(TestAgent::Agent0), STARTUP_DEFAULT);
        assert_eq!(startup_prompt_for(TestAgent::Clone(3)), STARTUP_DEFAULT);
        assert_eq!(
            startup_prompt_for(TestAgent::Agentinfinity),
            STARTUP_AGENTINFINITY
        );
    }
}