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 let parsed = parse_args(value);
456 for a in parsed.iter().filter(|a| !a.is_valid_name()) {
460 warnings.push(format!(
461 "task {:?}: argument name `{}` cannot be a shell variable. \
462 `Args:` is whitespace-separated (just's syntax), so a comma \
463 becomes part of the name: write `Args: a b`, not `Args: a, b`",
464 t.name, a.name
465 ));
466 }
467 t.args.extend(parsed);
468 }
469 return true;
470 }
471 "requires" | "req" => {
472 if let Some(t) = job {
473 t.requires.extend(parse_requires(value));
474 }
475 return true;
476 }
477 "agent" => {
478 if let Some(t) = job {
479 t.agent_allow = value.eq_ignore_ascii_case("allow");
480 }
481 return true;
482 }
483 other => {
484 if let Some(meant) = nearest_key(other) {
493 warnings.push(format!(
494 "unknown metadata key {other:?}; did you mean {meant:?}? \
495 (treating the line as description)"
496 ));
497 }
498 }
499 }
500 }
501 describe(line, job);
502 false
503}
504
505fn describe(line: &str, job: Option<&mut Job>) {
507 if let Some(t) = job
508 && !line.trim().is_empty()
509 {
510 t.description.push_str(line.trim());
511 t.description.push('\n');
512 }
513}
514
515fn split_key(line: &str) -> Option<(String, &str)> {
520 let colon = line.find(':')?;
521 let key = line[..colon].trim();
522 if key.is_empty() || key.contains(char::is_whitespace) {
523 return None;
524 }
525 Some((key.to_ascii_lowercase(), &line[colon + 1..]))
526}
527
528fn parse_env(value: &str, warnings: &mut Vec<String>) -> Vec<(String, String)> {
536 let mut out = Vec::new();
537 for entry in split_pairs(value) {
538 let entry = entry.trim();
539 if entry.is_empty() {
540 continue;
541 }
542 let Some((k, v)) = entry.split_once('=') else {
543 warnings.push(format!(
544 "`Env:` entry {entry:?} has no `=`, so it was ignored; entries are \
545 comma-separated KEY=VALUE, and a value containing a comma has to be \
546 quoted (KEY=\"a,b\")"
547 ));
548 continue;
549 };
550 let k = k.trim();
551 if k.is_empty() {
552 warnings.push(format!(
553 "`Env:` entry {entry:?} has an empty key, so it was ignored"
554 ));
555 continue;
556 }
557 out.push((k.to_string(), unquote(v).to_string()));
558 }
559 out
560}
561
562fn split_pairs(value: &str) -> Vec<&str> {
565 let mut out = Vec::new();
566 let mut quote: Option<char> = None;
567 let mut start = 0usize;
568 for (i, c) in value.char_indices() {
569 match (quote, c) {
570 (None, '\'' | '"') => quote = Some(c),
571 (Some(q), c) if c == q => quote = None,
572 (None, ',') => {
573 out.push(&value[start..i]);
574 start = i + 1;
575 }
576 _ => {}
577 }
578 }
579 out.push(&value[start..]);
580 out
581}
582
583fn parse_args(value: &str) -> Vec<Arg> {
588 tokenize_args(value)
589 .into_iter()
590 .filter_map(|tok| {
591 let (name, default) = match tok.split_once('=') {
592 Some((n, d)) => (n, Some(unquote(d).to_string())),
593 None => (tok.as_str(), None),
594 };
595 let (name, variadic) = match name.strip_prefix('*') {
596 Some(rest) => (rest, true),
597 None => (name, false),
598 };
599 let name = name.trim();
600 if name.is_empty() {
601 return None;
602 }
603 Some(Arg {
604 name: name.to_string(),
605 variadic,
606 default,
607 })
608 })
609 .collect()
610}
611
612fn tokenize_args(value: &str) -> Vec<String> {
615 let mut out = Vec::new();
616 let mut cur = String::new();
617 let mut quote: Option<char> = None;
618 for c in value.chars() {
619 match quote {
620 Some(q) => {
621 cur.push(c);
622 if c == q {
623 quote = None;
624 }
625 }
626 None if c == '\'' || c == '"' => {
627 cur.push(c);
628 quote = Some(c);
629 }
630 None if c.is_whitespace() => {
631 if !cur.is_empty() {
632 out.push(std::mem::take(&mut cur));
633 }
634 }
635 None => cur.push(c),
636 }
637 }
638 if !cur.is_empty() {
639 out.push(cur);
640 }
641 out
642}
643
644fn unquote(s: &str) -> &str {
646 let s = s.trim();
647 let b = s.as_bytes();
648 if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
649 &s[1..s.len() - 1]
650 } else {
651 s
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
668 fn a_longer_fence_is_not_closed_by_a_shorter_one() {
669 let tf = parse("## t\n\n````markdown\n# inner\n\n```sh\necho nested\n```\n````\n");
670 assert_eq!(tf.jobs[0].lang, "markdown", "not \"`markdown\"");
671 let script = &tf.jobs[0].script;
672 assert!(
673 script.contains("```sh"),
674 "the inner fence is content: {script:?}"
675 );
676 assert!(script.contains("echo nested"), "{script:?}");
677 assert!(
678 !script.contains("````"),
679 "and the outer one is not: {script:?}"
680 );
681 }
682
683 #[test]
684 fn a_shorter_fence_is_still_closed_normally() {
685 let tf = parse("## t\n\n```sh\ntrue\n````\n");
686 assert_eq!(tf.jobs[0].script.trim(), "true", "a longer run closes it");
687 assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
688 }
689
690 #[test]
691 fn tildes_track_their_own_length() {
692 let tf = parse("## t\n\n~~~~sh\n~~~\nstill inside\n~~~~\n");
693 assert!(tf.jobs[0].script.contains("still inside"));
694 }
695
696 #[test]
699 fn inline_code_does_not_open_a_fence() {
700 assert!(opening_fence("```rust").is_some());
701 assert!(opening_fence("``` rust ```").is_none());
702 assert!(opening_fence("``").is_none(), "two is not a fence");
703 assert!(opening_fence("~~~ a ~ b").is_some());
705 }
706
707 #[test]
711 fn an_env_value_with_a_comma_can_be_quoted() {
712 let tf = parse("## t\n\nEnv: FLAGS=\"-a,-b\", TIER=prod\n\n```sh\ntrue\n```\n");
713 assert_eq!(
714 tf.jobs[0].env,
715 vec![
716 ("FLAGS".into(), "-a,-b".into()),
717 ("TIER".into(), "prod".into())
718 ]
719 );
720 assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
721 }
722
723 #[test]
724 fn single_quotes_work_too() {
725 let tf = parse("## t\n\nEnv: FLAGS='-a,-b'\n\n```sh\ntrue\n```\n");
726 assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a,-b".into())]);
727 }
728
729 #[test]
730 fn an_env_fragment_that_cannot_be_a_pair_warns() {
731 let tf = parse("## t\n\nEnv: FLAGS=-a,-b\n\n```sh\ntrue\n```\n");
732 assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a".into())]);
733 assert!(
734 tf.warnings.iter().any(|w| w.contains("has no `=`")),
735 "{:?}",
736 tf.warnings
737 );
738 }
739
740 #[test]
741 fn an_env_entry_with_an_empty_key_warns() {
742 let tf = parse("## t\n\nEnv: =orphan\n\n```sh\ntrue\n```\n");
743 assert!(tf.jobs[0].env.is_empty());
744 assert!(
745 tf.warnings.iter().any(|w| w.contains("empty key")),
746 "{:?}",
747 tf.warnings
748 );
749 }
750
751 #[test]
753 fn an_env_value_may_contain_an_equals_sign() {
754 let tf = parse("## t\n\nEnv: OPTS=\"a=1,b=2\"\n\n```sh\ntrue\n```\n");
755 assert_eq!(tf.jobs[0].env, vec![("OPTS".into(), "a=1,b=2".into())]);
756 }
757
758 fn req_names(reqs: &[Requirement]) -> Vec<&str> {
759 reqs.iter().map(|r| r.name.as_str()).collect()
760 }
761
762 #[test]
763 fn parses_named_jobs_with_interpreter() {
764 let tf =
765 parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
766 assert_eq!(tf.jobs.len(), 2);
767 assert_eq!(tf.jobs[0].name, "build");
768 assert_eq!(tf.jobs[0].lang, "sh");
769 assert_eq!(tf.jobs[0].script.trim(), "cargo build");
770 assert_eq!(tf.jobs[1].lang, "zsh");
771 }
772
773 #[test]
774 fn metadata_keys_are_case_insensitive() {
775 let tf = parse(
776 "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
777 );
778 let t = &tf.jobs[0];
779 assert_eq!(t.opts, vec!["inherit-cwd"]);
780 assert!(t.inherits_cwd());
781 assert_eq!(
782 t.env,
783 vec![
784 ("REGION".into(), "us".into()),
785 ("TIER".into(), "prod".into())
786 ]
787 );
788 assert_eq!(
789 t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
790 ["target"]
791 );
792 assert_eq!(req_names(&t.requires), ["build", "test"]);
793 assert!(t.agent_allow);
794 }
795
796 #[test]
797 fn agent_gate_is_off_by_default() {
798 let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
799 assert!(!tf.jobs[0].agent_allow);
800 }
801
802 #[test]
803 fn top_level_env_is_hoisted() {
804 let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
805 assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
806 }
807
808 #[test]
809 fn fence_content_is_not_parsed_as_structure() {
810 let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
812 assert_eq!(tf.jobs.len(), 1);
813 assert!(tf.jobs[0].script.contains("## not a task"));
814 assert!(tf.jobs[0].env.is_empty());
815 }
816
817 #[test]
818 fn an_unknown_opt_warns_but_is_ignored() {
819 let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
820 assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
821 assert!(tf.jobs[0].inherits_cwd()); assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
823 }
824
825 #[test]
828 fn a_bare_requirement_carries_no_arguments() {
829 let reqs = parse_requires("build, test");
830 assert_eq!(req_names(&reqs), ["build", "test"]);
831 assert!(reqs.iter().all(|r| r.args.is_empty()));
832 }
833
834 #[test]
835 fn a_parenthesised_requirement_carries_its_arguments() {
836 let reqs = parse_requires("lint, (dist bonus-die)");
837 assert_eq!(req_names(&reqs), ["lint", "dist"]);
838 assert!(reqs[0].args.is_empty(), "the bare one is untouched");
839 assert_eq!(reqs[1].args, ["bonus-die"]);
840 }
841
842 #[test]
846 fn a_spaced_placeholder_stays_one_argument() {
847 assert_eq!(
848 parse_requires("(dist {{ module }})")[0].args,
849 ["{{ module }}"]
850 );
851 assert_eq!(parse_requires("(dist {{module}})")[0].args, ["{{module}}"]);
852 assert_eq!(
854 parse_requires("(dist {{ module }}-docs)")[0].args,
855 ["{{ module }}-docs"]
856 );
857 }
858
859 #[test]
863 fn a_comma_inside_parentheses_does_not_split_the_entry() {
864 let reqs = parse_requires("(deploy a, b), lint");
865 assert_eq!(req_names(&reqs), ["deploy", "lint"]);
866 assert_eq!(reqs[0].args, ["a", "b"]);
867 }
868
869 #[test]
870 fn a_quoted_argument_may_contain_a_space() {
871 let reqs = parse_requires(r#"(deploy "the droplet, west" now)"#);
872 assert_eq!(reqs[0].args, ["the droplet, west", "now"]);
873 }
874
875 #[test]
879 fn an_unterminated_placeholder_or_quote_is_taken_literally() {
880 assert_eq!(parse_requires("(dist {{ module)")[0].args, ["{{ module"]);
881 assert_eq!(parse_requires(r#"(dist "oops)"#)[0].args, ["oops"]);
882 }
883
884 #[test]
885 fn crlf_scripts_are_normalized() {
886 let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
887 assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
888 assert!(!tf.jobs[0].script.contains('\r'));
889 }
890
891 #[test]
892 fn an_unterminated_fence_warns_but_keeps_the_job() {
893 let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.jobs.len(), 1);
895 assert_eq!(tf.jobs[0].script.trim(), "echo hi");
896 assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
897 }
898
899 #[test]
900 fn a_stray_fence_open_does_not_close_an_unterminated_block() {
901 let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
904 assert!(tf.jobs[0].script.contains("one"));
905 assert!(tf.jobs[0].script.contains("```sh\ntwo"));
906 }
907
908 #[test]
913 fn metadata_inside_a_paragraph_does_not_configure_the_task() {
914 let tf = parse(
915 "## t\n\nThe reviewer decides whether to set\nAgent: allow\non a task.\n\n```sh\ntrue\n```\n",
916 );
917 assert!(!tf.jobs[0].agent_allow, "prose must not open the gate");
918 assert!(
919 tf.jobs[0].description.contains("Agent: allow"),
920 "it is description, and stays visible as such"
921 );
922 }
923
924 #[test]
927 fn metadata_inside_a_paragraph_warns() {
928 let tf = parse("## t\n\nRun this after\nRequires: build\n\n```sh\ntrue\n```\n");
929 assert!(tf.jobs[0].requires.is_empty());
930 assert!(
931 tf.warnings.iter().any(|w| w.contains("inside a paragraph")),
932 "warnings: {:?}",
933 tf.warnings
934 );
935 }
936
937 #[test]
941 fn metadata_after_a_blank_line_still_works() {
942 let tf = parse(
943 "## t\n\nSet a module's version.\n\nArgs: module version\nRequires: lint\nAgent: allow\n\n```sh\ntrue\n```\n",
944 );
945 let j = &tf.jobs[0];
946 assert_eq!(j.args.len(), 2, "after a blank line");
947 assert_eq!(req_names(&j.requires), ["lint"], "and a run stays together");
948 assert!(j.agent_allow);
949 assert!(tf.warnings.is_empty(), "warnings: {:?}", tf.warnings);
950 }
951
952 #[test]
953 fn metadata_directly_under_the_heading_still_works() {
954 let tf = parse("## t\nArgs: one\n\n```sh\ntrue\n```\n");
955 assert_eq!(tf.jobs[0].args.len(), 1);
956 }
957
958 #[test]
959 fn opens_list_item_recognizes_the_usual_markers() {
960 for good in ["- a", "* a", "+ a", "1. a", "12) a", " - indented"] {
961 assert!(opens_list_item(good), "{good:?}");
962 }
963 for bad in ["-not a bullet", "a - b", "1.5 is a number", "", "text"] {
964 assert!(!opens_list_item(bad), "{bad:?}");
965 }
966 }
967
968 #[test]
972 fn a_description_keeps_its_paragraph_breaks() {
973 let tf = parse("## t\n\nFirst thought,\nwrapped.\n\nSecond thought.\n\n```sh\ntrue\n```\n");
974 let d = &tf.jobs[0].description;
975 let paras: Vec<&str> = d.split("\n\n").filter(|p| !p.trim().is_empty()).collect();
976 assert_eq!(paras.len(), 2, "description was {d:?}");
977 assert_eq!(paras[0].trim(), "First thought,\nwrapped.");
978 }
979
980 #[test]
981 fn indented_metadata_is_recognized() {
982 let tf = parse("## a\n\n- steps:\n Env: KEY=val\n\n```sh\ntrue\n```\n");
983 assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
984 }
985
986 #[test]
992 fn repeated_metadata_lines_accumulate() {
993 let tf = parse("## t\n\nRequires: alpha\nRequires: beta\n\n```sh\ntrue\n```\n");
994 assert_eq!(req_names(&tf.jobs[0].requires), ["alpha", "beta"]);
995
996 let tf = parse("## t\n\nArgs: a\nArgs: b\n\n```sh\ntrue\n```\n");
997 let names: Vec<_> = tf.jobs[0].args.iter().map(|a| a.name.as_str()).collect();
998 assert_eq!(names, vec!["a", "b"]);
999 }
1000
1001 #[test]
1004 fn a_misspelled_metadata_key_warns() {
1005 for (typo, meant) in [("Require", "requires"), ("Arg", "args"), ("Opt", "opts")] {
1006 let src = format!("## t\n\n{typo}: x\n\n```sh\ntrue\n```\n");
1007 let tf = parse(&src);
1008 assert!(
1009 tf.warnings.iter().any(|w| w.contains(meant)),
1010 "{typo}: should suggest {meant}, warnings were {:?}",
1011 tf.warnings
1012 );
1013 }
1014 }
1015
1016 #[test]
1019 fn ordinary_prose_is_not_mistaken_for_metadata() {
1020 let tf = parse(
1021 "## t\n\nNote: this is a sentence.\nWarning: so is this.\nSee: the docs.\n\n```sh\ntrue\n```\n",
1022 );
1023 assert!(
1024 tf.warnings.is_empty(),
1025 "prose should not warn, got {:?}",
1026 tf.warnings
1027 );
1028 assert!(tf.jobs[0].description.contains("Note: this is a sentence."));
1029 }
1030
1031 #[test]
1032 fn an_unknown_language_is_still_a_task_and_warns() {
1033 let tf = parse("## a\n\n```shell-session\ntrue\n```\n");
1034 assert_eq!(tf.jobs.len(), 1);
1035 assert!(tf.warnings.iter().any(|w| w.contains("shell-session")));
1036 assert!(
1037 tf.warnings
1038 .iter()
1039 .any(|w| w.contains("running as a strict sh"))
1040 );
1041 }
1042
1043 #[test]
1044 fn duplicate_names_warn_and_the_first_wins() {
1045 let tf = parse("## a\n\n```sh\necho one\n```\n\n## a\n\n```sh\necho two\n```\n");
1046 assert_eq!(tf.jobs.len(), 2);
1047 assert!(tf.jobs[0].script.contains("one"));
1048 assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1049 }
1050
1051 #[test]
1052 fn a_task_option_at_file_level_says_where_it_belongs() {
1053 let tf = parse("Opts: inherit-cwd\n\n## t\n\n```sh\ntrue\n```\n");
1054 assert!(!tf.includes_parent());
1055 assert!(
1056 tf.warnings
1057 .iter()
1058 .any(|w| w.contains("task option belongs")),
1059 "warnings: {:?}",
1060 tf.warnings
1061 );
1062 }
1063
1064 #[test]
1065 fn a_file_option_under_a_task_says_where_it_belongs() {
1066 let tf = parse("## t\n\nOpts: include-parent\n\n```sh\ntrue\n```\n");
1067 assert!(
1068 tf.warnings.iter().any(|w| w.contains("file-level")),
1069 "warnings: {:?}",
1070 tf.warnings
1071 );
1072 }
1073
1074 #[test]
1075 fn no_strict_is_a_known_opt_and_warns_no_one() {
1076 let tf = parse("## t\n\nOpts: no-strict\n\n```sh\ntrue\n```\n");
1077 assert!(
1078 tf.warnings().is_empty(),
1079 "no-strict must be recognized: {:?}",
1080 tf.warnings()
1081 );
1082 }
1083}
1084
1085#[cfg(test)]
1086mod invalid_arg_names {
1087 use super::*;
1088
1089 fn file(args_line: &str) -> TaskFile {
1090 parse(&format!(
1091 "## build\n\nDo a thing.\n\nArgs: {args_line}\n\n```sh\necho \"$slug\"\n```\n"
1092 ))
1093 }
1094
1095 #[test]
1099 fn a_comma_separated_list_warns_and_says_why() {
1100 let tf = file("slug, repo");
1101 let warning = tf
1102 .warnings()
1103 .iter()
1104 .find(|w| w.contains("slug,"))
1105 .unwrap_or_else(|| panic!("no warning about `slug,` in {:?}", tf.warnings()));
1106 assert!(warning.contains("whitespace-separated"), "{warning}");
1107 assert!(warning.contains("Args: a b"), "{warning}");
1108 }
1109
1110 #[test]
1113 fn only_the_offending_name_is_reported() {
1114 let tf = file("slug, repo");
1115 assert_eq!(tf.warnings().len(), 1, "{:?}", tf.warnings());
1116 }
1117
1118 #[test]
1119 fn a_well_formed_declaration_warns_about_nothing() {
1120 assert!(file("slug repo").warnings().is_empty());
1121 assert!(file("slug *rest").warnings().is_empty());
1122 assert!(file("slug msg='hello world'").warnings().is_empty());
1123 assert!(file("_private").warnings().is_empty());
1124 }
1125
1126 #[test]
1127 fn validity_is_the_shell_identifier_rule() {
1128 let valid = |name: &str| {
1129 Arg {
1130 name: name.into(),
1131 variadic: false,
1132 default: None,
1133 }
1134 .is_valid_name()
1135 };
1136 assert!(valid("slug"));
1137 assert!(valid("_slug"));
1138 assert!(valid("slug2"));
1139 assert!(!valid("slug,"));
1140 assert!(!valid("2slug"), "a leading digit is not an identifier");
1141 assert!(!valid("my-slug"), "a hyphen is not an identifier");
1142 assert!(!valid(""));
1143 }
1144
1145 #[test]
1148 fn markers_are_not_part_of_the_name() {
1149 assert!(file("*rest").warnings().is_empty());
1150 assert!(file("name='a, b'").warnings().is_empty());
1151 }
1152}