1use crate::model::{Arg, Job, KNOWN_FILE_OPTS, KNOWN_OPTS, Requirement, TaskFile};
2use crate::run::is_known_lang;
3
4pub 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; let mut script = String::new();
16 let mut block_start = true;
19
20 for raw in src.split('\n') {
21 let line = raw.strip_suffix('\r').unwrap_or(raw); if in_fence {
23 if is_closing_fence(line, fence_marker) {
27 in_fence = false;
28 block_start = true; 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 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 block_start = was_meta || opens_list_item(line);
91 }
92 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
108fn 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); 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#[derive(Clone, Copy, PartialEq, Eq)]
147struct Fence {
148 ch: u8,
149 len: usize,
150}
151
152fn 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 if ch == b'`' && t[len..].contains('`') {
167 return None;
168 }
169 Some(Fence { ch, len })
170}
171
172fn 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
179fn 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
198fn 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
208fn 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 if after == t || !after.starts_with(' ') {
217 return None;
218 }
219 Some(after.trim().to_string())
220}
221
222fn 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
253fn 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 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
328const KEYS: &[&str] = &[
330 "env",
331 "environment",
332 "opts",
333 "options",
334 "args",
335 "arguments",
336 "requires",
337 "req",
338 "agent",
339];
340
341fn nearest_key(word: &str) -> Option<&'static str> {
347 KEYS.iter()
348 .copied()
349 .find(|k| {
350 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
358fn 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
367fn 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 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), }
415 return true;
416 }
417 "opts" | "options" => {
418 let Some(t) = job else {
419 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 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 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
493fn 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
503fn 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
516fn 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
550fn 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
571fn 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
600fn 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
632fn 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 #[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 #[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 assert!(opening_fence("~~~ a ~ b").is_some());
693 }
694
695 #[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 #[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 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()); assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
811 }
812
813 #[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 #[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 assert_eq!(
842 parse_requires("(dist {{ module }}-docs)")[0].args,
843 ["{{ module }}-docs"]
844 );
845 }
846
847 #[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 #[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"); 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}