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