Skip to main content

mdtask_core/
parse.rs

1use crate::model::{Arg, Job, KNOWN_FILE_OPTS, KNOWN_OPTS, Requirement, TaskFile};
2use crate::run::is_known_lang;
3
4/// Parse a markdown task file. It is line-based (no CommonMark dependency): a
5/// heading starts a job, the first fenced block under it is the script, and
6/// `Key: value` lines set metadata. Parsing is infallible; problems are reported
7/// in [`TaskFile::warnings`] rather than dropped to silence. CRLF endings are
8/// normalized.
9pub fn parse(src: &str) -> TaskFile {
10    let mut file = TaskFile::default();
11    let mut cur: Option<Job> = None;
12    let mut in_fence = false;
13    let mut fence_marker = Fence { ch: b'`', len: 3 };
14    let mut have_script = false; // first fence per job only
15    let mut script = String::new();
16    // Whether a `Key: value` line here is metadata or just a sentence that
17    // happens to start that way. See `apply_line`.
18    let mut block_start = true;
19
20    for raw in src.split('\n') {
21        let line = raw.strip_suffix('\r').unwrap_or(raw); // normalize CRLF
22        if in_fence {
23            // A fence is closed only by a BARE marker line (CommonMark): ` ``` `
24            // with an info string opens, it does not close, so a stray fence-open
25            // cannot accidentally terminate an unterminated block early.
26            if is_closing_fence(line, fence_marker) {
27                in_fence = false;
28                block_start = true; // a fence is a block boundary
29                if let Some(t) = cur.as_mut()
30                    && !have_script
31                {
32                    t.script = std::mem::take(&mut script);
33                    have_script = true;
34                }
35                script.clear();
36            } else if cur.is_some() && !have_script {
37                script.push_str(line);
38                script.push('\n');
39            }
40            continue;
41        }
42        if let Some(marker) = opening_fence(line) {
43            in_fence = true;
44            block_start = true;
45            fence_marker = marker;
46            if let Some(t) = cur.as_mut()
47                && !have_script
48            {
49                t.lang = info_string(line, marker);
50            }
51            script.clear();
52            continue;
53        }
54        if let Some(name) = heading(line) {
55            finalize(cur.take(), &mut file);
56            cur = Some(Job {
57                name,
58                ..Job::default()
59            });
60            have_script = false;
61            block_start = true;
62            continue;
63        }
64        if line.trim().is_empty() {
65            block_start = true;
66            // Keep the paragraph break. Descriptions used to be a run of lines
67            // with every blank dropped, so nothing downstream could tell where
68            // the opening thought ended: a listing had no first paragraph to
69            // show, only a first hard-wrapped line, which is a fragment.
70            if let Some(t) = cur.as_mut()
71                && !t.description.is_empty()
72                && !t.description.ends_with("\n\n")
73            {
74                t.description.push('\n');
75            }
76            continue;
77        }
78        let was_meta = apply_line(
79            line,
80            cur.as_mut(),
81            &mut file.env,
82            &mut file.opts,
83            &mut file.warnings,
84            block_start,
85        );
86        // A metadata line does not end the run, so `Args:` and `Requires:` can
87        // sit together. Nor does a list item, which opens a block of its own and
88        // is how an indented `Env:` under a bullet stays reachable. An ordinary
89        // sentence does end it: what follows a sentence is its continuation.
90        block_start = was_meta || opens_list_item(line);
91    }
92    // An unterminated fence at EOF: still capture the script so the job is not
93    // lost, but warn, since a forgotten closing fence is a common authoring slip.
94    if in_fence {
95        if let Some(t) = cur.as_mut()
96            && !have_script
97        {
98            t.script = std::mem::take(&mut script);
99        }
100        let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
101        file.warnings
102            .push(format!("unterminated code fence in task {name:?}"));
103    }
104    finalize(cur.take(), &mut file);
105    file
106}
107
108/// Finalize a heading into the file. A heading with a script is a job; one without
109/// (a `# Tasks` section) is not, but its `Env:` hoists to all jobs. Records
110/// warnings for a duplicate name or an unknown fence language.
111fn finalize(job: Option<Job>, file: &mut TaskFile) {
112    let Some(mut t) = job else {
113        return;
114    };
115    if t.script.is_empty() {
116        file.env.append(&mut t.env); // section heading, so hoist its env
117        return;
118    }
119    t.description = t.description.trim().to_string();
120    if file.jobs.iter().any(|x| x.name == t.name) {
121        file.warnings.push(format!(
122            "duplicate task {:?}; the first defined wins",
123            t.name
124        ));
125    }
126    if !is_known_lang(&t.lang) {
127        file.warnings.push(format!(
128            "task {:?}: fenced language {:?} is not a known interpreter; \
129             running as a strict sh script",
130            t.name, t.lang
131        ));
132    }
133    file.jobs.push(t);
134}
135
136/// An open fence: which character, and how many of them.
137///
138/// The length matters. CommonMark closes a fence only with a run of *at least
139/// as many* of the same character, which is how a markdown block can contain
140/// markdown blocks: fence the outer one with four backticks and the inner
141/// three-backtick fences are ordinary content. Discarding the length made every
142/// fence three long, so an inner ``` closed the outer block early. mdtask's own
143/// README, whose opening example is markdown inside markdown, parsed as a task
144/// named "mdtask" whose script was the first half of the sample and whose
145/// language was "`markdown".
146#[derive(Clone, Copy, PartialEq, Eq)]
147struct Fence {
148    ch: u8,
149    len: usize,
150}
151
152/// The opening fence if `line` starts one, else `None`.
153fn opening_fence(line: &str) -> Option<Fence> {
154    let t = line.trim_start();
155    let ch = match t.as_bytes().first() {
156        Some(b'`') => b'`',
157        Some(b'~') => b'~',
158        _ => return None,
159    };
160    let len = t.bytes().take_while(|b| *b == ch).count();
161    if len < 3 {
162        return None;
163    }
164    // A backtick fence's info string may not itself contain a backtick, which is
165    // what keeps inline code (`` `x` ``) from opening a block.
166    if ch == b'`' && t[len..].contains('`') {
167        return None;
168    }
169    Some(Fence { ch, len })
170}
171
172/// Whether `line` closes `fence`: only the fence character, at least as many of
173/// them as opened it, and no info string, per CommonMark's closing rule.
174fn is_closing_fence(line: &str, fence: Fence) -> bool {
175    let t = line.trim();
176    t.len() >= fence.len && t.bytes().all(|b| b == fence.ch)
177}
178
179/// Whether `line` opens a markdown list item (`-`, `*`, `+`, or `1.` / `1)`).
180///
181/// Such a line begins a block, so metadata indented beneath it is still
182/// metadata. Without this, documenting a task as a bulleted list and hanging an
183/// `Env:` off one of the bullets would quietly stop working.
184fn opens_list_item(line: &str) -> bool {
185    let t = line.trim_start();
186    if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
187        return rest.starts_with(' ') || rest.is_empty();
188    }
189    let digits = t.trim_start_matches(|c: char| c.is_ascii_digit());
190    if digits.len() < t.len()
191        && let Some(rest) = digits.strip_prefix(['.', ')'])
192    {
193        return rest.starts_with(' ') || rest.is_empty();
194    }
195    false
196}
197
198/// The info-string language after the opening fence.
199fn info_string(line: &str, fence: Fence) -> String {
200    let t = line.trim_start();
201    t[fence.len.min(t.len())..]
202        .split_whitespace()
203        .next()
204        .unwrap_or("")
205        .to_string()
206}
207
208/// The heading text if `line` is an ATX heading (`#`..`######`), else `None`.
209fn heading(line: &str) -> Option<String> {
210    let t = line.trim_start();
211    if !t.starts_with('#') {
212        return None;
213    }
214    let after = t.trim_start_matches('#');
215    // Must have a space after the `#` run (a real ATX heading), and not be all #.
216    if after == t || !after.starts_with(' ') {
217        return None;
218    }
219    Some(after.trim().to_string())
220}
221
222/// Apply a body line: a recognized `Key: value` sets metadata (case-insensitive
223/// key, xc vocabulary); anything else is description. `Env:` before the first job
224/// Parse a `Requires:` value into requirements.
225///
226/// Comma-separated. A bare entry is a name with no arguments; an entry wrapped
227/// in parentheses is a name followed by whitespace-separated arguments, which is
228/// just's `(dist module)` shape. Splitting on commas first is what makes the
229/// parenthesised form unambiguous: whitespace can mean "next argument" precisely
230/// because it never had to mean "next dependency".
231/// Split a `Requires:` value into its entries, on commas that are not inside a
232/// parenthesised entry. `(deploy a, b)` is one entry with two arguments, not two
233/// entries, which is why this is not `value.split(',')`.
234fn split_entries(value: &str) -> Vec<&str> {
235    let mut out = Vec::new();
236    let mut depth = 0usize;
237    let mut start = 0usize;
238    for (i, c) in value.char_indices() {
239        match c {
240            '(' => depth += 1,
241            ')' => depth = depth.saturating_sub(1),
242            ',' if depth == 0 => {
243                out.push(&value[start..i]);
244                start = i + 1;
245            }
246            _ => {}
247        }
248    }
249    out.push(&value[start..]);
250    out
251}
252
253/// Split the inside of a parenthesised entry into a name and its arguments.
254///
255/// Whitespace or a comma separates. A comma already means "next thing" at the
256/// entry level, so `(deploy a, b)` reading as two arguments is what anyone will
257/// expect; a literal comma needs quoting.
258///
259/// Two things are held together across a separator: a `{{ ... }}` placeholder
260/// (one token however it is spaced, so `{{ module }}` stays a placeholder rather
261/// than becoming three arguments), and a double-quoted run (so an argument may
262/// contain a space or a comma at all).
263fn split_args(inner: &str) -> Vec<String> {
264    let mut out: Vec<String> = Vec::new();
265    let mut cur = String::new();
266    let mut has = false;
267    let mut rest = inner;
268
269    while let Some(c) = rest.chars().next() {
270        if c.is_whitespace() || c == ',' {
271            if has {
272                out.push(std::mem::take(&mut cur));
273                has = false;
274            }
275            rest = &rest[c.len_utf8()..];
276        } else if rest.starts_with("{{") {
277            // Verbatim through the closing braces, so `substitute` sees an
278            // intact placeholder later. Unterminated, it is just literal text.
279            let end = rest.find("}}").map_or(rest.len(), |i| i + 2);
280            cur.push_str(&rest[..end]);
281            has = true;
282            rest = &rest[end..];
283        } else if c == '"' {
284            let body = &rest[1..];
285            let end = body.find('"');
286            cur.push_str(end.map_or(body, |i| &body[..i]));
287            has = true;
288            rest = end.map_or("", |i| &body[i + 1..]);
289        } else {
290            cur.push(c);
291            has = true;
292            rest = &rest[c.len_utf8()..];
293        }
294    }
295    if has {
296        out.push(cur);
297    }
298    out
299}
300
301fn parse_requires(value: &str) -> Vec<Requirement> {
302    split_entries(value)
303        .into_iter()
304        .map(str::trim)
305        .filter(|s| !s.is_empty())
306        .map(|entry| {
307            match entry
308                .strip_prefix('(')
309                .and_then(|rest| rest.strip_suffix(')'))
310            {
311                Some(inner) => {
312                    let mut parts = split_args(inner).into_iter();
313                    Requirement {
314                        name: parts.next().unwrap_or_default(),
315                        args: parts.collect(),
316                    }
317                }
318                None => Requirement {
319                    name: entry.to_string(),
320                    args: Vec::new(),
321                },
322            }
323        })
324        .filter(|r: &Requirement| !r.name.is_empty())
325        .collect()
326}
327
328/// The metadata keys, and the aliases each accepts.
329const KEYS: &[&str] = &[
330    "env",
331    "environment",
332    "opts",
333    "options",
334    "args",
335    "arguments",
336    "requires",
337    "req",
338    "agent",
339];
340
341/// The key `word` was probably meant to be, if it is close enough to one.
342///
343/// Deliberately narrow: a singular/plural slip or one wrong character. Anything
344/// looser would start warning about ordinary prose, which is the thing this
345/// format has to live alongside.
346fn nearest_key(word: &str) -> Option<&'static str> {
347    KEYS.iter()
348        .copied()
349        .find(|k| {
350            // "arg" vs "args", "require" vs "requires", "opt" vs "opts".
351            k.strip_suffix('s') == Some(word)
352                || word.strip_suffix('s') == Some(*k)
353                || k.starts_with(word) && k.len() == word.len() + 1
354        })
355        .or_else(|| KEYS.iter().copied().find(|k| edit_distance_one(k, word)))
356}
357
358/// Whether two words differ by exactly one substitution. Cheap, and enough for
359/// the typos that actually happen in a metadata key.
360fn edit_distance_one(a: &str, b: &str) -> bool {
361    if a.len() != b.len() || a == b {
362        return false;
363    }
364    a.bytes().zip(b.bytes()).filter(|(x, y)| x != y).count() == 1
365}
366
367/// accumulates into the hoisted `file_env`.
368///
369/// `block_start` is whether this line begins a block: it follows the heading, a
370/// blank line, a fence, or another metadata line. Metadata is only recognized
371/// there, because otherwise a wrapped sentence decides the task's configuration.
372/// This was reachable:
373///
374/// ```markdown
375/// The reviewer decides whether to set
376/// Agent: allow
377/// on a task, which should be rare.
378/// ```
379///
380/// which read as prose to every human and as *opt this task in to agent
381/// execution* to the parser. Requiring a block boundary costs nothing, because
382/// every real task file already writes its metadata on its own lines, and it
383/// makes the security-relevant key unreachable from inside a paragraph.
384///
385/// Returns whether the line was consumed as metadata, which is how the caller
386/// keeps a run of `Args:` / `Requires:` lines together.
387fn apply_line(
388    line: &str,
389    job: Option<&mut Job>,
390    file_env: &mut Vec<(String, String)>,
391    file_opts: &mut Vec<String>,
392    warnings: &mut Vec<String>,
393    block_start: bool,
394) -> bool {
395    if let Some((key, value)) = split_key(line) {
396        // A recognized key mid-paragraph is prose. Say so rather than silently
397        // dropping it: if it really was meant as metadata, the author needs to
398        // know it did nothing.
399        if !block_start && KEYS.contains(&key.as_str()) {
400            warnings.push(format!(
401                "`{key}:` is inside a paragraph, so it was read as description, \
402                 not metadata; put it on its own line after a blank one"
403            ));
404            describe(line, job);
405            return false;
406        }
407        let value = value.trim();
408        match key.as_str() {
409            "env" | "environment" => {
410                let pairs = parse_env(value, warnings);
411                match job {
412                    Some(t) => t.env.extend(pairs),
413                    None => file_env.extend(pairs), // hoisted
414                }
415                return true;
416            }
417            "opts" | "options" => {
418                let Some(t) = job else {
419                    // Before the first heading: a file-level option, which is a
420                    // different vocabulary from a task's.
421                    for flag in value.split_whitespace() {
422                        if KNOWN_FILE_OPTS.contains(&flag) {
423                            file_opts.push(flag.to_string());
424                        } else {
425                            warnings.push(format!(
426                                "unknown file-level option {flag:?} in `Opts:` (known: {}); \
427                                 a task option belongs under a task heading",
428                                KNOWN_FILE_OPTS.join(", ")
429                            ));
430                        }
431                    }
432                    return true;
433                };
434                // Extend, not assign. Assigning meant a second `Opts:` line
435                // silently erased the first, and a second `Requires:` line
436                // silently dropped a dependency while still exiting 0.
437                t.opts.extend(value.split_whitespace().map(str::to_string));
438                for flag in &t.opts {
439                    if !KNOWN_OPTS.contains(&flag.as_str()) {
440                        let hint = if KNOWN_FILE_OPTS.contains(&flag.as_str()) {
441                            "; that one is file-level, so it goes before the first task heading"
442                        } else {
443                            ""
444                        };
445                        warnings.push(format!(
446                            "unknown option {flag:?} in `Opts:` (known: {}){hint}",
447                            KNOWN_OPTS.join(", ")
448                        ));
449                    }
450                }
451                return true;
452            }
453            "args" | "arguments" => {
454                if let Some(t) = job {
455                    t.args.extend(parse_args(value));
456                }
457                return true;
458            }
459            "requires" | "req" => {
460                if let Some(t) = job {
461                    t.requires.extend(parse_requires(value));
462                }
463                return true;
464            }
465            "agent" => {
466                if let Some(t) = job {
467                    t.agent_allow = value.eq_ignore_ascii_case("allow");
468                }
469                return true;
470            }
471            other => {
472                // A near-miss of a real key is a typo, not prose. `Arg:`,
473                // `Require:` and `Opt:` all used to vanish into the description
474                // with no warning, so a declared dependency never ran and a
475                // declared argument never existed, silently and with exit 0.
476                //
477                // Only near-misses warn. An ordinary sentence starting "Note:"
478                // must stay description, or the format cannot coexist with the
479                // prose it is written in.
480                if let Some(meant) = nearest_key(other) {
481                    warnings.push(format!(
482                        "unknown metadata key {other:?}; did you mean {meant:?}? \
483                         (treating the line as description)"
484                    ));
485                }
486            }
487        }
488    }
489    describe(line, job);
490    false
491}
492
493/// Append a line to the job's description. Stray prose outside a job is dropped.
494fn describe(line: &str, job: Option<&mut Job>) {
495    if let Some(t) = job
496        && !line.trim().is_empty()
497    {
498        t.description.push_str(line.trim());
499        t.description.push('\n');
500    }
501}
502
503/// Split `Key: value`, returning the lowercased key if the line looks like one
504/// (a single-word key before the first colon). Leading indentation is allowed, so
505/// an `Env:` indented under a list still counts. This is safe because only *known*
506/// keys act (see `apply_line`), so ordinary prose with a colon stays description.
507fn split_key(line: &str) -> Option<(String, &str)> {
508    let colon = line.find(':')?;
509    let key = line[..colon].trim();
510    if key.is_empty() || key.contains(char::is_whitespace) {
511        return None;
512    }
513    Some((key.to_ascii_lowercase(), &line[colon + 1..]))
514}
515
516/// Parse an `Env:` value into `KEY=VALUE` pairs, comma-separated.
517///
518/// A value may be quoted, which is the only way to write one containing a
519/// comma. Without it, `Env: FLAGS=-a,-b` set `FLAGS` to `-a` and threw `-b`
520/// away without a word, because the fragment had no `=` and the old parser
521/// dropped anything that did not. Silently corrupting a value is worse than
522/// refusing it, so a fragment that cannot be a pair now warns.
523fn parse_env(value: &str, warnings: &mut Vec<String>) -> Vec<(String, String)> {
524    let mut out = Vec::new();
525    for entry in split_pairs(value) {
526        let entry = entry.trim();
527        if entry.is_empty() {
528            continue;
529        }
530        let Some((k, v)) = entry.split_once('=') else {
531            warnings.push(format!(
532                "`Env:` entry {entry:?} has no `=`, so it was ignored; entries are \
533                 comma-separated KEY=VALUE, and a value containing a comma has to be \
534                 quoted (KEY=\"a,b\")"
535            ));
536            continue;
537        };
538        let k = k.trim();
539        if k.is_empty() {
540            warnings.push(format!(
541                "`Env:` entry {entry:?} has an empty key, so it was ignored"
542            ));
543            continue;
544        }
545        out.push((k.to_string(), unquote(v).to_string()));
546    }
547    out
548}
549
550/// Split an `Env:` value on the commas that separate pairs, ignoring any inside
551/// a quoted run.
552fn split_pairs(value: &str) -> Vec<&str> {
553    let mut out = Vec::new();
554    let mut quote: Option<char> = None;
555    let mut start = 0usize;
556    for (i, c) in value.char_indices() {
557        match (quote, c) {
558            (None, '\'' | '"') => quote = Some(c),
559            (Some(q), c) if c == q => quote = None,
560            (None, ',') => {
561                out.push(&value[start..i]);
562                start = i + 1;
563            }
564            _ => {}
565        }
566    }
567    out.push(&value[start..]);
568    out
569}
570
571/// Parse an `Args:` value into declared [`Arg`]s (just's syntax): `name` is
572/// required, `*name` collects the rest (variadic), `name='default'` (or
573/// `name="default"`) is optional. Tokens are whitespace-separated, but a quoted
574/// default may itself contain spaces (`msg='hello world'`).
575fn parse_args(value: &str) -> Vec<Arg> {
576    tokenize_args(value)
577        .into_iter()
578        .filter_map(|tok| {
579            let (name, default) = match tok.split_once('=') {
580                Some((n, d)) => (n, Some(unquote(d).to_string())),
581                None => (tok.as_str(), None),
582            };
583            let (name, variadic) = match name.strip_prefix('*') {
584                Some(rest) => (rest, true),
585                None => (name, false),
586            };
587            let name = name.trim();
588            if name.is_empty() {
589                return None;
590            }
591            Some(Arg {
592                name: name.to_string(),
593                variadic,
594                default,
595            })
596        })
597        .collect()
598}
599
600/// Split an `Args:` value on whitespace, but keep a single- or double-quoted run
601/// (a default value) together so `msg='a b'` is one token.
602fn tokenize_args(value: &str) -> Vec<String> {
603    let mut out = Vec::new();
604    let mut cur = String::new();
605    let mut quote: Option<char> = None;
606    for c in value.chars() {
607        match quote {
608            Some(q) => {
609                cur.push(c);
610                if c == q {
611                    quote = None;
612                }
613            }
614            None if c == '\'' || c == '"' => {
615                cur.push(c);
616                quote = Some(c);
617            }
618            None if c.is_whitespace() => {
619                if !cur.is_empty() {
620                    out.push(std::mem::take(&mut cur));
621                }
622            }
623            None => cur.push(c),
624        }
625    }
626    if !cur.is_empty() {
627        out.push(cur);
628    }
629    out
630}
631
632/// Strip one matching pair of surrounding single or double quotes, if present.
633fn unquote(s: &str) -> &str {
634    let s = s.trim();
635    let b = s.as_bytes();
636    if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
637        &s[1..s.len() - 1]
638    } else {
639        s
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    /// CommonMark closes a fence only with a run of *at least as many* of the
648    /// same character. That is how a markdown block contains markdown blocks,
649    /// and it is what mdtask's own README needs: fence the sample with four
650    /// backticks and the inner three-backtick fences are content.
651    ///
652    /// Before this, every fence was three long, so the inner bare ``` closed the
653    /// outer block: the README parsed as a task named "mdtask" whose script was
654    /// half the sample and whose language was "`markdown".
655    #[test]
656    fn a_longer_fence_is_not_closed_by_a_shorter_one() {
657        let tf = parse("## t\n\n````markdown\n# inner\n\n```sh\necho nested\n```\n````\n");
658        assert_eq!(tf.jobs[0].lang, "markdown", "not \"`markdown\"");
659        let script = &tf.jobs[0].script;
660        assert!(
661            script.contains("```sh"),
662            "the inner fence is content: {script:?}"
663        );
664        assert!(script.contains("echo nested"), "{script:?}");
665        assert!(
666            !script.contains("````"),
667            "and the outer one is not: {script:?}"
668        );
669    }
670
671    #[test]
672    fn a_shorter_fence_is_still_closed_normally() {
673        let tf = parse("## t\n\n```sh\ntrue\n````\n");
674        assert_eq!(tf.jobs[0].script.trim(), "true", "a longer run closes it");
675        assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
676    }
677
678    #[test]
679    fn tildes_track_their_own_length() {
680        let tf = parse("## t\n\n~~~~sh\n~~~\nstill inside\n~~~~\n");
681        assert!(tf.jobs[0].script.contains("still inside"));
682    }
683
684    /// A backtick fence's info string may not contain a backtick, which is what
685    /// keeps a line of inline code from opening a block.
686    #[test]
687    fn inline_code_does_not_open_a_fence() {
688        assert!(opening_fence("```rust").is_some());
689        assert!(opening_fence("``` rust ```").is_none());
690        assert!(opening_fence("``").is_none(), "two is not a fence");
691        // Tildes have no such restriction in CommonMark.
692        assert!(opening_fence("~~~ a ~ b").is_some());
693    }
694
695    /// The silent corruption this replaced: `FLAGS=-a,-b` set `FLAGS` to `-a`
696    /// and threw `-b` away without a word, because the fragment had no `=` and
697    /// anything without one was dropped.
698    #[test]
699    fn an_env_value_with_a_comma_can_be_quoted() {
700        let tf = parse("## t\n\nEnv: FLAGS=\"-a,-b\", TIER=prod\n\n```sh\ntrue\n```\n");
701        assert_eq!(
702            tf.jobs[0].env,
703            vec![
704                ("FLAGS".into(), "-a,-b".into()),
705                ("TIER".into(), "prod".into())
706            ]
707        );
708        assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
709    }
710
711    #[test]
712    fn single_quotes_work_too() {
713        let tf = parse("## t\n\nEnv: FLAGS='-a,-b'\n\n```sh\ntrue\n```\n");
714        assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a,-b".into())]);
715    }
716
717    #[test]
718    fn an_env_fragment_that_cannot_be_a_pair_warns() {
719        let tf = parse("## t\n\nEnv: FLAGS=-a,-b\n\n```sh\ntrue\n```\n");
720        assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a".into())]);
721        assert!(
722            tf.warnings.iter().any(|w| w.contains("has no `=`")),
723            "{:?}",
724            tf.warnings
725        );
726    }
727
728    #[test]
729    fn an_env_entry_with_an_empty_key_warns() {
730        let tf = parse("## t\n\nEnv: =orphan\n\n```sh\ntrue\n```\n");
731        assert!(tf.jobs[0].env.is_empty());
732        assert!(
733            tf.warnings.iter().any(|w| w.contains("empty key")),
734            "{:?}",
735            tf.warnings
736        );
737    }
738
739    /// An `=` inside a quoted value is part of the value, not a second pair.
740    #[test]
741    fn an_env_value_may_contain_an_equals_sign() {
742        let tf = parse("## t\n\nEnv: OPTS=\"a=1,b=2\"\n\n```sh\ntrue\n```\n");
743        assert_eq!(tf.jobs[0].env, vec![("OPTS".into(), "a=1,b=2".into())]);
744    }
745
746    fn req_names(reqs: &[Requirement]) -> Vec<&str> {
747        reqs.iter().map(|r| r.name.as_str()).collect()
748    }
749
750    #[test]
751    fn parses_named_jobs_with_interpreter() {
752        let tf =
753            parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
754        assert_eq!(tf.jobs.len(), 2);
755        assert_eq!(tf.jobs[0].name, "build");
756        assert_eq!(tf.jobs[0].lang, "sh");
757        assert_eq!(tf.jobs[0].script.trim(), "cargo build");
758        assert_eq!(tf.jobs[1].lang, "zsh");
759    }
760
761    #[test]
762    fn metadata_keys_are_case_insensitive() {
763        let tf = parse(
764            "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
765        );
766        let t = &tf.jobs[0];
767        assert_eq!(t.opts, vec!["inherit-cwd"]);
768        assert!(t.inherits_cwd());
769        assert_eq!(
770            t.env,
771            vec![
772                ("REGION".into(), "us".into()),
773                ("TIER".into(), "prod".into())
774            ]
775        );
776        assert_eq!(
777            t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
778            ["target"]
779        );
780        assert_eq!(req_names(&t.requires), ["build", "test"]);
781        assert!(t.agent_allow);
782    }
783
784    #[test]
785    fn agent_gate_is_off_by_default() {
786        let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
787        assert!(!tf.jobs[0].agent_allow);
788    }
789
790    #[test]
791    fn top_level_env_is_hoisted() {
792        let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
793        assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
794    }
795
796    #[test]
797    fn fence_content_is_not_parsed_as_structure() {
798        // A `## heading` and a `Key:` line inside a fence stay in the script.
799        let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
800        assert_eq!(tf.jobs.len(), 1);
801        assert!(tf.jobs[0].script.contains("## not a task"));
802        assert!(tf.jobs[0].env.is_empty());
803    }
804
805    #[test]
806    fn an_unknown_opt_warns_but_is_ignored() {
807        let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
808        assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
809        assert!(tf.jobs[0].inherits_cwd()); // the known flag still applies
810        assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
811    }
812
813    // ---- parameterized dependencies ---------------------------------------
814
815    #[test]
816    fn a_bare_requirement_carries_no_arguments() {
817        let reqs = parse_requires("build, test");
818        assert_eq!(req_names(&reqs), ["build", "test"]);
819        assert!(reqs.iter().all(|r| r.args.is_empty()));
820    }
821
822    #[test]
823    fn a_parenthesised_requirement_carries_its_arguments() {
824        let reqs = parse_requires("lint, (dist bonus-die)");
825        assert_eq!(req_names(&reqs), ["lint", "dist"]);
826        assert!(reqs[0].args.is_empty(), "the bare one is untouched");
827        assert_eq!(reqs[1].args, ["bonus-die"]);
828    }
829
830    /// `{{ module }}` is conventionally written with spaces, and a plain
831    /// whitespace split turns it into three arguments. It must survive whole or
832    /// the syntax is unusable in the form everyone will write it in.
833    #[test]
834    fn a_spaced_placeholder_stays_one_argument() {
835        assert_eq!(
836            parse_requires("(dist {{ module }})")[0].args,
837            ["{{ module }}"]
838        );
839        assert_eq!(parse_requires("(dist {{module}})")[0].args, ["{{module}}"]);
840        // And a placeholder is a token, not the whole argument.
841        assert_eq!(
842            parse_requires("(dist {{ module }}-docs)")[0].args,
843            ["{{ module }}-docs"]
844        );
845    }
846
847    /// Entries are comma-separated and arguments are space-separated, so a comma
848    /// inside the parentheses would otherwise cut an entry in half and leave a
849    /// requirement named `b)`.
850    #[test]
851    fn a_comma_inside_parentheses_does_not_split_the_entry() {
852        let reqs = parse_requires("(deploy a, b), lint");
853        assert_eq!(req_names(&reqs), ["deploy", "lint"]);
854        assert_eq!(reqs[0].args, ["a", "b"]);
855    }
856
857    #[test]
858    fn a_quoted_argument_may_contain_a_space() {
859        let reqs = parse_requires(r#"(deploy "the droplet, west" now)"#);
860        assert_eq!(reqs[0].args, ["the droplet, west", "now"]);
861    }
862
863    /// An unterminated placeholder or quote is text, not a parse failure: this
864    /// runs over hand-written markdown, and refusing to plan is worse than
865    /// passing through what was actually typed.
866    #[test]
867    fn an_unterminated_placeholder_or_quote_is_taken_literally() {
868        assert_eq!(parse_requires("(dist {{ module)")[0].args, ["{{ module"]);
869        assert_eq!(parse_requires(r#"(dist "oops)"#)[0].args, ["oops"]);
870    }
871
872    #[test]
873    fn crlf_scripts_are_normalized() {
874        let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
875        assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
876        assert!(!tf.jobs[0].script.contains('\r'));
877    }
878
879    #[test]
880    fn an_unterminated_fence_warns_but_keeps_the_job() {
881        let tf = parse("## a\n\n```sh\necho hi\n"); // no closing fence
882        assert_eq!(tf.jobs.len(), 1);
883        assert_eq!(tf.jobs[0].script.trim(), "echo hi");
884        assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
885    }
886
887    #[test]
888    fn a_stray_fence_open_does_not_close_an_unterminated_block() {
889        // ```sh has an info string, so it opens rather than closes; only a bare
890        // ``` closes. (The trailing block here is what closes it.)
891        let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
892        assert!(tf.jobs[0].script.contains("one"));
893        assert!(tf.jobs[0].script.contains("```sh\ntwo"));
894    }
895
896    /// The one that matters. A sentence can be wrapped so that a line inside it
897    /// reads as a metadata key, which let a paragraph opt its own task in to
898    /// agent execution while reading as ordinary prose to every human reviewing
899    /// the file. Metadata has to begin a block.
900    #[test]
901    fn metadata_inside_a_paragraph_does_not_configure_the_task() {
902        let tf = parse(
903            "## t\n\nThe reviewer decides whether to set\nAgent: allow\non a task.\n\n```sh\ntrue\n```\n",
904        );
905        assert!(!tf.jobs[0].agent_allow, "prose must not open the gate");
906        assert!(
907            tf.jobs[0].description.contains("Agent: allow"),
908            "it is description, and stays visible as such"
909        );
910    }
911
912    /// Silently ignoring it would be its own trap: an author who did mean it
913    /// needs to hear that it did nothing.
914    #[test]
915    fn metadata_inside_a_paragraph_warns() {
916        let tf = parse("## t\n\nRun this after\nRequires: build\n\n```sh\ntrue\n```\n");
917        assert!(tf.jobs[0].requires.is_empty());
918        assert!(
919            tf.warnings.iter().any(|w| w.contains("inside a paragraph")),
920            "warnings: {:?}",
921            tf.warnings
922        );
923    }
924
925    /// Metadata after prose is how nearly every real task file is written: a
926    /// paragraph of description, a blank line, then `Args:`. The rule is about
927    /// paragraph *interiors*, and must not break that.
928    #[test]
929    fn metadata_after_a_blank_line_still_works() {
930        let tf = parse(
931            "## t\n\nSet a module's version.\n\nArgs: module version\nRequires: lint\nAgent: allow\n\n```sh\ntrue\n```\n",
932        );
933        let j = &tf.jobs[0];
934        assert_eq!(j.args.len(), 2, "after a blank line");
935        assert_eq!(req_names(&j.requires), ["lint"], "and a run stays together");
936        assert!(j.agent_allow);
937        assert!(tf.warnings.is_empty(), "warnings: {:?}", tf.warnings);
938    }
939
940    #[test]
941    fn metadata_directly_under_the_heading_still_works() {
942        let tf = parse("## t\nArgs: one\n\n```sh\ntrue\n```\n");
943        assert_eq!(tf.jobs[0].args.len(), 1);
944    }
945
946    #[test]
947    fn opens_list_item_recognizes_the_usual_markers() {
948        for good in ["- a", "* a", "+ a", "1. a", "12) a", "  - indented"] {
949            assert!(opens_list_item(good), "{good:?}");
950        }
951        for bad in ["-not a bullet", "a - b", "1.5 is a number", "", "text"] {
952            assert!(!opens_list_item(bad), "{bad:?}");
953        }
954    }
955
956    /// A description keeps its paragraph breaks, so a consumer can tell where
957    /// the opening thought ends. Dropping blanks left one undifferentiated run
958    /// of lines, and the only "summary" available was a hard-wrap fragment.
959    #[test]
960    fn a_description_keeps_its_paragraph_breaks() {
961        let tf = parse("## t\n\nFirst thought,\nwrapped.\n\nSecond thought.\n\n```sh\ntrue\n```\n");
962        let d = &tf.jobs[0].description;
963        let paras: Vec<&str> = d.split("\n\n").filter(|p| !p.trim().is_empty()).collect();
964        assert_eq!(paras.len(), 2, "description was {d:?}");
965        assert_eq!(paras[0].trim(), "First thought,\nwrapped.");
966    }
967
968    #[test]
969    fn indented_metadata_is_recognized() {
970        let tf = parse("## a\n\n- steps:\n  Env: KEY=val\n\n```sh\ntrue\n```\n");
971        assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
972    }
973
974    /// An unrecognized language is still a task, run as `sh`, and warned about.
975    /// Forgiving is deliberate: `shell-session` and `bash5` should work.
976    /// Repeated metadata accumulates. Assigning meant the second line silently
977    /// erased the first, so a declared dependency never ran and the task still
978    /// exited 0.
979    #[test]
980    fn repeated_metadata_lines_accumulate() {
981        let tf = parse("## t\n\nRequires: alpha\nRequires: beta\n\n```sh\ntrue\n```\n");
982        assert_eq!(req_names(&tf.jobs[0].requires), ["alpha", "beta"]);
983
984        let tf = parse("## t\n\nArgs: a\nArgs: b\n\n```sh\ntrue\n```\n");
985        let names: Vec<_> = tf.jobs[0].args.iter().map(|a| a.name.as_str()).collect();
986        assert_eq!(names, vec!["a", "b"]);
987    }
988
989    /// A near-miss of a real key is a typo, and used to vanish into the
990    /// description with no warning at all.
991    #[test]
992    fn a_misspelled_metadata_key_warns() {
993        for (typo, meant) in [("Require", "requires"), ("Arg", "args"), ("Opt", "opts")] {
994            let src = format!("## t\n\n{typo}: x\n\n```sh\ntrue\n```\n");
995            let tf = parse(&src);
996            assert!(
997                tf.warnings.iter().any(|w| w.contains(meant)),
998                "{typo}: should suggest {meant}, warnings were {:?}",
999                tf.warnings
1000            );
1001        }
1002    }
1003
1004    /// ...but ordinary prose that happens to start with a word and a colon must
1005    /// stay prose, or the format cannot live in the documentation it claims to.
1006    #[test]
1007    fn ordinary_prose_is_not_mistaken_for_metadata() {
1008        let tf = parse(
1009            "## t\n\nNote: this is a sentence.\nWarning: so is this.\nSee: the docs.\n\n```sh\ntrue\n```\n",
1010        );
1011        assert!(
1012            tf.warnings.is_empty(),
1013            "prose should not warn, got {:?}",
1014            tf.warnings
1015        );
1016        assert!(tf.jobs[0].description.contains("Note: this is a sentence."));
1017    }
1018
1019    #[test]
1020    fn an_unknown_language_is_still_a_task_and_warns() {
1021        let tf = parse("## a\n\n```shell-session\ntrue\n```\n");
1022        assert_eq!(tf.jobs.len(), 1);
1023        assert!(tf.warnings.iter().any(|w| w.contains("shell-session")));
1024        assert!(
1025            tf.warnings
1026                .iter()
1027                .any(|w| w.contains("running as a strict sh"))
1028        );
1029    }
1030
1031    #[test]
1032    fn duplicate_names_warn_and_the_first_wins() {
1033        let tf = parse("## a\n\n```sh\necho one\n```\n\n## a\n\n```sh\necho two\n```\n");
1034        assert_eq!(tf.jobs.len(), 2);
1035        assert!(tf.jobs[0].script.contains("one"));
1036        assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1037    }
1038
1039    #[test]
1040    fn a_task_option_at_file_level_says_where_it_belongs() {
1041        let tf = parse("Opts: inherit-cwd\n\n## t\n\n```sh\ntrue\n```\n");
1042        assert!(!tf.includes_parent());
1043        assert!(
1044            tf.warnings
1045                .iter()
1046                .any(|w| w.contains("task option belongs")),
1047            "warnings: {:?}",
1048            tf.warnings
1049        );
1050    }
1051
1052    #[test]
1053    fn a_file_option_under_a_task_says_where_it_belongs() {
1054        let tf = parse("## t\n\nOpts: include-parent\n\n```sh\ntrue\n```\n");
1055        assert!(
1056            tf.warnings.iter().any(|w| w.contains("file-level")),
1057            "warnings: {:?}",
1058            tf.warnings
1059        );
1060    }
1061
1062    #[test]
1063    fn no_strict_is_a_known_opt_and_warns_no_one() {
1064        let tf = parse("## t\n\nOpts: no-strict\n\n```sh\ntrue\n```\n");
1065        assert!(
1066            tf.warnings().is_empty(),
1067            "no-strict must be recognized: {:?}",
1068            tf.warnings()
1069        );
1070    }
1071}