use std::env;
use std::path::{Path, PathBuf};
fn resolve_global_justfile_path() -> Option<PathBuf> {
resolve_global_justfile_path_inner(|k| env::var(k).ok())
}
fn resolve_global_justfile_path_inner(
env_lookup: impl Fn(&str) -> Option<String>,
) -> Option<PathBuf> {
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()
);
}
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);
}
}
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
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum TaskMode {
#[default]
AgentOnly,
All,
}
impl TaskMode {
fn from_env_value(val: &str) -> Self {
match val.trim().to_lowercase().as_str() {
"all" => Self::All,
_ => Self::AgentOnly,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Config {
pub mode: TaskMode,
pub justfile_path: Option<String>,
pub allowed_dirs: Vec<PathBuf>,
pub init_template_file: Option<String>,
pub load_global: bool,
pub global_justfile_path: Option<PathBuf>,
}
impl Config {
pub fn load() -> Self {
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,
}
}
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))
}
}
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() {
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;
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();
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() {
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()
};
assert!(!cfg.is_workdir_allowed(Path::new("/home/user/other")));
assert!(!cfg.is_workdir_allowed(Path::new("/home/user/projects-extra")));
assert!(!cfg.is_workdir_allowed(Path::new("/home/user")));
}
}