Skip to main content

mdtask_core/
lib.rs

1//! `mdtask-core` parses a markdown task file into a typed command tree and builds
2//! runnable invocations. It is embeddable, execution-capable, and dependency-free.
3//!
4//! A task file is ordinary markdown (a `tasks.md`, a `maskfile.md`, or a project
5//! `README.md`): a heading is a task, the first fenced code block under it is the
6//! script, and `Key: value` lines in the body carry metadata. The format is its
7//! own grammar, a graceful superset that borrows xc's metadata vocabulary and
8//! mask's runtime shape (per-fence interpreter, positional args). It reads cleanly
9//! in those tools where the features overlap, but claims no compatibility.
10//!
11//! ```
12//! let tf = mdtask_core::parse("\
13//! ## greet\n\
14//! \n\
15//! Args: name\n\
16//! \n\
17//! ```sh\n\
18//! echo \"hello {{ name }}\"\n\
19//! ```\n");
20//! let task = tf.task("greet").unwrap();
21//! assert_eq!(task.args[0].name, "name");
22//! ```
23//!
24//! Parsing is pure. `Task` and `TaskFile` build an [`Invocation`] (program, args,
25//! env, cwd) that the caller runs on its own worker or thread, or executes with
26//! [`Invocation::run`]. The parser is line-based (no CommonMark dependency), so a
27//! `#` or `Key:` inside a fenced block is never mistaken for structure.
28
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32/// A parsed task file: the tasks, any file-level environment hoisted to all of
33/// them (an `Env:` under a section heading applies to **every** task regardless
34/// of where in the document it appears; hoisting is not positional), and any
35/// parse warnings (an unterminated fence, a duplicate task, an unknown fence
36/// language). Parsing is infallible. A malformed file still yields what it can,
37/// so an embedder should surface `warnings` rather than trust silence.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct TaskFile {
40    pub env: Vec<(String, String)>,
41    pub tasks: Vec<Task>,
42    pub warnings: Vec<String>,
43}
44
45/// One task: a named script with its interpreter and metadata.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct Task {
48    /// The heading text (the command name).
49    pub name: String,
50    /// Prose in the task body that is not a recognized `Key: value` line.
51    pub description: String,
52    /// The fenced block's info-string language (`sh`, `zsh`, `python`, ...); empty
53    /// means an unlabeled fence (treated as `sh`).
54    pub lang: String,
55    /// The script (the fenced block's contents), verbatim.
56    pub script: String,
57    /// `Dir:` overrides the working directory. **When absent, a task runs in
58    /// the invocation directory**, not where the task file lives, so an inherited
59    /// or global task acts on your current project. A relative value resolves
60    /// against the task file's own directory (`Dir: .` pins the task there); an
61    /// absolute value is used verbatim. It may contain `{{ arg }}`, and resolution
62    /// is purely lexical, with no filesystem access. See [`TaskFile::invocation`].
63    pub dir: Option<String>,
64    /// `Env:` adds extra environment for this task.
65    pub env: Vec<(String, String)>,
66    /// `Args:` declares positional arguments in just's syntax. A bare `name` is
67    /// required, `name='default'` is optional, and a trailing `*name` is variadic
68    /// (it collects the rest, space-joined). Each one is substituted as
69    /// `{{ name }}` in the script and also exported as `$name`. Note that
70    /// **`{{ name }}` is raw text substitution**, applied before the shell parses
71    /// the script, so `"{{ name }}"` is NOT injection-safe for untrusted values.
72    /// Prefer `"$name"`, which the shell quotes, and reserve `{{ }}` for `Dir:`
73    /// and developer-authored templates.
74    pub args: Vec<Arg>,
75    /// `Requires:` names the tasks this one depends on. mdtask-core does not run
76    /// them (execution stays the caller's), but [`dependency_order`] resolves the
77    /// transitive run order (deps first, cycle and typo detected) so a caller can
78    /// run each in turn. The mdtask CLI does exactly that.
79    pub requires: Vec<String>,
80    /// `Agent: allow` opts a task in to being listed and run by an MCP or agent
81    /// surface. It is advisory data: nothing in mdtask-core's execution path
82    /// checks it. A caller exposing tasks to an agent must filter with
83    /// [`TaskFile::agent_tasks`] (off by default), which is the enforcement point.
84    /// The flag alone enforces nothing.
85    pub agent_allow: bool,
86}
87
88/// One declared positional argument.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Arg {
91    pub name: String,
92    /// `*name`: collects all remaining positionals, space-joined.
93    pub variadic: bool,
94    /// `name='default'`: optional, with this value when not supplied.
95    pub default: Option<String>,
96}
97
98/// A runnable command built from a task: what to exec, with what environment, in
99/// which directory. The caller runs it however it likes (on a worker, on a
100/// thread), keeping execution off any hot path.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Invocation {
103    pub program: String,
104    pub args: Vec<String>,
105    pub env: Vec<(String, String)>,
106    pub cwd: PathBuf,
107}
108
109/// A declared argument had no value supplied when building an invocation.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct MissingArg(pub String);
112
113impl std::fmt::Display for MissingArg {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        write!(f, "missing value for argument `{}`", self.0)
116    }
117}
118impl std::error::Error for MissingArg {}
119
120/// A `Requires:` dependency chain could not be resolved.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum DepError {
123    /// A `Requires:` named a task that does not exist.
124    Missing { task: String, required_by: String },
125    /// A dependency cycle, reported at the task where the back edge closes.
126    Cycle(String),
127}
128
129impl std::fmt::Display for DepError {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            DepError::Missing { task, required_by } => {
133                write!(f, "task {required_by:?} requires unknown task {task:?}")
134            }
135            DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
136        }
137    }
138}
139impl std::error::Error for DepError {}
140
141/// Resolve the run order for `target` and its transitive `Requires:`: each
142/// dependency comes before the task that needs it, `target` comes last, and every
143/// task appears at most once (a diamond runs its shared dependency once). This is
144/// the sequencing mdtask-core does not do inside `invocation`; the caller supplies
145/// `requires_of`, which returns a task's declared dependency names, or `None` if
146/// the name is not a known task (so a typo in `Requires:` is a hard error, not a
147/// silent skip). Pure: no filesystem or process access.
148///
149/// The traversal is iterative (an explicit work stack, not native recursion), so a
150/// pathologically deep chain cannot overflow the call stack and abort the process.
151pub fn dependency_order(
152    target: &str,
153    requires_of: impl Fn(&str) -> Option<Vec<String>>,
154) -> Result<Vec<String>, DepError> {
155    // Each frame is a task whose dependencies we are still walking (`next` is the
156    // index of the next dependency to descend into). A post-order DFS: a frame
157    // moves to `order` only once all its dependencies are done.
158    struct Frame {
159        name: String,
160        deps: Vec<String>,
161        next: usize,
162    }
163
164    let mut order = Vec::new();
165    let mut done = std::collections::BTreeSet::new();
166    let mut on_stack = std::collections::BTreeSet::new();
167    let mut stack: Vec<Frame> = Vec::new();
168
169    let deps = requires_of(target).ok_or_else(|| DepError::Missing {
170        task: target.to_string(),
171        required_by: target.to_string(),
172    })?;
173    on_stack.insert(target.to_string());
174    stack.push(Frame {
175        name: target.to_string(),
176        deps,
177        next: 0,
178    });
179
180    loop {
181        // Decide the next move using a short-lived borrow of the top frame, so the
182        // stack is free to push/pop afterwards.
183        let descend = {
184            let Some(frame) = stack.last_mut() else { break };
185            if frame.next < frame.deps.len() {
186                let dep = frame.deps[frame.next].clone();
187                frame.next += 1;
188                Some(dep)
189            } else {
190                None
191            }
192        };
193        match descend {
194            Some(dep) => {
195                if done.contains(&dep) {
196                    continue; // already resolved via another path (a diamond)
197                }
198                if on_stack.contains(&dep) {
199                    return Err(DepError::Cycle(dep));
200                }
201                let required_by = stack.last().expect("a top frame exists").name.clone();
202                let deps = requires_of(&dep).ok_or(DepError::Missing {
203                    task: dep.clone(),
204                    required_by,
205                })?;
206                on_stack.insert(dep.clone());
207                stack.push(Frame {
208                    name: dep,
209                    deps,
210                    next: 0,
211                });
212            }
213            None => {
214                let frame = stack.pop().expect("a top frame exists");
215                on_stack.remove(&frame.name);
216                done.insert(frame.name.clone());
217                order.push(frame.name);
218            }
219        }
220    }
221    Ok(order)
222}
223
224impl Task {
225    /// The declared argument names this task interpolates into its **script** via
226    /// `{{ arg }}` (raw text substitution, applied before the shell parses the
227    /// script). Because `{{ }}` is not shell-quoted, each of these is a shell
228    /// injection point for an untrusted argument value. A surface that runs a task
229    /// with caller-controlled argument values (an agent/MCP surface) should refuse
230    /// a task that has any, and the author should switch to `"$arg"`, which the
231    /// shell quotes. Empty for a task that only uses `$arg` (the safe form) or uses
232    /// `{{ }}` solely in `Dir:` (which is not the script). See [`TaskFile::invocation`].
233    pub fn script_arg_templates(&self) -> Vec<&str> {
234        let declared: std::collections::BTreeSet<&str> =
235            self.args.iter().map(|a| a.name.as_str()).collect();
236        let mut found: Vec<&str> = Vec::new();
237        let mut rest = self.script.as_str();
238        while let Some(open) = rest.find("{{") {
239            let after = &rest[open + 2..];
240            let Some(close) = after.find("}}") else { break };
241            let tok = after[..close].trim();
242            if declared.contains(tok) && !found.contains(&tok) {
243                found.push(tok);
244            }
245            rest = &after[close + 2..];
246        }
247        found
248    }
249}
250
251impl TaskFile {
252    /// Find a task by name. The match is exact and case-sensitive, against the
253    /// heading text as written. The first definition wins if a name is duplicated
254    /// (a warning is recorded).
255    pub fn task(&self, name: &str) -> Option<&Task> {
256        self.tasks.iter().find(|t| t.name == name)
257    }
258
259    /// The tasks that opted in to an agent or MCP surface via `Agent: allow`. A
260    /// caller exposing tasks to an agent should list and run **only** these. The
261    /// flag is advisory data on `Task`, so this iterator is the enforcement point,
262    /// not the field. (Direct field access bypasses the gate by design; the gate
263    /// lives at the surface that chooses what to expose.)
264    pub fn agent_tasks(&self) -> impl Iterator<Item = &Task> {
265        self.tasks.iter().filter(|t| t.agent_allow)
266    }
267
268    /// Build the invocation for `task`, given `args` mapping each name to a value
269    /// (from [`TaskFile::bind`] or an embedder's prompts). It substitutes
270    /// `{{ arg }}` in the script and `Dir:`, exports the args and env, and resolves
271    /// the working directory this way:
272    ///
273    /// - With **no `Dir:`**, the task runs in `cwd`, the invocation directory, so
274    ///   an inherited or global task acts on your current repo rather than on the
275    ///   directory the task file happens to live in.
276    /// - A **relative `Dir:`** resolves against `task_file_dir`, the file that
277    ///   defined the task (`None` falls back to `cwd`). `Dir: .` pins the task to
278    ///   the task file's own directory, the inverse of just's `[no-cd]`.
279    /// - An **absolute `Dir:`** is used verbatim.
280    ///
281    /// Missing optional and variadic args are filled from their defaults, so a
282    /// partial `args` map is fine; only a missing *required* arg is an error.
283    ///
284    /// The call is pure and cheap, with no filesystem or process access, so it is
285    /// safe to call straight from a UI event handler or render path. Build the
286    /// `Invocation` here and run it elsewhere.
287    pub fn invocation(
288        &self,
289        task: &Task,
290        args: &BTreeMap<String, String>,
291        cwd: &Path,
292        task_file_dir: Option<&Path>,
293    ) -> Result<Invocation, MissingArg> {
294        // Fill defaults for any declared arg the caller did not supply.
295        let mut effective = args.clone();
296        for a in &task.args {
297            if !effective.contains_key(&a.name) {
298                if a.variadic {
299                    effective.insert(a.name.clone(), String::new());
300                } else if let Some(d) = &a.default {
301                    effective.insert(a.name.clone(), d.clone());
302                } else {
303                    return Err(MissingArg(a.name.clone()));
304                }
305            }
306        }
307
308        let script = substitute(&task.script, &effective);
309        let (program, flag) = interpreter(&task.lang);
310
311        // Env precedence: hoisted, then task, then args. Args win, being the most
312        // specific, so `$name` resolves to the passed value.
313        let mut env = self.env.clone();
314        env.extend(task.env.iter().cloned());
315        env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
316
317        let run_cwd = match &task.dir {
318            None => cwd.to_path_buf(),
319            Some(d) => {
320                let d = substitute(d, &effective);
321                let d = d.trim().to_string();
322                let p = Path::new(&d);
323                if p.is_absolute() {
324                    p.to_path_buf()
325                } else {
326                    task_file_dir.unwrap_or(cwd).join(&d)
327                }
328            }
329        };
330
331        Ok(Invocation {
332            program: program.to_string(),
333            args: vec![flag.to_string(), script],
334            env,
335            cwd: run_cwd,
336        })
337    }
338
339    /// Bind positional argument values (for example, from the CLI) to a task's
340    /// declared `Args:`, applying defaults and collecting a trailing `*variadic`
341    /// from the rest. This feeds [`TaskFile::invocation`] and errors on a missing
342    /// required arg.
343    pub fn bind(
344        task: &Task,
345        positional: &[String],
346    ) -> Result<BTreeMap<String, String>, MissingArg> {
347        let mut map = BTreeMap::new();
348        let mut i = 0;
349        for a in &task.args {
350            if a.variadic {
351                map.insert(
352                    a.name.clone(),
353                    positional[i.min(positional.len())..].join(" "),
354                );
355                i = positional.len();
356            } else if i < positional.len() {
357                map.insert(a.name.clone(), positional[i].clone());
358                i += 1;
359            } else if let Some(d) = &a.default {
360                map.insert(a.name.clone(), d.clone());
361            } else {
362                return Err(MissingArg(a.name.clone()));
363            }
364        }
365        Ok(map)
366    }
367}
368
369impl Invocation {
370    /// Execute the invocation and wait for it, inheriting the parent's stdio so
371    /// the task's output streams straight through. The CLI wants this, because a
372    /// task is an interactive command, not a captured subprocess. An embedder that
373    /// must not block a thread, or that wants to capture output, should build the
374    /// [`std::process::Command`] from the fields itself.
375    pub fn run(&self) -> std::io::Result<std::process::ExitStatus> {
376        std::process::Command::new(&self.program)
377            .args(&self.args)
378            .envs(self.env.iter().map(|(k, v)| (k, v)))
379            .current_dir(&self.cwd)
380            .status()
381    }
382}
383
384/// Map a fence language to `(program, code-flag)`. Unlabeled or unknown falls
385/// back to `sh -c`, so a plain ` ``` ` block runs as a shell script.
386fn interpreter(lang: &str) -> (&'static str, &'static str) {
387    match lang.trim().to_ascii_lowercase().as_str() {
388        "" | "sh" | "shell" => ("sh", "-c"),
389        "bash" => ("bash", "-c"),
390        "zsh" => ("zsh", "-c"),
391        "fish" => ("fish", "-c"),
392        "python" | "py" | "python3" => ("python3", "-c"),
393        "ruby" => ("ruby", "-e"),
394        "node" | "js" | "javascript" => ("node", "-e"),
395        _ => ("sh", "-c"),
396    }
397}
398
399/// Replace `{{ name }}` tokens (any inner whitespace) with `args[name]`. A token
400/// whose name is not in `args` is left as written, so a literal `{{x}}` that is
401/// not an argument survives.
402fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
403    let mut out = String::with_capacity(src.len());
404    let mut rest = src;
405    while let Some(open) = rest.find("{{") {
406        out.push_str(&rest[..open]);
407        let after = &rest[open + 2..];
408        if let Some(close) = after.find("}}") {
409            let name = after[..close].trim();
410            match args.get(name) {
411                Some(v) => out.push_str(v),
412                None => {
413                    // Not an argument: keep the token verbatim.
414                    out.push_str("{{");
415                    out.push_str(&after[..close]);
416                    out.push_str("}}");
417                }
418            }
419            rest = &after[close + 2..];
420        } else {
421            out.push_str("{{");
422            rest = after;
423        }
424    }
425    out.push_str(rest);
426    out
427}
428
429/// Parse a markdown task file. It is line-based (no CommonMark dependency): a
430/// heading starts a task, the first fenced block under it is the script, and
431/// `Key: value` lines set metadata. Parsing is infallible; problems are reported
432/// in [`TaskFile::warnings`] rather than dropped to silence. CRLF endings are
433/// normalized.
434pub fn parse(src: &str) -> TaskFile {
435    let mut file = TaskFile::default();
436    let mut cur: Option<Task> = None;
437    let mut in_fence = false;
438    let mut fence_marker = "";
439    let mut have_script = false; // first fence per task only
440    let mut script = String::new();
441
442    for raw in src.split('\n') {
443        let line = raw.strip_suffix('\r').unwrap_or(raw); // normalize CRLF
444        if in_fence {
445            // A fence is closed only by a BARE marker line (CommonMark): ` ``` `
446            // with an info string opens, it does not close, so a stray fence-open
447            // cannot accidentally terminate an unterminated block early.
448            if is_closing_fence(line, fence_marker) {
449                in_fence = false;
450                if let Some(t) = cur.as_mut()
451                    && !have_script
452                {
453                    t.script = std::mem::take(&mut script);
454                    have_script = true;
455                }
456                script.clear();
457            } else if cur.is_some() && !have_script {
458                script.push_str(line);
459                script.push('\n');
460            }
461            continue;
462        }
463        if let Some(marker) = opening_fence(line) {
464            in_fence = true;
465            fence_marker = marker;
466            if let Some(t) = cur.as_mut()
467                && !have_script
468            {
469                t.lang = info_string(line, marker);
470            }
471            script.clear();
472            continue;
473        }
474        if let Some(name) = heading(line) {
475            finalize(cur.take(), &mut file);
476            cur = Some(Task {
477                name,
478                ..Task::default()
479            });
480            have_script = false;
481            continue;
482        }
483        apply_line(line, cur.as_mut(), &mut file.env);
484    }
485    // An unterminated fence at EOF: still capture the script so the task is not
486    // lost, but warn, since a forgotten closing fence is a common authoring slip.
487    if in_fence {
488        if let Some(t) = cur.as_mut()
489            && !have_script
490        {
491            t.script = std::mem::take(&mut script);
492        }
493        let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
494        file.warnings
495            .push(format!("unterminated code fence in task {name:?}"));
496    }
497    finalize(cur.take(), &mut file);
498    file
499}
500
501/// Finalize a heading into the file. A heading with a script is a task; one
502/// without (a `# Tasks` section) is not, but its `Env:` hoists to all tasks.
503/// Records warnings for a duplicate name or an unknown fence language.
504fn finalize(task: Option<Task>, file: &mut TaskFile) {
505    let Some(mut t) = task else {
506        return;
507    };
508    if t.script.is_empty() {
509        file.env.append(&mut t.env); // section heading, so hoist its env
510        return;
511    }
512    t.description = t.description.trim().to_string();
513    if file.tasks.iter().any(|x| x.name == t.name) {
514        file.warnings.push(format!(
515            "duplicate task {:?}; the first defined wins",
516            t.name
517        ));
518    }
519    if !is_known_lang(&t.lang) {
520        file.warnings.push(format!(
521            "task {:?}: fenced language {:?} is not a known interpreter; running as sh",
522            t.name, t.lang
523        ));
524    }
525    file.tasks.push(t);
526}
527
528/// Whether a fence language maps to an interpreter (unlabeled counts as `sh`).
529fn is_known_lang(lang: &str) -> bool {
530    matches!(
531        lang.trim().to_ascii_lowercase().as_str(),
532        "" | "sh"
533            | "shell"
534            | "bash"
535            | "zsh"
536            | "fish"
537            | "python"
538            | "py"
539            | "python3"
540            | "ruby"
541            | "node"
542            | "js"
543            | "javascript"
544    )
545}
546
547/// The opening fence marker if `line` starts one, else `None`.
548fn opening_fence(line: &str) -> Option<&'static str> {
549    let t = line.trim_start();
550    if t.starts_with("```") {
551        Some("```")
552    } else if t.starts_with("~~~") {
553        Some("~~~")
554    } else {
555        None
556    }
557}
558
559/// Whether `line` is a bare closing fence for `marker`: only the fence char, no
560/// info string, per CommonMark's closing rule.
561fn is_closing_fence(line: &str, marker: &str) -> bool {
562    let ch = marker.as_bytes()[0];
563    let t = line.trim();
564    t.len() >= 3 && t.bytes().all(|b| b == ch)
565}
566
567/// Search for task files from `start` up to the filesystem root, **nearest
568/// first**. In each ancestor directory the first of `tasks.md`, `maskfile.md`,
569/// `README.md` that parses to at least one task is taken. The CLI layers these
570/// child-first, so a nearer file shadows a farther one by task name (like just's
571/// `set fallback`, letting a project inherit a baseline of tasks from a parent).
572/// Embedders with their own project root can ignore this and call [`parse`].
573pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
574    let mut found = Vec::new();
575    for dir in start.ancestors() {
576        for name in ["tasks.md", "maskfile.md", "README.md"] {
577            let path = dir.join(name);
578            if let Ok(src) = std::fs::read_to_string(&path) {
579                let tf = parse(&src);
580                if !tf.tasks.is_empty() {
581                    found.push((path, tf));
582                    break; // one file per directory
583                }
584            }
585        }
586    }
587    found
588}
589
590/// The info-string language after the opening fence marker.
591fn info_string(line: &str, marker: &str) -> String {
592    line.trim_start()
593        .strip_prefix(marker)
594        .unwrap_or("")
595        .split_whitespace()
596        .next()
597        .unwrap_or("")
598        .to_string()
599}
600
601/// The heading text if `line` is an ATX heading (`#`..`######`), else `None`.
602fn heading(line: &str) -> Option<String> {
603    let t = line.trim_start();
604    if !t.starts_with('#') {
605        return None;
606    }
607    let after = t.trim_start_matches('#');
608    // Must have a space after the `#` run (a real ATX heading), and not be all #.
609    if after == t || !after.starts_with(' ') {
610        return None;
611    }
612    Some(after.trim().to_string())
613}
614
615/// Apply a body line: a recognized `Key: value` sets metadata (case-insensitive
616/// key, xc vocabulary); anything else is description. `Env:` before the first
617/// task accumulates into the hoisted `file_env`.
618fn apply_line(line: &str, task: Option<&mut Task>, file_env: &mut Vec<(String, String)>) {
619    if let Some((key, value)) = split_key(line) {
620        let value = value.trim();
621        match key.as_str() {
622            "env" | "environment" => {
623                let pairs = parse_env(value);
624                match task {
625                    Some(t) => t.env.extend(pairs),
626                    None => file_env.extend(pairs), // hoisted
627                }
628                return;
629            }
630            "dir" | "directory" => {
631                if let Some(t) = task {
632                    t.dir = Some(value.to_string());
633                }
634                return;
635            }
636            "args" | "arguments" => {
637                if let Some(t) = task {
638                    t.args = parse_args(value);
639                }
640                return;
641            }
642            "requires" | "req" => {
643                if let Some(t) = task {
644                    t.requires = value
645                        .split(',')
646                        .map(|s| s.trim().to_string())
647                        .filter(|s| !s.is_empty())
648                        .collect();
649                }
650                return;
651            }
652            "agent" => {
653                if let Some(t) = task {
654                    t.agent_allow = value.eq_ignore_ascii_case("allow");
655                }
656                return;
657            }
658            _ => {}
659        }
660    }
661    // Description (only within a task; drop stray prose outside one).
662    if let Some(t) = task
663        && !line.trim().is_empty()
664    {
665        t.description.push_str(line.trim());
666        t.description.push('\n');
667    }
668}
669
670/// Split `Key: value`, returning the lowercased key if the line looks like one
671/// (a single-word key before the first colon). Leading indentation is allowed, so
672/// a `Dir:` indented under a list still counts. This is safe because only *known*
673/// keys act (see `apply_line`), so ordinary prose with a colon stays description.
674fn split_key(line: &str) -> Option<(String, &str)> {
675    let colon = line.find(':')?;
676    let key = line[..colon].trim();
677    if key.is_empty() || key.contains(char::is_whitespace) {
678        return None;
679    }
680    Some((key.to_ascii_lowercase(), &line[colon + 1..]))
681}
682
683/// Parse an `Env:` value: comma-separated `KEY=VALUE` pairs.
684fn parse_env(value: &str) -> Vec<(String, String)> {
685    value
686        .split(',')
687        .filter_map(|p| {
688            let (k, v) = p.split_once('=')?;
689            let k = k.trim();
690            if k.is_empty() {
691                return None;
692            }
693            Some((k.to_string(), v.trim().to_string()))
694        })
695        .collect()
696}
697
698/// Parse an `Args:` value into declared [`Arg`]s (just's syntax): `name` is
699/// required, `*name` collects the rest (variadic), `name='default'` (or
700/// `name="default"`) is optional. Tokens are whitespace-separated, but a quoted
701/// default may itself contain spaces (`msg='hello world'`).
702fn parse_args(value: &str) -> Vec<Arg> {
703    tokenize_args(value)
704        .into_iter()
705        .filter_map(|tok| {
706            let (name, default) = match tok.split_once('=') {
707                Some((n, d)) => (n, Some(unquote(d).to_string())),
708                None => (tok.as_str(), None),
709            };
710            let (name, variadic) = match name.strip_prefix('*') {
711                Some(rest) => (rest, true),
712                None => (name, false),
713            };
714            let name = name.trim();
715            if name.is_empty() {
716                return None;
717            }
718            Some(Arg {
719                name: name.to_string(),
720                variadic,
721                default,
722            })
723        })
724        .collect()
725}
726
727/// Split an `Args:` value on whitespace, but keep a single- or double-quoted run
728/// (a default value) together so `msg='a b'` is one token.
729fn tokenize_args(value: &str) -> Vec<String> {
730    let mut out = Vec::new();
731    let mut cur = String::new();
732    let mut quote: Option<char> = None;
733    for c in value.chars() {
734        match quote {
735            Some(q) => {
736                cur.push(c);
737                if c == q {
738                    quote = None;
739                }
740            }
741            None if c == '\'' || c == '"' => {
742                cur.push(c);
743                quote = Some(c);
744            }
745            None if c.is_whitespace() => {
746                if !cur.is_empty() {
747                    out.push(std::mem::take(&mut cur));
748                }
749            }
750            None => cur.push(c),
751        }
752    }
753    if !cur.is_empty() {
754        out.push(cur);
755    }
756    out
757}
758
759/// Strip one matching pair of surrounding single or double quotes, if present.
760fn unquote(s: &str) -> &str {
761    let s = s.trim();
762    let b = s.as_bytes();
763    if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
764        &s[1..s.len() - 1]
765    } else {
766        s
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
775        pairs
776            .iter()
777            .map(|(k, v)| (k.to_string(), v.to_string()))
778            .collect()
779    }
780
781    #[test]
782    fn parses_named_tasks_with_interpreter() {
783        let tf =
784            parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
785        assert_eq!(tf.tasks.len(), 2);
786        assert_eq!(tf.tasks[0].name, "build");
787        assert_eq!(tf.tasks[0].lang, "sh");
788        assert_eq!(tf.tasks[0].script.trim(), "cargo build");
789        assert_eq!(tf.tasks[1].lang, "zsh");
790    }
791
792    #[test]
793    fn metadata_keys_are_xc_vocabulary_case_insensitive() {
794        let tf = parse(
795            "## deploy\n\nDIR: {{ target }}\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
796        );
797        let t = &tf.tasks[0];
798        assert_eq!(t.dir.as_deref(), Some("{{ target }}"));
799        assert_eq!(
800            t.env,
801            vec![
802                ("REGION".into(), "us".into()),
803                ("TIER".into(), "prod".into())
804            ]
805        );
806        assert_eq!(
807            t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
808            ["target"]
809        );
810        assert_eq!(t.requires, vec!["build", "test"]);
811        assert!(t.agent_allow);
812    }
813
814    #[test]
815    fn agent_gate_is_off_by_default() {
816        let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
817        assert!(!tf.tasks[0].agent_allow);
818    }
819
820    #[test]
821    fn top_level_env_is_hoisted() {
822        let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
823        assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
824    }
825
826    #[test]
827    fn fence_content_is_not_parsed_as_structure() {
828        // A `## heading` and a `Key:` line inside a fence stay in the script.
829        let tf = parse("## a\n\n```sh\n## not a task\nDir: not-metadata\n```\n");
830        assert_eq!(tf.tasks.len(), 1);
831        assert!(tf.tasks[0].script.contains("## not a task"));
832        assert!(tf.tasks[0].dir.is_none());
833    }
834
835    #[test]
836    fn substitutes_args_and_leaves_unknown_tokens() {
837        let out = substitute(
838            "hello {{ name }} and {{ other }}",
839            &args(&[("name", "world")]),
840        );
841        assert_eq!(out, "hello world and {{ other }}");
842    }
843
844    #[test]
845    fn invocation_substitutes_sets_env_and_picks_interpreter() {
846        let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
847        let t = tf.task("greet").unwrap();
848        let inv = tf
849            .invocation(
850                t,
851                &args(&[("name", "sam")]),
852                Path::new("/here"),
853                Some(Path::new("/file")),
854            )
855            .unwrap();
856        assert_eq!(inv.program, "zsh");
857        assert_eq!(inv.args[0], "-c");
858        assert!(inv.args[1].contains("hi sam"));
859        assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
860        // No Dir:, so it runs in the invocation directory, not where the file lives.
861        assert_eq!(inv.cwd, Path::new("/here"));
862    }
863
864    #[test]
865    fn a_missing_required_arg_is_an_error() {
866        let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
867        let t = tf.task("t").unwrap();
868        assert_eq!(
869            tf.invocation(t, &args(&[]), Path::new("/here"), None),
870            Err(MissingArg("file".into()))
871        );
872    }
873
874    #[test]
875    fn optional_and_variadic_args_fill_from_defaults() {
876        let tf = parse(
877            "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
878        );
879        let t = tf.task("t").unwrap();
880        assert!(!t.args[0].variadic && t.args[0].default.is_none());
881        assert_eq!(t.args[1].default.as_deref(), Some("fallback"));
882        assert!(t.args[2].variadic);
883        // Only `a` supplied: `b` uses its default, `rest` is empty.
884        let inv = tf
885            .invocation(t, &args(&[("a", "x")]), Path::new("/here"), None)
886            .unwrap();
887        assert!(inv.args[1].contains("echo x fallback "));
888        // bind() collects a trailing variadic from the leftover positionals.
889        let bound =
890            TaskFile::bind(t, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
891        assert_eq!(bound.get("b").map(String::as_str), Some("y"));
892        assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
893    }
894
895    #[test]
896    fn relative_dir_resolves_against_the_task_file_not_the_cwd() {
897        let tf = parse("## t\n\nDir: sub/dir\n\n```sh\ntrue\n```\n");
898        let t = tf.task("t").unwrap();
899        let inv = tf
900            .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
901            .unwrap();
902        assert_eq!(inv.cwd, Path::new("/proj/sub/dir"));
903    }
904
905    #[test]
906    fn dir_dot_pins_to_the_task_file_dir_and_absolute_is_verbatim() {
907        let dot = parse("## t\n\nDir: .\n\n```sh\ntrue\n```\n");
908        let t = dot.task("t").unwrap();
909        let inv = dot
910            .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
911            .unwrap();
912        assert_eq!(inv.cwd, Path::new("/proj"));
913
914        let abs = parse("## t\n\nDir: /opt/build\n\n```sh\ntrue\n```\n");
915        let t = abs.task("t").unwrap();
916        let inv = abs
917            .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
918            .unwrap();
919        assert_eq!(inv.cwd, Path::new("/opt/build"));
920    }
921
922    #[test]
923    fn dir_may_interpolate_an_arg() {
924        let tf = parse("## t\n\nArgs: name\nDir: {{ name }}\n\n```sh\ntrue\n```\n");
925        let t = tf.task("t").unwrap();
926        let inv = tf
927            .invocation(
928                t,
929                &args(&[("name", "out")]),
930                Path::new("/here"),
931                Some(Path::new("/proj")),
932            )
933            .unwrap();
934        assert_eq!(inv.cwd, Path::new("/proj/out"));
935    }
936
937    // A `requires_of` for tests: a map from task name to its dependency names.
938    fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
939        move |name| {
940            map.iter()
941                .find(|(n, _)| *n == name)
942                .map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
943        }
944    }
945
946    #[test]
947    fn dependency_order_is_deps_first_target_last() {
948        // a -> b -> c, plus a -> c: c runs once, before b, and a is last.
949        let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
950        assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
951    }
952
953    #[test]
954    fn dependency_order_dedupes_a_diamond() {
955        let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
956        let order = dependency_order("a", g).unwrap();
957        assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
958        // d before b and c; a last.
959        let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
960        assert!(pos("d") < pos("b") && pos("d") < pos("c"));
961        assert_eq!(order.last().unwrap(), "a");
962    }
963
964    #[test]
965    fn dependency_order_detects_a_cycle() {
966        let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
967        assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
968    }
969
970    #[test]
971    fn dependency_order_flags_a_missing_dependency() {
972        let g = deps_of(&[("a", &["ghost"])]);
973        assert_eq!(
974            dependency_order("a", g),
975            Err(DepError::Missing {
976                task: "ghost".into(),
977                required_by: "a".into(),
978            })
979        );
980    }
981
982    #[test]
983    fn dependency_order_survives_a_pathologically_deep_chain() {
984        // t0 -> t1 -> ... -> tN. Native recursion overflowed the stack here; the
985        // iterative walk must return a full, correctly ordered chain instead.
986        const N: usize = 200_000;
987        let order = dependency_order("t0", |n| {
988            let i: usize = n.strip_prefix('t')?.parse().ok()?;
989            Some(if i + 1 < N {
990                vec![format!("t{}", i + 1)]
991            } else {
992                vec![]
993            })
994        })
995        .unwrap();
996        assert_eq!(order.len(), N);
997        assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); // deepest runs first
998        assert_eq!(order.last().unwrap(), "t0"); // target runs last
999    }
1000
1001    #[test]
1002    fn script_arg_templates_flags_only_declared_args_in_the_script() {
1003        // `name` is interpolated raw via {{ name }} (injectable); `safe` uses $safe.
1004        let tf =
1005            parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
1006        let t = tf.task("t").unwrap();
1007        assert_eq!(t.script_arg_templates(), vec!["name"]);
1008
1009        // A task that only uses $arg has no raw template interpolation.
1010        let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
1011        assert!(safe.task("t").unwrap().script_arg_templates().is_empty());
1012    }
1013
1014    #[test]
1015    fn crlf_scripts_are_normalized() {
1016        let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
1017        assert_eq!(tf.tasks[0].script, "echo foo\necho bar\n");
1018        assert!(!tf.tasks[0].script.contains('\r'));
1019    }
1020
1021    #[test]
1022    fn an_unterminated_fence_warns_but_keeps_the_task() {
1023        let tf = parse("## a\n\n```sh\necho hi\n"); // no closing fence
1024        assert_eq!(tf.tasks.len(), 1);
1025        assert_eq!(tf.tasks[0].script.trim(), "echo hi");
1026        assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
1027    }
1028
1029    #[test]
1030    fn a_stray_fence_open_does_not_close_an_unterminated_block() {
1031        // ```sh has an info string, so it opens rather than closes; only a bare
1032        // ``` closes. (The trailing block here is what closes it.)
1033        let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
1034        assert!(tf.tasks[0].script.contains("one"));
1035        assert!(tf.tasks[0].script.contains("```sh\ntwo"));
1036    }
1037
1038    #[test]
1039    fn indented_metadata_is_recognized() {
1040        let tf = parse("## a\n\n- steps:\n  Dir: sub\n\n```sh\ntrue\n```\n");
1041        assert_eq!(tf.tasks[0].dir.as_deref(), Some("sub"));
1042    }
1043
1044    #[test]
1045    fn duplicate_and_unknown_lang_warn() {
1046        let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
1047        assert_eq!(tf.tasks.len(), 2);
1048        assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1049        assert!(tf.warnings.iter().any(|w| w.contains("json")));
1050    }
1051
1052    #[test]
1053    fn agent_tasks_filters_to_the_gated_ones() {
1054        let tf =
1055            parse("## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n");
1056        let names: Vec<_> = tf.agent_tasks().map(|t| t.name.as_str()).collect();
1057        assert_eq!(names, ["open"]);
1058    }
1059
1060    #[test]
1061    fn find_task_files_layers_child_over_parent() {
1062        // parent/tasks.md defines `base` + `shared`; parent/child/tasks.md
1063        // redefines `shared` + adds `only`. Nearest-first, so child wins.
1064        let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
1065        let child = base.join("child");
1066        std::fs::create_dir_all(&child).unwrap();
1067        std::fs::write(
1068            base.join("tasks.md"),
1069            "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
1070        )
1071        .unwrap();
1072        std::fs::write(
1073            child.join("tasks.md"),
1074            "## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
1075        )
1076        .unwrap();
1077
1078        let files = find_task_files(&child);
1079        assert_eq!(files.len(), 2, "child and parent files found");
1080        // Nearest first: child then parent.
1081        assert!(files[0].0.starts_with(&child));
1082        assert_eq!(
1083            files[0].1.task("shared").unwrap().script.trim(),
1084            "echo child"
1085        );
1086        // The parent still supplies `base` as an inherited baseline.
1087        assert!(files[1].1.task("base").is_some());
1088        std::fs::remove_dir_all(&base).ok();
1089    }
1090}