llman 0.0.42

A tool for managing LLM application rules(prompts) ...
Documentation
use crate::path_utils::validate_path_str;
use anyhow::{Result, anyhow};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

pub const ENV_CONFIG_DIR: &str = "LLMAN_CONFIG_DIR";
pub const ENV_LANG: &str = "LLMAN_LANG";
pub const APP_NAME: &str = "llman";
pub const CURSOR_APP: &str = "cursor";
pub const CODEX_APP: &str = "codex";
pub const CLAUDE_CODE_APP: &str = "claude-code";
pub const CURSOR_EXTENSION: &str = "mdc";
pub const CODEX_EXTENSION: &str = "md";
pub const DEFAULT_EXTENSION: &str = "txt";
pub const PROMPT_DIR: &str = "prompt";
pub const TARGET_CURSOR_RULES_DIR: &str = ".cursor/rules";

static RUNTIME_CONFIG_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);

pub(crate) struct ConfigDirOverrideGuard {
    previous: Option<PathBuf>,
}

pub(crate) fn override_runtime_config_dir(config_dir: PathBuf) -> ConfigDirOverrideGuard {
    let mut slot = RUNTIME_CONFIG_DIR.lock().unwrap_or_else(|e| e.into_inner());
    let previous = slot.replace(config_dir);
    ConfigDirOverrideGuard { previous }
}

impl Drop for ConfigDirOverrideGuard {
    fn drop(&mut self) {
        let mut slot = RUNTIME_CONFIG_DIR.lock().unwrap_or_else(|e| e.into_inner());
        *slot = self.previous.take();
    }
}

fn runtime_config_dir() -> Option<PathBuf> {
    RUNTIME_CONFIG_DIR
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .clone()
}

fn rule_extension_for_app(app: &str) -> &'static str {
    match app {
        CURSOR_APP => CURSOR_EXTENSION,
        CODEX_APP => CODEX_EXTENSION,
        _ => DEFAULT_EXTENSION,
    }
}

pub(crate) fn try_home_dir() -> Option<PathBuf> {
    if let Some(home) = env::var_os("HOME")
        && !home.is_empty()
    {
        return Some(PathBuf::from(home));
    }

    #[cfg(windows)]
    {
        if let Some(profile) = env::var_os("USERPROFILE")
            && !profile.is_empty()
        {
            return Some(PathBuf::from(profile));
        }

        let drive = env::var_os("HOMEDRIVE");
        let path = env::var_os("HOMEPATH");
        if let (Some(drive), Some(path)) = (drive, path)
            && !drive.is_empty()
            && !path.is_empty()
        {
            let mut combined = drive;
            combined.push(path);
            return Some(PathBuf::from(combined));
        }
    }

    None
}

pub(crate) fn home_dir() -> Result<PathBuf> {
    try_home_dir().ok_or_else(|| anyhow!(t!("errors.home_dir_missing")))
}

fn expand_tilde_path(path: &Path) -> Result<PathBuf> {
    let Ok(stripped) = path.strip_prefix("~") else {
        return Ok(path.to_path_buf());
    };

    let home = home_dir()?;
    Ok(home.join(stripped))
}

pub fn resolve_config_dir(cli_override: Option<&Path>) -> Result<PathBuf> {
    if let Some(path) = cli_override {
        return resolve_config_dir_with(Some(path), None);
    }

    if let Some(runtime_override) = runtime_config_dir() {
        return Ok(runtime_override);
    }

    let env_override = env::var(ENV_CONFIG_DIR).ok();
    resolve_config_dir_with(None, env_override.as_deref())
}

pub fn resolve_config_dir_with(
    cli_override: Option<&Path>,
    env_override: Option<&str>,
) -> Result<PathBuf> {
    if let Some(path) = cli_override {
        validate_path_str(&path.to_string_lossy())
            .map_err(|e| anyhow!(t!("errors.invalid_config_dir", error = e)))?;
        return expand_tilde_path(path);
    }

    if let Some(env_config_dir) = env_override {
        validate_path_str(env_config_dir)
            .map_err(|e| anyhow!(t!("errors.invalid_config_dir_env", error = e)))?;
        return expand_tilde_path(Path::new(env_config_dir));
    }

    let home = home_dir()?;
    Ok(home.join(".config").join(APP_NAME))
}

