use crate::model::{Arg, Job, KNOWN_FILE_OPTS, KNOWN_OPTS, Requirement, TaskFile};
use crate::run::is_known_lang;
pub fn parse(src: &str) -> TaskFile {
let mut file = TaskFile::default();
let mut cur: Option<Job> = None;
let mut in_fence = false;
let mut fence_marker = Fence { ch: b'`', len: 3 };
let mut have_script = false; let mut script = String::new();
let mut block_start = true;
for raw in src.split('\n') {
let line = raw.strip_suffix('\r').unwrap_or(raw); if in_fence {
if is_closing_fence(line, fence_marker) {
in_fence = false;
block_start = true; if let Some(t) = cur.as_mut()
&& !have_script
{
t.script = std::mem::take(&mut script);
have_script = true;
}
script.clear();
} else if cur.is_some() && !have_script {
script.push_str(line);
script.push('\n');
}
continue;
}
if let Some(marker) = opening_fence(line) {
in_fence = true;
block_start = true;
fence_marker = marker;
if let Some(t) = cur.as_mut()
&& !have_script
{
t.lang = info_string(line, marker);
}
script.clear();
continue;
}
if let Some(name) = heading(line) {
finalize(cur.take(), &mut file);
cur = Some(Job {
name,
..Job::default()
});
have_script = false;
block_start = true;
continue;
}
if line.trim().is_empty() {
block_start = true;
if let Some(t) = cur.as_mut()
&& !t.description.is_empty()
&& !t.description.ends_with("\n\n")
{
t.description.push('\n');
}
continue;
}
let was_meta = apply_line(
line,
cur.as_mut(),
&mut file.env,
&mut file.opts,
&mut file.warnings,
block_start,
);
block_start = was_meta || opens_list_item(line);
}
if in_fence {
if let Some(t) = cur.as_mut()
&& !have_script
{
t.script = std::mem::take(&mut script);
}
let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
file.warnings
.push(format!("unterminated code fence in task {name:?}"));
}
finalize(cur.take(), &mut file);
file
}
fn finalize(job: Option<Job>, file: &mut TaskFile) {
let Some(mut t) = job else {
return;
};
if t.script.is_empty() {
file.env.append(&mut t.env); return;
}
t.description = t.description.trim().to_string();
if file.jobs.iter().any(|x| x.name == t.name) {
file.warnings.push(format!(
"duplicate task {:?}; the first defined wins",
t.name
));
}
if !is_known_lang(&t.lang) {
file.warnings.push(format!(
"task {:?}: fenced language {:?} is not a known interpreter; \
running as a strict sh script",
t.name, t.lang
));
}
file.jobs.push(t);
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct Fence {
ch: u8,
len: usize,
}
fn opening_fence(line: &str) -> Option<Fence> {
let t = line.trim_start();
let ch = match t.as_bytes().first() {
Some(b'`') => b'`',
Some(b'~') => b'~',
_ => return None,
};
let len = t.bytes().take_while(|b| *b == ch).count();
if len < 3 {
return None;
}
if ch == b'`' && t[len..].contains('`') {
return None;
}
Some(Fence { ch, len })
}
fn is_closing_fence(line: &str, fence: Fence) -> bool {
let t = line.trim();
t.len() >= fence.len && t.bytes().all(|b| b == fence.ch)
}
fn opens_list_item(line: &str) -> bool {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
return rest.starts_with(' ') || rest.is_empty();
}
let digits = t.trim_start_matches(|c: char| c.is_ascii_digit());
if digits.len() < t.len()
&& let Some(rest) = digits.strip_prefix(['.', ')'])
{
return rest.starts_with(' ') || rest.is_empty();
}
false
}
fn info_string(line: &str, fence: Fence) -> String {
let t = line.trim_start();
t[fence.len.min(t.len())..]
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
fn heading(line: &str) -> Option<String> {
let t = line.trim_start();
if !t.starts_with('#') {
return None;
}
let after = t.trim_start_matches('#');
if after == t || !after.starts_with(' ') {
return None;
}
Some(after.trim().to_string())
}
fn split_entries(value: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
for (i, c) in value.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
out.push(&value[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&value[start..]);
out
}
fn split_args(inner: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut has = false;
let mut rest = inner;
while let Some(c) = rest.chars().next() {
if c.is_whitespace() || c == ',' {
if has {
out.push(std::mem::take(&mut cur));
has = false;
}
rest = &rest[c.len_utf8()..];
} else if rest.starts_with("{{") {
let end = rest.find("}}").map_or(rest.len(), |i| i + 2);
cur.push_str(&rest[..end]);
has = true;
rest = &rest[end..];
} else if c == '"' {
let body = &rest[1..];
let end = body.find('"');
cur.push_str(end.map_or(body, |i| &body[..i]));
has = true;
rest = end.map_or("", |i| &body[i + 1..]);
} else {
cur.push(c);
has = true;
rest = &rest[c.len_utf8()..];
}
}
if has {
out.push(cur);
}
out
}
fn parse_requires(value: &str) -> Vec<Requirement> {
split_entries(value)
.into_iter()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|entry| {
match entry
.strip_prefix('(')
.and_then(|rest| rest.strip_suffix(')'))
{
Some(inner) => {
let mut parts = split_args(inner).into_iter();
Requirement {
name: parts.next().unwrap_or_default(),
args: parts.collect(),
}
}
None => Requirement {
name: entry.to_string(),
args: Vec::new(),
},
}
})
.filter(|r: &Requirement| !r.name.is_empty())
.collect()
}
const KEYS: &[&str] = &[
"env",
"environment",
"opts",
"options",
"args",
"arguments",
"requires",
"req",
"agent",
];
fn nearest_key(word: &str) -> Option<&'static str> {
KEYS.iter()
.copied()
.find(|k| {
k.strip_suffix('s') == Some(word)
|| word.strip_suffix('s') == Some(*k)
|| k.starts_with(word) && k.len() == word.len() + 1
})
.or_else(|| KEYS.iter().copied().find(|k| edit_distance_one(k, word)))
}
fn edit_distance_one(a: &str, b: &str) -> bool {
if a.len() != b.len() || a == b {
return false;
}
a.bytes().zip(b.bytes()).filter(|(x, y)| x != y).count() == 1
}
fn apply_line(
line: &str,
job: Option<&mut Job>,
file_env: &mut Vec<(String, String)>,
file_opts: &mut Vec<String>,
warnings: &mut Vec<String>,
block_start: bool,
) -> bool {
if let Some((key, value)) = split_key(line) {
if !block_start && KEYS.contains(&key.as_str()) {
warnings.push(format!(
"`{key}:` is inside a paragraph, so it was read as description, \
not metadata; put it on its own line after a blank one"
));
describe(line, job);
return false;
}
let value = value.trim();
match key.as_str() {
"env" | "environment" => {
let pairs = parse_env(value, warnings);
match job {
Some(t) => t.env.extend(pairs),
None => file_env.extend(pairs), }
return true;
}
"opts" | "options" => {
let Some(t) = job else {
for flag in value.split_whitespace() {
if KNOWN_FILE_OPTS.contains(&flag) {
file_opts.push(flag.to_string());
} else {
warnings.push(format!(
"unknown file-level option {flag:?} in `Opts:` (known: {}); \
a task option belongs under a task heading",
KNOWN_FILE_OPTS.join(", ")
));
}
}
return true;
};
t.opts.extend(value.split_whitespace().map(str::to_string));
for flag in &t.opts {
if !KNOWN_OPTS.contains(&flag.as_str()) {
let hint = if KNOWN_FILE_OPTS.contains(&flag.as_str()) {
"; that one is file-level, so it goes before the first task heading"
} else {
""
};
warnings.push(format!(
"unknown option {flag:?} in `Opts:` (known: {}){hint}",
KNOWN_OPTS.join(", ")
));
}
}
return true;
}
"args" | "arguments" => {
if let Some(t) = job {
let parsed = parse_args(value);
for a in parsed.iter().filter(|a| !a.is_valid_name()) {
warnings.push(format!(
"task {:?}: argument name `{}` cannot be a shell variable. \
`Args:` is whitespace-separated (just's syntax), so a comma \
becomes part of the name: write `Args: a b`, not `Args: a, b`",
t.name, a.name
));
}
t.args.extend(parsed);
}
return true;
}
"requires" | "req" => {
if let Some(t) = job {
t.requires.extend(parse_requires(value));
}
return true;
}
"agent" => {
if let Some(t) = job {
t.agent_allow = value.eq_ignore_ascii_case("allow");
}
return true;
}
other => {
if let Some(meant) = nearest_key(other) {
warnings.push(format!(
"unknown metadata key {other:?}; did you mean {meant:?}? \
(treating the line as description)"
));
}
}
}
}
describe(line, job);
false
}
fn describe(line: &str, job: Option<&mut Job>) {
if let Some(t) = job
&& !line.trim().is_empty()
{
t.description.push_str(line.trim());
t.description.push('\n');
}
}
fn split_key(line: &str) -> Option<(String, &str)> {
let colon = line.find(':')?;
let key = line[..colon].trim();
if key.is_empty() || key.contains(char::is_whitespace) {
return None;
}
Some((key.to_ascii_lowercase(), &line[colon + 1..]))
}
fn parse_env(value: &str, warnings: &mut Vec<String>) -> Vec<(String, String)> {
let mut out = Vec::new();
for entry in split_pairs(value) {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some((k, v)) = entry.split_once('=') else {
warnings.push(format!(
"`Env:` entry {entry:?} has no `=`, so it was ignored; entries are \
comma-separated KEY=VALUE, and a value containing a comma has to be \
quoted (KEY=\"a,b\")"
));
continue;
};
let k = k.trim();
if k.is_empty() {
warnings.push(format!(
"`Env:` entry {entry:?} has an empty key, so it was ignored"
));
continue;
}
out.push((k.to_string(), unquote(v).to_string()));
}
out
}
fn split_pairs(value: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut quote: Option<char> = None;
let mut start = 0usize;
for (i, c) in value.char_indices() {
match (quote, c) {
(None, '\'' | '"') => quote = Some(c),
(Some(q), c) if c == q => quote = None,
(None, ',') => {
out.push(&value[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&value[start..]);
out
}
fn parse_args(value: &str) -> Vec<Arg> {
tokenize_args(value)
.into_iter()
.filter_map(|tok| {
let (name, default) = match tok.split_once('=') {
Some((n, d)) => (n, Some(unquote(d).to_string())),
None => (tok.as_str(), None),
};
let (name, variadic) = match name.strip_prefix('*') {
Some(rest) => (rest, true),
None => (name, false),
};
let name = name.trim();
if name.is_empty() {
return None;
}
Some(Arg {
name: name.to_string(),
variadic,
default,
})
})
.collect()
}
fn tokenize_args(value: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for c in value.chars() {
match quote {
Some(q) => {
cur.push(c);
if c == q {
quote = None;
}
}
None if c == '\'' || c == '"' => {
cur.push(c);
quote = Some(c);
}
None if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
None => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn unquote(s: &str) -> &str {
let s = s.trim();
let b = s.as_bytes();
if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
&s[1..s.len() - 1]
} else {
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_longer_fence_is_not_closed_by_a_shorter_one() {
let tf = parse("## t\n\n````markdown\n# inner\n\n```sh\necho nested\n```\n````\n");
assert_eq!(tf.jobs[0].lang, "markdown", "not \"`markdown\"");
let script = &tf.jobs[0].script;
assert!(
script.contains("```sh"),
"the inner fence is content: {script:?}"
);
assert!(script.contains("echo nested"), "{script:?}");
assert!(
!script.contains("````"),
"and the outer one is not: {script:?}"
);
}
#[test]
fn a_shorter_fence_is_still_closed_normally() {
let tf = parse("## t\n\n```sh\ntrue\n````\n");
assert_eq!(tf.jobs[0].script.trim(), "true", "a longer run closes it");
assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
}
#[test]
fn tildes_track_their_own_length() {
let tf = parse("## t\n\n~~~~sh\n~~~\nstill inside\n~~~~\n");
assert!(tf.jobs[0].script.contains("still inside"));
}
#[test]
fn inline_code_does_not_open_a_fence() {
assert!(opening_fence("```rust").is_some());
assert!(opening_fence("``` rust ```").is_none());
assert!(opening_fence("``").is_none(), "two is not a fence");
assert!(opening_fence("~~~ a ~ b").is_some());
}
#[test]
fn an_env_value_with_a_comma_can_be_quoted() {
let tf = parse("## t\n\nEnv: FLAGS=\"-a,-b\", TIER=prod\n\n```sh\ntrue\n```\n");
assert_eq!(
tf.jobs[0].env,
vec![
("FLAGS".into(), "-a,-b".into()),
("TIER".into(), "prod".into())
]
);
assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
}
#[test]
fn single_quotes_work_too() {
let tf = parse("## t\n\nEnv: FLAGS='-a,-b'\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a,-b".into())]);
}
#[test]
fn an_env_fragment_that_cannot_be_a_pair_warns() {
let tf = parse("## t\n\nEnv: FLAGS=-a,-b\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a".into())]);
assert!(
tf.warnings.iter().any(|w| w.contains("has no `=`")),
"{:?}",
tf.warnings
);
}
#[test]
fn an_env_entry_with_an_empty_key_warns() {
let tf = parse("## t\n\nEnv: =orphan\n\n```sh\ntrue\n```\n");
assert!(tf.jobs[0].env.is_empty());
assert!(
tf.warnings.iter().any(|w| w.contains("empty key")),
"{:?}",
tf.warnings
);
}
#[test]
fn an_env_value_may_contain_an_equals_sign() {
let tf = parse("## t\n\nEnv: OPTS=\"a=1,b=2\"\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].env, vec![("OPTS".into(), "a=1,b=2".into())]);
}
fn req_names(reqs: &[Requirement]) -> Vec<&str> {
reqs.iter().map(|r| r.name.as_str()).collect()
}
#[test]
fn parses_named_jobs_with_interpreter() {
let tf =
parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
assert_eq!(tf.jobs.len(), 2);
assert_eq!(tf.jobs[0].name, "build");
assert_eq!(tf.jobs[0].lang, "sh");
assert_eq!(tf.jobs[0].script.trim(), "cargo build");
assert_eq!(tf.jobs[1].lang, "zsh");
}
#[test]
fn metadata_keys_are_case_insensitive() {
let tf = parse(
"## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
);
let t = &tf.jobs[0];
assert_eq!(t.opts, vec!["inherit-cwd"]);
assert!(t.inherits_cwd());
assert_eq!(
t.env,
vec![
("REGION".into(), "us".into()),
("TIER".into(), "prod".into())
]
);
assert_eq!(
t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
["target"]
);
assert_eq!(req_names(&t.requires), ["build", "test"]);
assert!(t.agent_allow);
}
#[test]
fn agent_gate_is_off_by_default() {
let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
assert!(!tf.jobs[0].agent_allow);
}
#[test]
fn top_level_env_is_hoisted() {
let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
}
#[test]
fn fence_content_is_not_parsed_as_structure() {
let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
assert_eq!(tf.jobs.len(), 1);
assert!(tf.jobs[0].script.contains("## not a task"));
assert!(tf.jobs[0].env.is_empty());
}
#[test]
fn an_unknown_opt_warns_but_is_ignored() {
let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
assert!(tf.jobs[0].inherits_cwd()); assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
}
#[test]
fn a_bare_requirement_carries_no_arguments() {
let reqs = parse_requires("build, test");
assert_eq!(req_names(&reqs), ["build", "test"]);
assert!(reqs.iter().all(|r| r.args.is_empty()));
}
#[test]
fn a_parenthesised_requirement_carries_its_arguments() {
let reqs = parse_requires("lint, (dist bonus-die)");
assert_eq!(req_names(&reqs), ["lint", "dist"]);
assert!(reqs[0].args.is_empty(), "the bare one is untouched");
assert_eq!(reqs[1].args, ["bonus-die"]);
}
#[test]
fn a_spaced_placeholder_stays_one_argument() {
assert_eq!(
parse_requires("(dist {{ module }})")[0].args,
["{{ module }}"]
);
assert_eq!(parse_requires("(dist {{module}})")[0].args, ["{{module}}"]);
assert_eq!(
parse_requires("(dist {{ module }}-docs)")[0].args,
["{{ module }}-docs"]
);
}
#[test]
fn a_comma_inside_parentheses_does_not_split_the_entry() {
let reqs = parse_requires("(deploy a, b), lint");
assert_eq!(req_names(&reqs), ["deploy", "lint"]);
assert_eq!(reqs[0].args, ["a", "b"]);
}
#[test]
fn a_quoted_argument_may_contain_a_space() {
let reqs = parse_requires(r#"(deploy "the droplet, west" now)"#);
assert_eq!(reqs[0].args, ["the droplet, west", "now"]);
}
#[test]
fn an_unterminated_placeholder_or_quote_is_taken_literally() {
assert_eq!(parse_requires("(dist {{ module)")[0].args, ["{{ module"]);
assert_eq!(parse_requires(r#"(dist "oops)"#)[0].args, ["oops"]);
}
#[test]
fn crlf_scripts_are_normalized() {
let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
assert!(!tf.jobs[0].script.contains('\r'));
}
#[test]
fn an_unterminated_fence_warns_but_keeps_the_job() {
let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.jobs.len(), 1);
assert_eq!(tf.jobs[0].script.trim(), "echo hi");
assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
}
#[test]
fn a_stray_fence_open_does_not_close_an_unterminated_block() {
let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
assert!(tf.jobs[0].script.contains("one"));
assert!(tf.jobs[0].script.contains("```sh\ntwo"));
}
#[test]
fn metadata_inside_a_paragraph_does_not_configure_the_task() {
let tf = parse(
"## t\n\nThe reviewer decides whether to set\nAgent: allow\non a task.\n\n```sh\ntrue\n```\n",
);
assert!(!tf.jobs[0].agent_allow, "prose must not open the gate");
assert!(
tf.jobs[0].description.contains("Agent: allow"),
"it is description, and stays visible as such"
);
}
#[test]
fn metadata_inside_a_paragraph_warns() {
let tf = parse("## t\n\nRun this after\nRequires: build\n\n```sh\ntrue\n```\n");
assert!(tf.jobs[0].requires.is_empty());
assert!(
tf.warnings.iter().any(|w| w.contains("inside a paragraph")),
"warnings: {:?}",
tf.warnings
);
}
#[test]
fn metadata_after_a_blank_line_still_works() {
let tf = parse(
"## t\n\nSet a module's version.\n\nArgs: module version\nRequires: lint\nAgent: allow\n\n```sh\ntrue\n```\n",
);
let j = &tf.jobs[0];
assert_eq!(j.args.len(), 2, "after a blank line");
assert_eq!(req_names(&j.requires), ["lint"], "and a run stays together");
assert!(j.agent_allow);
assert!(tf.warnings.is_empty(), "warnings: {:?}", tf.warnings);
}
#[test]
fn metadata_directly_under_the_heading_still_works() {
let tf = parse("## t\nArgs: one\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].args.len(), 1);
}
#[test]
fn opens_list_item_recognizes_the_usual_markers() {
for good in ["- a", "* a", "+ a", "1. a", "12) a", " - indented"] {
assert!(opens_list_item(good), "{good:?}");
}
for bad in ["-not a bullet", "a - b", "1.5 is a number", "", "text"] {
assert!(!opens_list_item(bad), "{bad:?}");
}
}
#[test]
fn a_description_keeps_its_paragraph_breaks() {
let tf = parse("## t\n\nFirst thought,\nwrapped.\n\nSecond thought.\n\n```sh\ntrue\n```\n");
let d = &tf.jobs[0].description;
let paras: Vec<&str> = d.split("\n\n").filter(|p| !p.trim().is_empty()).collect();
assert_eq!(paras.len(), 2, "description was {d:?}");
assert_eq!(paras[0].trim(), "First thought,\nwrapped.");
}
#[test]
fn indented_metadata_is_recognized() {
let tf = parse("## a\n\n- steps:\n Env: KEY=val\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
}
#[test]
fn repeated_metadata_lines_accumulate() {
let tf = parse("## t\n\nRequires: alpha\nRequires: beta\n\n```sh\ntrue\n```\n");
assert_eq!(req_names(&tf.jobs[0].requires), ["alpha", "beta"]);
let tf = parse("## t\n\nArgs: a\nArgs: b\n\n```sh\ntrue\n```\n");
let names: Vec<_> = tf.jobs[0].args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn a_misspelled_metadata_key_warns() {
for (typo, meant) in [("Require", "requires"), ("Arg", "args"), ("Opt", "opts")] {
let src = format!("## t\n\n{typo}: x\n\n```sh\ntrue\n```\n");
let tf = parse(&src);
assert!(
tf.warnings.iter().any(|w| w.contains(meant)),
"{typo}: should suggest {meant}, warnings were {:?}",
tf.warnings
);
}
}
#[test]
fn ordinary_prose_is_not_mistaken_for_metadata() {
let tf = parse(
"## t\n\nNote: this is a sentence.\nWarning: so is this.\nSee: the docs.\n\n```sh\ntrue\n```\n",
);
assert!(
tf.warnings.is_empty(),
"prose should not warn, got {:?}",
tf.warnings
);
assert!(tf.jobs[0].description.contains("Note: this is a sentence."));
}
#[test]
fn an_unknown_language_is_still_a_task_and_warns() {
let tf = parse("## a\n\n```shell-session\ntrue\n```\n");
assert_eq!(tf.jobs.len(), 1);
assert!(tf.warnings.iter().any(|w| w.contains("shell-session")));
assert!(
tf.warnings
.iter()
.any(|w| w.contains("running as a strict sh"))
);
}
#[test]
fn duplicate_names_warn_and_the_first_wins() {
let tf = parse("## a\n\n```sh\necho one\n```\n\n## a\n\n```sh\necho two\n```\n");
assert_eq!(tf.jobs.len(), 2);
assert!(tf.jobs[0].script.contains("one"));
assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
}
#[test]
fn a_task_option_at_file_level_says_where_it_belongs() {
let tf = parse("Opts: inherit-cwd\n\n## t\n\n```sh\ntrue\n```\n");
assert!(!tf.includes_parent());
assert!(
tf.warnings
.iter()
.any(|w| w.contains("task option belongs")),
"warnings: {:?}",
tf.warnings
);
}
#[test]
fn a_file_option_under_a_task_says_where_it_belongs() {
let tf = parse("## t\n\nOpts: include-parent\n\n```sh\ntrue\n```\n");
assert!(
tf.warnings.iter().any(|w| w.contains("file-level")),
"warnings: {:?}",
tf.warnings
);
}
#[test]
fn no_strict_is_a_known_opt_and_warns_no_one() {
let tf = parse("## t\n\nOpts: no-strict\n\n```sh\ntrue\n```\n");
assert!(
tf.warnings().is_empty(),
"no-strict must be recognized: {:?}",
tf.warnings()
);
}
}
#[cfg(test)]
mod invalid_arg_names {
use super::*;
fn file(args_line: &str) -> TaskFile {
parse(&format!(
"## build\n\nDo a thing.\n\nArgs: {args_line}\n\n```sh\necho \"$slug\"\n```\n"
))
}
#[test]
fn a_comma_separated_list_warns_and_says_why() {
let tf = file("slug, repo");
let warning = tf
.warnings()
.iter()
.find(|w| w.contains("slug,"))
.unwrap_or_else(|| panic!("no warning about `slug,` in {:?}", tf.warnings()));
assert!(warning.contains("whitespace-separated"), "{warning}");
assert!(warning.contains("Args: a b"), "{warning}");
}
#[test]
fn only_the_offending_name_is_reported() {
let tf = file("slug, repo");
assert_eq!(tf.warnings().len(), 1, "{:?}", tf.warnings());
}
#[test]
fn a_well_formed_declaration_warns_about_nothing() {
assert!(file("slug repo").warnings().is_empty());
assert!(file("slug *rest").warnings().is_empty());
assert!(file("slug msg='hello world'").warnings().is_empty());
assert!(file("_private").warnings().is_empty());
}
#[test]
fn validity_is_the_shell_identifier_rule() {
let valid = |name: &str| {
Arg {
name: name.into(),
variadic: false,
default: None,
}
.is_valid_name()
};
assert!(valid("slug"));
assert!(valid("_slug"));
assert!(valid("slug2"));
assert!(!valid("slug,"));
assert!(!valid("2slug"), "a leading digit is not an identifier");
assert!(!valid("my-slug"), "a hyphen is not an identifier");
assert!(!valid(""));
}
#[test]
fn markers_are_not_part_of_the_name() {
assert!(file("*rest").warnings().is_empty());
assert!(file("name='a, b'").warnings().is_empty());
}
}