1use std::env;
2use std::path::{Path, PathBuf};
3
4fn resolve_global_justfile_path() -> Option<PathBuf> {
15 resolve_global_justfile_path_inner(|k| env::var(k).ok())
16}
17
18fn resolve_global_justfile_path_inner(
22 env_lookup: impl Fn(&str) -> Option<String>,
23) -> Option<PathBuf> {
24 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
67pub enum TaskMode {
68 #[default]
70 AgentOnly,
71 All,
73}
74
75impl TaskMode {
76 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#[derive(Debug, Clone, Default)]
91pub struct Config {
92 pub mode: TaskMode,
94 pub justfile_path: Option<String>,
96 pub allowed_dirs: Vec<PathBuf>,
99 pub init_template_file: Option<String>,
101 pub load_global: bool,
103 pub global_justfile_path: Option<PathBuf>,
105}
106
107impl Config {
108 pub fn load() -> Self {
112 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 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
159fn 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 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 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 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 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 assert!(!cfg.is_workdir_allowed(Path::new("/home/user/other")));
289 assert!(!cfg.is_workdir_allowed(Path::new("/home/user/projects-extra")));
291 assert!(!cfg.is_workdir_allowed(Path::new("/home/user")));
292 }
293}