task-mcp 0.5.0

MCP server for task runner integration — Agent-safe harness for defined tasks
Documentation
use std::env;
use std::path::{Path, PathBuf};

// =============================================================================
// Global justfile path resolution
// =============================================================================

/// Resolve the global justfile path according to the resolution order:
/// 1. `TASK_MCP_GLOBAL_JUSTFILE` env
/// 2. `$XDG_CONFIG_HOME/task-mcp/justfile`
/// 3. `$HOME/.config/task-mcp/justfile`
///
/// Returns `None` if none of the candidates exist, emitting a warning to stderr.
fn resolve_global_justfile_path() -> Option<PathBuf> {
    resolve_global_justfile_path_inner(|k| env::var(k).ok())
}

/// Testable inner implementation that accepts an env lookup closure instead of
/// reading the process environment directly.  This avoids `unsafe { set_var }`
/// in tests, which is unsound in multi-threaded test runners.
fn resolve_global_justfile_path_inner(
    env_lookup: impl Fn(&str) -> Option<String>,
) -> Option<PathBuf> {
    // 1. Explicit override
    if let Some(p) = env_lookup("TASK_MCP_GLOBAL_JUSTFILE") {
        let path = PathBuf::from(p);
        if path.exists() {
            return Some(path);
        }
        eprintln!(
            "task-mcp: TASK_MCP_GLOBAL_JUSTFILE={} does not exist; ignoring",
            path.display()
        );
    }

    // 2. XDG_CONFIG_HOME
    if let Some(xdg) = env_lookup("XDG_CONFIG_HOME") {
        let path = PathBuf::from(xdg).join("task-mcp").join("justfile");
        if path.exists() {
            return Some(path);
        }
    }

    // 3. $HOME/.config/task-mcp/justfile
    if let Some(home) = env_lookup("HOME") {
        let path = PathBuf::from(home)
            .join(".config")
            .join("task-mcp")
            .join("justfile");
        if path.exists() {
            return Some(path);
        }
    }

    eprintln!(
        "task-mcp: TASK_MCP_LOAD_GLOBAL=true but no global justfile found; continuing without global recipes"
    );
    None
}

// =============================================================================
// Task mode
// =============================================================================

/// Controls which recipes are exposed via MCP.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum TaskMode {
    /// Only recipes tagged with `[allow-agent]` (group attribute or comment tag).
    #[default]
    AgentOnly,
    /// All non-private recipes.
    All,
}

impl TaskMode {
    /// Parse from the `TASK_MCP_MODE` environment variable value.
    fn from_env_value(val: &str) -> Self {
        match val.trim().to_lowercase().as_str() {
            "all" => Self::All,
            _ => Self::AgentOnly,
        }
    }
}

// =============================================================================
// Config
// =============================================================================

/// Runtime configuration for task-mcp.
#[derive(Debug, Clone, Default)]
pub struct Config {
    /// Filtering mode for exposed recipes.
    pub mode: TaskMode,
    /// Path to the justfile (relative or absolute).
    pub justfile_path: Option<String>,
    /// Allowed working directories for session_start.
    /// Empty means no restriction (all directories allowed).
    pub allowed_dirs: Vec<PathBuf>,
    /// Path to a custom justfile template used by the `init` tool.
    pub init_template_file: Option<String>,
    /// When `true`, global justfile recipes are merged with project recipes.
    pub load_global: bool,
    /// Resolved absolute path to the global justfile (only set when `load_global=true` and file exists).
    pub global_justfile_path: Option<PathBuf>,
}

impl Config {
    /// Load configuration from `.task-mcp.env` (if present) and environment variables.
    ///
    /// Priority: env var > `.task-mcp.env` > default.
    pub fn load() -> Self {
        // Load .task-mcp.env if it exists; ignore errors (file may not be present).
        let _ = dotenvy::from_filename(".task-mcp.env");

        let mode = env::var("TASK_MCP_MODE")
            .map(|v| TaskMode::from_env_value(&v))
            .unwrap_or_default();

        let justfile_path = env::var("TASK_MCP_JUSTFILE").ok();

        let allowed_dirs = env::var("TASK_MCP_ALLOWED_DIRS")
            .map(|v| parse_allowed_dirs(&v))
            .unwrap_or_default();

        let init_template_file = env::var("TASK_MCP_INIT_TEMPLATE_FILE").ok();

        let load_global = env::var("TASK_MCP_LOAD_GLOBAL")
            .map(|v| matches!(v.trim().to_lowercase().as_str(), "true" | "1"))
            .unwrap_or(false);

        let global_justfile_path = if load_global {
            resolve_global_justfile_path()
        } else {
            None
        };

        Self {
            mode,
            justfile_path,
            allowed_dirs,
            init_template_file,
            load_global,
            global_justfile_path,
        }
    }

