Skip to main content

task_mcp/
config.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4// =============================================================================
5// Global justfile path resolution
6// =============================================================================
7
8/// Resolve the global justfile path according to the resolution order:
9/// 1. `TASK_MCP_GLOBAL_JUSTFILE` env
10/// 2. `$XDG_CONFIG_HOME/task-mcp/justfile`
11/// 3. `$HOME/.config/task-mcp/justfile`
12///
13/// Returns `None` if none of the candidates exist, emitting a warning to stderr.
14fn resolve_global_justfile_path() -> Option<PathBuf> {
15    resolve_global_justfile_path_inner(|k| env::var(k).ok())
16}
17
18/// Testable inner implementation that accepts an env lookup closure instead of
19/// reading the process environment directly.  This avoids `unsafe { set_var }`
20/// in tests, which is unsound in multi-threaded test runners.
21fn resolve_global_justfile_path_inner(
22    env_lookup: impl Fn(&str) -> Option<String>,
23) -> Option<PathBuf> {
24    // 1. Explicit override
25    if let Some(p) = env_lookup("TASK_MCP_GLOBAL_JUSTFILE") {
26        let path = PathBuf::from(p);
27        if path.exists() {
28            return Some(path);
29        }
30        eprintln!(
31            "task-mcp: TASK_MCP_GLOBAL_JUSTFILE={} does not exist; ignoring",
32            path.display()
33        );
34    }
35
36    // 2. XDG_CONFIG_HOME
37    if let Some(xdg) = env_lookup("XDG_CONFIG_HOME") {
38        let path = PathBuf::from(xdg).join("task-mcp").join("justfile");
39        if path.exists() {
40            return Some(path);
41        }
42    }
43
44    // 3. $HOME/.config/task-mcp/justfile
45    if let Some(home) = env_lookup("HOME") {
46        let path = PathBuf::from(home)
47            .join(".config")
48            .join("task-mcp")
49            .join("justfile");
50        if path.exists() {
51            return Some(path);
52        }
53    }
54
55    eprintln!(
56        "task-mcp: TASK_MCP_LOAD_GLOBAL=true but no global justfile found; continuing without global recipes"
57    );
58    None
59}
60
61// =============================================================================
62// Task mode
63// =============================================================================
64
65/// Controls which recipes are exposed via MCP.
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
67pub enum TaskMode {
68    /// Only recipes tagged with `[allow-agent]` (group attribute or comment tag).
69    #[default]
70    AgentOnly,
71    /// All non-private recipes.
72    All,
73}
74
75impl TaskMode {
76    /// Parse from the `TASK_MCP_MODE` environment variable value.
77    fn from_env_value(val: &str) -> Self {
78        match val.trim().to_lowercase().as_str() {
79            "all" => Self::All,
80            _ => Self::AgentOnly,
81        }
82    }
83}
84
85// =============================================================================
86// Config
87// =============================================================================
88
89/// Runtime configuration for task-mcp.
90#[derive(Debug, Clone, Default)]
91pub struct Config {
92    /// Filtering mode for exposed recipes.
93    pub mode: TaskMode,
94    /// Path to the justfile (relative or absolute).
95    pub justfile_path: Option<String>,
96    /// Allowed working directories for session_start.
97    /// Empty means no restriction (all directories allowed).
98    pub allowed_dirs: Vec<PathBuf>,
99    /// Path to a custom justfile template used by the `init` tool.
100    pub init_template_file: Option<String>,
101    /// When `true`, global justfile recipes are merged with project recipes.
102    pub load_global: bool,
103    /// Resolved absolute path to the global justfile (only set when `load_global=true` and file exists).
104    pub global_justfile_path: Option<PathBuf>,
105}
106
107impl Config {
108    /// Load configuration from `.task-mcp.env` (if present) and environment variables.
109    ///
110    /// Priority: env var > `.task-mcp.env` > default.
111    pub fn load() -> Self {
112        // Load .task-mcp.env if it exists; ignore errors (file may not be present).
113        let _ = dotenvy::from_filename(".task-mcp.env");
114
115        let mode = env::var("TASK_MCP_MODE")
116            .map(|v| TaskMode::from_env_value(&v))
117            .unwrap_or_default();
118
119        let justfile_path = env::var("TASK_MCP_JUSTFILE").ok();
120
121        let allowed_dirs = env::var("TASK_MCP_ALLOWED_DIRS")
122            .map(|v| parse_allowed_dirs(&v))
123            .unwrap_or_default();
124
125        let init_template_file = env::var("TASK_MCP_INIT_TEMPLATE_FILE").ok();
126
127        let load_global = env::var("TASK_MCP_LOAD_GLOBAL")
128            .map(|v| matches!(v.trim().to_lowercase().as_str(), "true" | "1"))
129            .unwrap_or(false);
130
131        let global_justfile_path = if load_global {
132            resolve_global_justfile_path()
133        } else {
134            None
135        };
136
137        Self {
138            mode,
139            justfile_path,
140            allowed_dirs,
141            init_template_file,
142            load_global,
143            global_justfile_path,
144        }
145    }
146
147    /// Check whether `workdir` is permitted.
148    ///
149    /// Returns `true` when `allowed_dirs` is empty (no restriction) or when
150    /// `workdir` starts with at least one entry in `allowed_dirs`.
151    pub fn is_workdir_allowed(&self, workdir: &Path) -> bool {
152        if self.allowed_dirs.is_empty() {
153            return true;
154        }
155        self.allowed_dirs.iter().any(|d| workdir.starts_with(d))
156    }
157}
158
159/// Parse `TASK_MCP_ALLOWED_DIRS` value into canonicalized `PathBuf`s.
160///
161/// Comma-separated entries are canonicalized. Entries that fail canonicalization
162/// (e.g. directory does not exist) are skipped with a warning to stderr.
163fn parse_allowed_dirs(raw: &str) -> Vec<PathBuf> {
164    raw.split(',')
165        .map(str::trim)
166        .filter(|s| !s.is_empty())
167        .filter_map(|entry| match std::fs::canonicalize(entry) {
168            Ok(p) => Some(p),
169            Err(e) => {
170                eprintln!("task-mcp: TASK_MCP_ALLOWED_DIRS: skipping {entry:?}: {e}");
171                None
172            }
173        })
174        .collect()
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn task_mode_default_is_agent_only() {
183        assert_eq!(TaskMode::default(), TaskMode::AgentOnly);
184    }
185
186    #[test]
187    fn task_mode_from_env_value_all() {
188        assert_eq!(TaskMode::from_env_value("all"), TaskMode::All);
189        assert_eq!(TaskMode::from_env_value("ALL"), TaskMode::All);
190        assert_eq!(TaskMode::from_env_value("All"), TaskMode::All);
191    }
192
193    #[test]
194    fn task_mode_from_env_value_agent_only() {
195        assert_eq!(TaskMode::from_env_value("agent-only"), TaskMode::AgentOnly);
196        assert_eq!(TaskMode::from_env_value("unknown"), TaskMode::AgentOnly);
197        assert_eq!(TaskMode::from_env_value(""), TaskMode::AgentOnly);
198    }
199
200    #[test]
201    fn config_default() {
202        let cfg = Config::default();
203        assert_eq!(cfg.mode, TaskMode::AgentOnly);
204        assert!(cfg.justfile_path.is_none());
205        assert!(cfg.allowed_dirs.is_empty());
206        assert!(!cfg.load_global);
207        assert!(cfg.global_justfile_path.is_none());
208    }
209
210    #[test]
211    fn load_global_default_false() {
212        // When TASK_MCP_LOAD_GLOBAL is not set, load_global must be false.
213        // We test the flag parse logic directly (can't safely mutate env in parallel tests).
214        let parse = |val: &str| matches!(val.trim().to_lowercase().as_str(), "true" | "1");
215        assert!(!parse("false"));
216        assert!(!parse("0"));
217        assert!(!parse(""));
218        assert!(parse("true"));
219        assert!(parse("True"));
220        assert!(parse("TRUE"));
221        assert!(parse("1"));
222    }
223
224    #[test]
225    fn resolve_global_justfile_path_respects_explicit_env() {
226        use std::io::Write;
227        // Create a temp file to act as the "global justfile".
228        let tmp = tempfile::NamedTempFile::new().expect("temp file");
229        writeln!(tmp.as_file(), "# test").unwrap();
230        let path = tmp.path().to_path_buf();
231        let path_str = path.to_string_lossy().into_owned();
232
233        // Pure test: inject the path via closure — no unsafe set_var needed.
234        let result = resolve_global_justfile_path_inner(|k| {
235            if k == "TASK_MCP_GLOBAL_JUSTFILE" {
236                Some(path_str.clone())
237            } else {
238                None
239            }
240        });
241
242        assert_eq!(result, Some(path));
243    }
244
245    #[test]
246    fn resolve_global_justfile_path_nonexistent_returns_none() {
247        // All env vars point to non-existent paths — result must be None.
248        let result = resolve_global_justfile_path_inner(|k| match k {
249            "TASK_MCP_GLOBAL_JUSTFILE" => {
250                Some("/nonexistent/path/that/does/not/exist/justfile".to_string())
251            }
252            "XDG_CONFIG_HOME" => Some("/nonexistent/xdg".to_string()),
253            "HOME" => Some("/nonexistent/home".to_string()),
254            _ => None,
255        });
256
257        assert!(result.is_none());
258    }
259
260    #[test]
261    fn is_workdir_allowed_empty_allows_all() {
262        let cfg = Config {
263            allowed_dirs: vec![],
264            ..Config::default()
265        };
266        assert!(cfg.is_workdir_allowed(Path::new("/any/path")));
267        assert!(cfg.is_workdir_allowed(Path::new("/")));
268    }
269
270    #[test]
271    fn is_workdir_allowed_match() {
272        let cfg = Config {
273            allowed_dirs: vec![PathBuf::from("/home/user/projects")],
274            ..Config::default()
275        };
276        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects")));
277        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects/foo")));
278        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects/foo/bar")));
279    }
280
281    #[test]
282    fn is_workdir_allowed_no_match() {
283        let cfg = Config {
284            allowed_dirs: vec![PathBuf::from("/home/user/projects")],
285            ..Config::default()
286        };
287        // Sibling directory — not under allowed path
288        assert!(!cfg.is_workdir_allowed(Path::new("/home/user/other")));
289        // Path prefix match but not a directory boundary
290        assert!(!cfg.is_workdir_allowed(Path::new("/home/user/projects-extra")));
291        assert!(!cfg.is_workdir_allowed(Path::new("/home/user")));
292    }
293}