use std::path::{Path, PathBuf};
use crate::model::TaskFile;
use crate::parse::parse;
pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
let mut found = Vec::new();
for (depth, dir) in start.ancestors().enumerate() {
for name in ["tasks.md", "maskfile.md", "README.md"] {
let path = dir.join(name);
if let Some(src) = read_candidate(&path, depth == 0) {
let tf = parse(&src);
if !tf.jobs.is_empty() {
let inherits = tf.includes_parent();
found.push((path, tf));
if !inherits {
return found;
}
break; }
}
}
}
found
}
const MAX_TASK_FILE: u64 = 4 * 1024 * 1024;
fn read_candidate(path: &Path, explicit: bool) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > MAX_TASK_FILE {
return None;
}
#[cfg(target_os = "macos")]
if !explicit && is_dataless(&meta) {
return None;
}
let _ = explicit;
std::fs::read_to_string(path).ok()
}
#[cfg(target_os = "macos")]
fn is_dataless(meta: &std::fs::Metadata) -> bool {
use std::os::macos::fs::MetadataExt;
const SF_DATALESS: u32 = 0x4000_0000;
meta.st_flags() & SF_DATALESS != 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_task_files_layers_child_over_parent() {
let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
let child = base.join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(
base.join("tasks.md"),
"## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
)
.unwrap();
std::fs::write(
child.join("tasks.md"),
"Opts: include-parent\n\n## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
)
.unwrap();
let files = find_task_files(&child);
assert_eq!(files.len(), 2, "child and parent files found");
assert!(files[0].0.starts_with(&child));
assert_eq!(
files[0].1.job("shared").unwrap().script.trim(),
"echo child"
);
assert!(files[1].1.job("base").is_some());
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn the_walk_stops_at_the_first_file_by_default() {
let base = std::env::temp_dir().join(format!("mdtask-stop-{}", std::process::id()));
let child = base.join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(
base.join("tasks.md"),
"## build\n\n```sh\necho hijacked\n```\n",
)
.unwrap();
std::fs::write(child.join("tasks.md"), "## only\n\n```sh\ntrue\n```\n").unwrap();
let files = find_task_files(&child);
std::fs::remove_dir_all(&base).ok();
assert_eq!(files.len(), 1, "the parent is not consulted");
assert!(
files[0].1.job("build").is_none(),
"and cannot supply a name"
);
}
#[test]
fn opting_in_is_per_file_all_the_way_up() {
let base = std::env::temp_dir().join(format!("mdtask-chain-{}", std::process::id()));
let mid = base.join("mid");
let leaf = mid.join("leaf");
std::fs::create_dir_all(&leaf).unwrap();
std::fs::write(base.join("tasks.md"), "## top\n\n```sh\ntrue\n```\n").unwrap();
std::fs::write(
mid.join("tasks.md"),
"Opts: include-parent\n\n## middle\n\n```sh\ntrue\n```\n",
)
.unwrap();
std::fs::write(
leaf.join("tasks.md"),
"Opts: include-parent\n\n## leaf\n\n```sh\ntrue\n```\n",
)
.unwrap();
let files = find_task_files(&leaf);
std::fs::remove_dir_all(&base).ok();
assert_eq!(files.len(), 3, "leaf, mid, top");
assert!(files[2].1.job("top").is_some());
}
#[cfg(unix)]
#[test]
fn a_fifo_task_file_does_not_hang_the_walk() {
let base = std::env::temp_dir().join(format!("mdtask-fifo-{}", std::process::id()));
let child = base.join("child");
std::fs::create_dir_all(&child).unwrap();
let fifo = base.join("tasks.md");
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !made {
std::fs::remove_dir_all(&base).ok();
return; }
std::fs::write(child.join("tasks.md"), "## only\n\n```sh\ntrue\n```\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let probe = child.clone();
std::thread::spawn(move || {
let _ = tx.send(find_task_files(&probe).len());
});
let found = rx.recv_timeout(std::time::Duration::from_secs(10));
std::fs::remove_dir_all(&base).ok();
let found = found.expect("the walk returned instead of blocking on the FIFO");
assert_eq!(
found, 1,
"the FIFO was skipped and the real file still read"
);
}
#[test]
fn an_oversized_candidate_is_skipped() {
let base = std::env::temp_dir().join(format!("mdtask-big-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let path = base.join("tasks.md");
let real = "## only\n\n```sh\ntrue\n```\n";
std::fs::write(&path, real).unwrap();
assert!(
read_candidate(&path, true).is_some(),
"an ordinary file reads"
);
let padding = "x".repeat(MAX_TASK_FILE as usize + 1);
std::fs::write(&path, padding).unwrap();
assert!(
read_candidate(&path, true).is_none(),
"an oversized one does not"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn a_directory_named_like_a_task_file_is_skipped() {
let base = std::env::temp_dir().join(format!("mdtask-dir-{}", std::process::id()));
let child = base.join("child");
std::fs::create_dir_all(child.join("tasks.md")).unwrap();
std::fs::write(base.join("tasks.md"), "## only\n\n```sh\ntrue\n```\n").unwrap();
let files = find_task_files(&child);
std::fs::remove_dir_all(&base).ok();
assert_eq!(files.len(), 1);
assert!(
files[0].1.job("only").is_some(),
"the real one, one level up"
);
}
}