pub struct Config {
    config_dir: PathBuf,
    prompt_dir: PathBuf,
}

impl Config {
    #[allow(dead_code)]
    pub fn new() -> Result<Self> {
        Self::with_config_dir(None)
    }

    pub fn with_config_dir(config_dir_override: Option<&str>) -> Result<Self> {
        let config_dir = resolve_config_dir(config_dir_override.map(Path::new))?;

        let prompt_dir = config_dir.join(PROMPT_DIR);

        fs::create_dir_all(&config_dir)?;

        Ok(Self {
            config_dir,
            prompt_dir,
        })
    }

    #[allow(dead_code)]
    pub fn config_dir(&self) -> &Path {
        &self.config_dir
    }

    #[allow(dead_code)]
    pub fn prompt_dir(&self) -> &Path {
        &self.prompt_dir
    }

    pub fn app_dir(&self, app: &str) -> PathBuf {
        self.prompt_dir.join(app)
    }

    pub fn ensure_app_dir(&self, app: &str) -> Result<PathBuf> {
        let app_dir = self.app_dir(app);
        fs::create_dir_all(&app_dir)?;
        Ok(app_dir)
    }

    pub fn rule_file_path(&self, app: &str, name: &str) -> PathBuf {
        let extension = rule_extension_for_app(app);
        self.app_dir(app).join(format!("{name}.{extension}"))
    }