    /// Check whether `workdir` is permitted.
    ///
    /// Returns `true` when `allowed_dirs` is empty (no restriction) or when
    /// `workdir` starts with at least one entry in `allowed_dirs`.
    pub fn is_workdir_allowed(&self, workdir: &Path) -> bool {
        if self.allowed_dirs.is_empty() {
            return true;
        }
        self.allowed_dirs.iter().any(|d| workdir.starts_with(d))
    }
}

/// Parse `TASK_MCP_ALLOWED_DIRS` value into canonicalized `PathBuf`s.
///
/// Comma-separated entries are canonicalized. Entries that fail canonicalization
/// (e.g. directory does not exist) are skipped with a warning to stderr.
fn parse_allowed_dirs(raw: &str) -> Vec<PathBuf> {
    raw.split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .filter_map(|entry| match std::fs::canonicalize(entry) {
            Ok(p) => Some(p),
            Err(e) => {
                eprintln!("task-mcp: TASK_MCP_ALLOWED_DIRS: skipping {entry:?}: {e}");
                None
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn task_mode_default_is_agent_only() {
        assert_eq!(TaskMode::default(), TaskMode::AgentOnly);
    }

    #[test]
    fn task_mode_from_env_value_all() {
        assert_eq!(TaskMode::from_env_value("all"), TaskMode::All);
        assert_eq!(TaskMode::from_env_value("ALL"), TaskMode::All);
        assert_eq!(TaskMode::from_env_value("All"), TaskMode::All);
    }

    #[test]
    fn task_mode_from_env_value_agent_only() {
        assert_eq!(TaskMode::from_env_value("agent-only"), TaskMode::AgentOnly);
        assert_eq!(TaskMode::from_env_value("unknown"), TaskMode::AgentOnly);
        assert_eq!(TaskMode::from_env_value(""), TaskMode::AgentOnly);
    }

    #[test]
    fn config_default() {
        let cfg = Config::default();
        assert_eq!(cfg.mode, TaskMode::AgentOnly);
        assert!(cfg.justfile_path.is_none());
        assert!(cfg.allowed_dirs.is_empty());
        assert!(!cfg.load_global);
        assert!(cfg.global_justfile_path.is_none());
    }

    #[test]
    fn load_global_default_false() {
        // When TASK_MCP_LOAD_GLOBAL is not set, load_global must be false.
        // We test the flag parse logic directly (can't safely mutate env in parallel tests).
        let parse = |val: &str| matches!(val.trim().to_lowercase().as_str(), "true" | "1");
        assert!(!parse("false"));
        assert!(!parse("0"));
        assert!(!parse(""));
        assert!(parse("true"));
        assert!(parse("True"));
        assert!(parse("TRUE"));
        assert!(parse("1"));
    }

    #[test]
    fn resolve_global_justfile_path_respects_explicit_env() {
        use std::io::Write;
        // Create a temp file to act as the "global justfile".
        let tmp = tempfile::NamedTempFile::new().expect("temp file");
        writeln!(tmp.as_file(), "# test").unwrap();
        let path = tmp.path().to_path_buf();
        let path_str = path.to_string_lossy().into_owned();

        // Pure test: inject the path via closure — no unsafe set_var needed.
        let result = resolve_global_justfile_path_inner(|k| {
            if k == "TASK_MCP_GLOBAL_JUSTFILE" {
                Some(path_str.clone())
            } else {
                None
            }
        });

        assert_eq!(result, Some(path));
    }

    #[test]
    fn resolve_global_justfile_path_nonexistent_returns_none() {
        // All env vars point to non-existent paths — result must be None.
        let result = resolve_global_justfile_path_inner(|k| match k {
            "TASK_MCP_GLOBAL_JUSTFILE" => {
                Some("/nonexistent/path/that/does/not/exist/justfile".to_string())
            }
            "XDG_CONFIG_HOME" => Some("/nonexistent/xdg".to_string()),
            "HOME" => Some("/nonexistent/home".to_string()),
            _ => None,
        });

        assert!(result.is_none());
    }

    #[test]
    fn is_workdir_allowed_empty_allows_all() {
        let cfg = Config {
            allowed_dirs: vec![],
            ..Config::default()
        };
        assert!(cfg.is_workdir_allowed(Path::new("/any/path")));
        assert!(cfg.is_workdir_allowed(Path::new("/")));
    }

    #[test]
    fn is_workdir_allowed_match() {
        let cfg = Config {
            allowed_dirs: vec![PathBuf::from("/home/user/projects")],
            ..Config::default()
        };
        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects")));
        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects/foo")));
        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects/foo/bar")));
    }

    #[test]
    fn is_workdir_allowed_no_match() {
        let cfg = Config {
            allowed_dirs: vec![PathBuf::from("/home/user/projects")],
            ..Config::default()
        };
        // Sibling directory — not under allowed path
        assert!(!cfg.is_workdir_allowed(Path::new("/home/user/other")));
        // Path prefix match but not a directory boundary
        assert!(!cfg.is_workdir_allowed(Path::new("/home/user/projects-extra")));
        assert!(!cfg.is_workdir_allowed(Path::new("/home/user")));
    }
}