    pub fn list_rules(&self, app: &str) -> Result<Vec<String>> {
        let app_dir = self.app_dir(app);

        if !app_dir.exists() {
            return Ok(Vec::new());
        }

        let mut rules = Vec::new();
        let expected_extension = rule_extension_for_app(app);
        for entry in fs::read_dir(app_dir)? {
            let entry = entry?;
            let path = entry.path();

            if path.is_file()
                && path.extension().and_then(|ext| ext.to_str()) == Some(expected_extension)
                && let Some(stem) = path.file_stem()
                && let Some(name) = stem.to_str()
            {
                rules.push(name.to_string());
            }
        }

        rules.sort();
        Ok(rules)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_utils::TestProcess;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_resolve_config_dir_env_override() {
        let temp = TempDir::new().expect("temp dir");
        let temp_dir = temp.path().to_path_buf();
        let resolved = resolve_config_dir_with(None, temp_dir.to_str()).unwrap();
        assert_eq!(resolved, temp_dir);
    }

    #[test]
    fn test_resolve_config_dir_cli_overrides_env() {
        let env_temp = TempDir::new().expect("temp dir");
        let cli_temp = TempDir::new().expect("temp dir");
        let env_dir = env_temp.path().to_path_buf();
        let cli_dir = cli_temp.path().to_path_buf();

        let resolved = resolve_config_dir_with(Some(cli_dir.as_path()), env_dir.to_str()).unwrap();
        assert_eq!(resolved, cli_dir);
    }

    #[test]
    fn test_resolve_config_dir_env_overrides_default() {
        let env_temp = TempDir::new().expect("temp dir");
        let env_dir = env_temp.path().to_path_buf();

        let resolved = resolve_config_dir_with(None, env_dir.to_str()).unwrap();
        assert_eq!(resolved, env_dir);
    }

    #[test]
    fn test_resolve_config_dir_default_path() {
        let temp_home = TempDir::new().expect("temp home");
        let mut proc = TestProcess::new();
        proc.set_var("HOME", temp_home.path());

        let resolved = resolve_config_dir_with(None, None).unwrap();
        assert_eq!(resolved, temp_home.path().join(".config").join(APP_NAME));
        assert!(!resolved.exists(), "resolution must not create directories");
    }

    #[test]
    fn test_resolve_config_dir_default_ignores_legacy_macos_directories() {
        let temp_home = TempDir::new().expect("temp home");
        let mut proc = TestProcess::new();
        proc.set_var("HOME", temp_home.path());

        let legacy_app_support = temp_home
            .path()
            .join("Library")
            .join("Application Support")
            .join(APP_NAME);
        fs::create_dir_all(legacy_app_support.join(PROMPT_DIR)).expect("create legacy prompt dir");
        fs::write(legacy_app_support.join("config.yaml"), "legacy: true\n")
            .expect("write legacy config");

        let legacy_bundle_id = temp_home
            .path()
            .join("Library")
            .join("Application Support")
            .join("com.StrayDragon.llman");
        fs::create_dir_all(legacy_bundle_id.join(PROMPT_DIR))
            .expect("create legacy bundle prompt dir");

        let resolved = resolve_config_dir_with(None, None).unwrap();
        assert_eq!(resolved, temp_home.path().join(".config").join(APP_NAME));
    }

    #[cfg(unix)]
    #[test]
    fn test_resolve_config_dir_cli_expands_tilde_to_home() {
        let temp_home = TempDir::new().expect("temp home");
        let mut proc = TestProcess::new();
        proc.set_var("HOME", temp_home.path());

        let resolved =
            resolve_config_dir_with(Some(Path::new("~/.config/llman")), None).expect("resolve");
        assert_eq!(resolved, temp_home.path().join(".config").join("llman"));
    }

    #[cfg(unix)]
    #[test]
    fn test_resolve_config_dir_env_expands_tilde_to_home() {
        let temp_home = TempDir::new().expect("temp home");
        let mut proc = TestProcess::new();
        proc.set_var("HOME", temp_home.path());

        let resolved = resolve_config_dir_with(None, Some("~/.config/llman")).expect("resolve");
        assert_eq!(resolved, temp_home.path().join(".config").join("llman"));
    }

    #[test]
    fn test_app_dir() {
        let temp = TempDir::new().expect("temp dir");
        let temp_dir = temp.path().to_path_buf();

        let config = Config::with_config_dir(temp_dir.to_str()).unwrap();
        let cursor_dir = config.app_dir("cursor");
        assert_eq!(cursor_dir, temp_dir.join("prompt").join("cursor"));
    }

    #[test]
    fn test_rule_file_path() {
        let temp = TempDir::new().expect("temp dir");
        let temp_dir = temp.path().to_path_buf();

        let config = Config::with_config_dir(temp_dir.to_str()).unwrap();
        let rule_path = config.rule_file_path("cursor", "test-rule");
        assert_eq!(
            rule_path,
            temp_dir.join("prompt").join("cursor").join("test-rule.mdc")
        );
    }

    #[test]
    fn test_rule_file_path_codex_uses_md() {
        let temp = TempDir::new().expect("temp dir");
        let temp_dir = temp.path().to_path_buf();

        let config = Config::with_config_dir(temp_dir.to_str()).unwrap();
        let rule_path = config.rule_file_path(CODEX_APP, "draftpr");
        assert_eq!(
            rule_path,
            temp_dir.join("prompt").join(CODEX_APP).join("draftpr.md")
        );
    }

    #[test]
    fn test_list_rules_filters_by_extension_per_app() {
        let temp = TempDir::new().expect("temp dir");
        let config = Config::with_config_dir(temp.path().to_str()).unwrap();

        let cursor_dir = config.ensure_app_dir(CURSOR_APP).unwrap();
        let codex_dir = config.ensure_app_dir(CODEX_APP).unwrap();
        let claude_dir = config.ensure_app_dir(CLAUDE_CODE_APP).unwrap();

        fs::write(cursor_dir.join("keep.mdc"), "x").expect("write");
        fs::write(cursor_dir.join("ignore.txt"), "x").expect("write");
        fs::write(cursor_dir.join("backup.mdc.bak"), "x").expect("write");

        fs::write(codex_dir.join("draft.md"), "x").expect("write");
        fs::write(codex_dir.join("draft.md.bak"), "x").expect("write");

        fs::write(claude_dir.join("mem.txt"), "x").expect("write");
        fs::write(claude_dir.join("mem.md"), "x").expect("write");

        assert_eq!(config.list_rules(CURSOR_APP).unwrap(), vec!["keep"]);
        assert_eq!(config.list_rules(CODEX_APP).unwrap(), vec!["draft"]);
        assert_eq!(config.list_rules(CLAUDE_CODE_APP).unwrap(), vec!["mem"]);
    }
}