use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Entry {
idx: usize,
name: String,
predicate: String,
reason: String,
}
struct KillCheckArgs {
plan_path: Option<String>,
state_file: Option<String>,
git_bin: String,
}
fn parse_args(args: &[String]) -> KillCheckArgs {
let mut plan_path: Option<String> = None;
let mut state_file = std::env::var("STATE_FILE").ok().filter(|s| !s.is_empty());
let mut git_bin = std::env::var("FNO_KILLCHECK_GIT_BIN").unwrap_or_else(|_| "git".to_string());
let args = if args.first().map(|s| s.as_str()) == Some("kill-check") {
&args[1..]
} else {
args
};
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if let Some(v) = arg.strip_prefix("--state-file=") {
state_file = Some(v.to_string());
} else if arg == "--state-file" {
i += 1;
if let Some(v) = args.get(i) {
state_file = Some(v.clone());
}
} else if let Some(v) = arg.strip_prefix("--git-bin=") {
git_bin = v.to_string();
} else if arg == "--git-bin" {
i += 1;
if let Some(v) = args.get(i) {
git_bin = v.clone();
}
} else if !arg.starts_with("--") && plan_path.is_none() {
plan_path = Some(arg.clone());
}
i += 1;
}
KillCheckArgs {
plan_path,
state_file,
git_bin,
}
}
struct RunOutput {
code: i32,
stdout: String,
stderr: String,
}
fn git_root(git_bin: &str) -> PathBuf {
let out = Command::new(git_bin)
.args(["rev-parse", "--show-toplevel"])
.output();
match out {
Ok(o) if o.status.success() => {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
} else {
PathBuf::from(s)
}
}
_ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}
fn resolve_warn_state_file(args: &KillCheckArgs) -> PathBuf {
if let Some(sf) = &args.state_file {
return PathBuf::from(sf);
}
git_root(&args.git_bin).join(".fno/target-state.md")
}
fn resolve_field_state_file(args: &KillCheckArgs) -> PathBuf {
git_root(&args.git_bin).join(".fno/target-state.md")
}
fn log_warn(out: &mut RunOutput, args: &KillCheckArgs, msg: &str) {
out.stderr
.push_str(&format!("kill-criteria: WARN: {msg}\n"));
let state_file = resolve_warn_state_file(args);
if state_file.is_file() {
let block = format!(
"\n## Kill Criteria Warning\n- {}: {}\n",
utc_timestamp(),
msg
);
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(&state_file) {
let _ = f.write_all(block.as_bytes());
}
}
}
fn utc_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let hour = (rem / 3600) as u32;
let min = ((rem % 3600) / 60) as u32;
let sec = (rem % 60) as u32;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = if m <= 2 { y + 1 } else { y };
format!("{year:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z")
}
fn read_plan_block(plan_path: &str) -> Option<String> {
let plan = Path::new(plan_path);
if !plan.is_file() {
return None;
}
let content = std::fs::read_to_string(plan).ok()?;
let fm = extract_frontmatter(&content);
let fm_block = extract_kill_criteria_block(&fm);
if !fm_block.is_empty() {
return Some(fm_block);
}
let fenced = extract_fenced_under_heading(&content);
let fenced_block = extract_kill_criteria_block(&fenced);
if !fenced_block.is_empty() {
return Some(fenced_block);
}
None
}
fn extract_frontmatter(content: &str) -> String {
let mut c = 0;
let mut lines: Vec<&str> = Vec::new();
for line in content.lines() {
if line.starts_with("---") {
c += 1;
if c == 2 {
break;
}
continue;
}
if c == 1 {
lines.push(line);
}
}
join_lines(&lines)
}
fn extract_fenced_under_heading(content: &str) -> String {
let mut found = false;
let mut in_fence = false;
let mut lines: Vec<&str> = Vec::new();
for line in content.lines() {
if !found {
if let Some(rest) = line.strip_prefix("## Kill Criteria") {
if rest.chars().all(|c| c == ' ' || c == '\t') {
found = true;
continue;
}
}
continue;
}
if line.starts_with("## ") {
break;
}
if line.starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
lines.push(line);
}
}
join_lines(&lines)
}
fn extract_kill_criteria_block(text: &str) -> String {
let mut in_block = false;
let mut lines: Vec<&str> = Vec::new();
for line in text.lines() {
if line.starts_with("kill_criteria:") {
in_block = true;
continue;
}
if in_block && starts_with_top_level_key(line) {
in_block = false;
}
if in_block {
lines.push(line);
}
}
join_lines(&lines)
}
fn starts_with_top_level_key(line: &str) -> bool {
let bytes = line.as_bytes();
if bytes.is_empty() {
return false;
}
let first = bytes[0];
if !(first.is_ascii_alphabetic() || first == b'_') {
return false;
}
let mut i = 1;
while i < bytes.len() {
let b = bytes[i];
if b == b':' {
return true;
}
if !(b.is_ascii_alphanumeric() || b == b'_') {
return false;
}
i += 1;
}
false
}
fn join_lines(lines: &[&str]) -> String {
if lines.is_empty() {
return String::new();
}
let mut s = String::new();
for l in lines {
s.push_str(l);
s.push('\n');
}
s
}
fn parse_entries(block: &str) -> Vec<Entry> {
let mut entries: Vec<Entry> = Vec::new();
let mut idx = 0usize;
let mut in_entry = false;
let mut name = String::new();
let mut pred = String::new();
let mut reason = String::new();
let flush = |entries: &mut Vec<Entry>,
in_entry: &mut bool,
idx: usize,
name: &mut String,
pred: &mut String,
reason: &mut String| {
if *in_entry {
entries.push(Entry {
idx,
name: std::mem::take(name),
predicate: std::mem::take(pred),
reason: std::mem::take(reason),
});
*in_entry = false;
}
};
for raw in block.lines() {
if is_list_item_start(raw) {
flush(
&mut entries,
&mut in_entry,
idx,
&mut name,
&mut pred,
&mut reason,
);
idx += 1;
in_entry = true;
let rewritten = rewrite_list_marker(raw);
apply_field(&rewritten, &mut name, &mut pred, &mut reason);
continue;
}
if in_entry {
apply_field(raw, &mut name, &mut pred, &mut reason);
}
}
flush(
&mut entries,
&mut in_entry,
idx,
&mut name,
&mut pred,
&mut reason,
);
entries
}
fn is_list_item_start(line: &str) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i == 0 {
return false;
}
if i >= bytes.len() || bytes[i] != b'-' {
return false;
}
let after = i + 1;
after < bytes.len() && (bytes[after] == b' ' || bytes[after] == b'\t')
}
fn rewrite_list_marker(line: &str) -> String {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
let mut j = i + 1; while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
format!(" {}", &line[j..])
}
fn apply_field(line: &str, name: &mut String, pred: &mut String, reason: &mut String) {
if let Some(v) = field_value(line, "name") {
*name = strip_quotes(v);
} else if let Some(v) = field_value(line, "predicate") {
*pred = strip_quotes(v);
} else if let Some(v) = field_value(line, "reason") {
*reason = strip_quotes(v);
}
}
fn field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i == 0 {
return None; }
let rest = &line[i..];
let key = format!("{field}:");
let after = rest.strip_prefix(&key)?;
let trimmed = after.trim_start_matches([' ', '\t']);
Some(trimmed)
}
fn strip_quotes(s: &str) -> String {
let mut s = s.to_string();
if let Some(first) = s.chars().next() {
if first == '"' || first == '\'' {
s.remove(0);
}
}
if let Some(last) = s.chars().last() {
if last == '"' || last == '\'' {
s.pop();
}
}
s
}
fn state_field(state_file: &Path, field: &str) -> Option<String> {
let content = std::fs::read_to_string(state_file).ok()?;
for line in content.lines() {
let trimmed = line.trim_start_matches([' ', '\t']);
let key = format!("{field}:");
if let Some(after) = trimmed.strip_prefix(&key) {
let val = after.trim_start_matches([' ', '\t']);
let val = val.trim_end_matches([' ', '\t']);
let val = val.replace('"', ""); return Some(val);
}
}
Some(String::new())
}
fn consecutive_failures(state_file: &Path) -> String {
let Ok(content) = std::fs::read_to_string(state_file) else {
return "0".to_string();
};
let mut in_block = false;
for line in content.lines() {
if line.starts_with("verification:") {
in_block = true;
continue;
}
if in_block {
if let Some(&first) = line.as_bytes().first() {
if first.is_ascii_alphabetic() || first == b'_' {
in_block = false;
}
}
}
if in_block {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i > 0 {
let rest = &line[i..];
if let Some(after) = rest.strip_prefix("consecutive_failures:") {
let v = after
.trim_start_matches([' ', '\t'])
.trim_end_matches([' ', '\t']);
return v.to_string();
}
}
}
}
"0".to_string()
}
fn is_int(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
#[derive(Debug, PartialEq, Eq)]
enum Eval {
Fired,
NotFired,
Malformed,
}
fn eval_iteration(pred: &str, state_file: &Path) -> Eval {
let Some((op, rhs)) = parse_cmp(pred, "iteration") else {
return Eval::Malformed;
};
let cur_raw = state_field(state_file, "iteration").unwrap_or_default();
let cur: i64 = if is_int(&cur_raw) {
cur_raw.parse().unwrap_or(1)
} else {
1
};
cmp_fires(cur, op, rhs)
}
fn eval_stuck_test(pred: &str, state_file: &Path) -> Eval {
let Some((op, rhs)) = parse_cmp(pred, "same_test_failing_for") else {
return Eval::Malformed;
};
let failures_raw = consecutive_failures(state_file);
let failures: i64 = if is_int(&failures_raw) {
failures_raw.parse().unwrap_or(0)
} else {
0
};
cmp_fires(failures, op, rhs)
}
fn parse_cmp(pred: &str, key: &str) -> Option<(&'static str, i64)> {
let rest = pred.strip_prefix(key)?;
let rest = rest.trim_start_matches([' ', '\t']);
let (op, after): (&'static str, &str) = if let Some(a) = rest.strip_prefix(">=") {
(">=", a)
} else if let Some(a) = rest.strip_prefix('>') {
(">", a)
} else {
return None;
};
let after = after.trim_start_matches([' ', '\t']);
let digits_end = after
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after.len());
if digits_end == 0 {
return None; }
let digits = &after[..digits_end];
let tail = &after[digits_end..];
if !tail.chars().all(|c| c == ' ' || c == '\t') {
return None; }
let rhs: i64 = digits.parse().ok()?;
Some((op, rhs))
}
fn cmp_fires(lhs: i64, op: &str, rhs: i64) -> Eval {
let fired = match op {
">" => lhs > rhs,
">=" => lhs >= rhs,
_ => false,
};
if fired {
Eval::Fired
} else {
Eval::NotFired
}
}
fn eval_files_outside(pred: &str, plan_path: &str, git_bin: &str) -> Eval {
let rest = match pred.strip_prefix("files_outside(plan_path)") {
Some(r) => r.trim_start_matches([' ', '\t']),
None => return Eval::Malformed,
};
let after = match rest.strip_prefix('>') {
Some(a) => a.trim_start_matches([' ', '\t']),
None => return Eval::Malformed,
};
let digits_end = after
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after.len());
if digits_end == 0 {
return Eval::Malformed;
}
let tail = &after[digits_end..];
if !tail.chars().all(|c| c == ' ' || c == '\t') {
return Eval::Malformed;
}
let rhs: i64 = match after[..digits_end].parse() {
Ok(v) => v,
Err(_) => return Eval::Malformed,
};
let root = match git_show_toplevel(git_bin) {
Some(r) => r,
None => return Eval::NotFired,
};
let base = resolve_baseline(git_bin, &root);
let mut raw = String::new();
if let Some(b) = &base {
raw.push_str(&git_diff_name_only(
git_bin,
&root,
&["diff", "--name-only", b, "HEAD"],
));
}
raw.push_str(&git_diff_name_only(
git_bin,
&root,
&["diff", "--name-only"],
));
raw.push_str(&git_diff_name_only(
git_bin,
&root,
&["diff", "--name-only", "--cached"],
));
let diff_list = sort_unique_nonempty(&raw);
let plan_path_abs = std::fs::canonicalize(plan_path)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| plan_path.to_string());
let root_str = root.to_string_lossy();
let rel_inside: Option<String> = {
let prefix = format!("{root_str}/");
plan_path_abs.strip_prefix(&prefix).map(|s| s.to_string())
};
let mut count: i64 = 0;
for f in &diff_list {
if f.is_empty() {
continue;
}
if let Some(rel) = &rel_inside {
if f == rel {
continue;
}
if f.starts_with(&format!("{rel}/")) {
continue;
}
}
count += 1;
}
if count > rhs {
Eval::Fired
} else {
Eval::NotFired
}
}
fn eval_test_file_deleted(pred: &str, git_bin: &str) -> Eval {
let rest = match pred.strip_prefix("any_test_file_deleted") {
Some(r) => r,
None => return Eval::Malformed,
};
if !rest.chars().all(|c| c == ' ' || c == '\t') {
return Eval::Malformed;
}
let root = match git_show_toplevel(git_bin) {
Some(r) => r,
None => return Eval::NotFired,
};
let status = git_status_porcelain(git_bin, &root);
for line in status.lines() {
if let Some(path) = porcelain_candidate_path(line) {
if is_test_path(&path) {
return Eval::Fired;
}
}
}
Eval::NotFired
}
fn porcelain_candidate_path(line: &str) -> Option<String> {
let matches_pattern = {
let lead_trimmed = line.trim_start_matches([' ', '\t']);
let d_space = lead_trimmed.starts_with("D ");
let r_space = lead_trimmed.starts_with("R ");
let d_at_zero = {
let b = line.as_bytes();
b.first() == Some(&b'D') && b.get(1).map(|&c| c == b' ' || c == b'\t').unwrap_or(false)
};
d_space || r_space || d_at_zero
};
if !matches_pattern {
return None;
}
let fields: Vec<&str> = line.split_whitespace().collect();
fields.get(1).map(|s| s.to_string())
}
fn is_test_path(path: &str) -> bool {
if path_has_dir_segment(path, "__tests__")
|| path_has_dir_segment(path, "tests")
|| path_has_dir_segment(path, "test")
|| path_has_dir_segment(path, "spec")
{
return true;
}
let base = path.rsplit('/').next().unwrap_or(path);
if has_double_ext(
base,
&["test", "spec"],
&["ts", "tsx", "js", "jsx", "py", "sh"],
) {
return true;
}
if base.starts_with("test_") && (base.ends_with(".py") || base.ends_with(".sh")) {
return true;
}
if (base.ends_with(".py") || base.ends_with(".go")) && {
let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
stem.ends_with("_test")
} {
return true;
}
false
}
fn path_has_dir_segment(path: &str, seg: &str) -> bool {
let needle = format!("{seg}/");
let mut start = 0;
while let Some(pos) = path[start..].find(&needle) {
let abs = start + pos;
let preceded_ok = abs == 0 || path.as_bytes()[abs - 1] == b'/';
if preceded_ok {
return true;
}
start = abs + 1;
}
false
}
fn has_double_ext(base: &str, mids: &[&str], exts: &[&str]) -> bool {
let Some((rest, ext)) = base.rsplit_once('.') else {
return false;
};
if !exts.contains(&ext) {
return false;
}
let Some((_, mid)) = rest.rsplit_once('.') else {
return false;
};
mids.contains(&mid)
}
fn git_show_toplevel(git_bin: &str) -> Option<PathBuf> {
let out = Command::new(git_bin)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(PathBuf::from(s))
}
}
fn resolve_baseline(git_bin: &str, root: &Path) -> Option<String> {
let upstream = Command::new(git_bin)
.current_dir(root)
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
if let Some(u) = upstream {
if let Some(base) = git_merge_base(git_bin, root, "HEAD", &u) {
return Some(base);
}
}
for candidate in ["origin/main", "origin/master", "origin/HEAD"] {
if let Some(base) = git_merge_base(git_bin, root, "HEAD", candidate) {
if !base.is_empty() {
return Some(base);
}
}
}
None
}
fn git_merge_base(git_bin: &str, root: &Path, a: &str, b: &str) -> Option<String> {
let out = Command::new(git_bin)
.current_dir(root)
.args(["merge-base", a, b])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn git_diff_name_only(git_bin: &str, root: &Path, args: &[&str]) -> String {
let out = Command::new(git_bin).current_dir(root).args(args).output();
match out {
Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
if !s.ends_with('\n') && !s.is_empty() {
s.push('\n');
}
s
}
Err(_) => String::new(),
}
}
fn git_status_porcelain(git_bin: &str, root: &Path) -> String {
let out = Command::new(git_bin)
.current_dir(root)
.args(["status", "--porcelain"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
Err(_) => String::new(),
}
}
fn sort_unique_nonempty(raw: &str) -> Vec<String> {
let mut v: Vec<String> = raw
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect();
v.sort();
v.dedup();
v
}
fn dispatch_predicate(pred: &str, plan_path: &str, state_file: &Path, git_bin: &str) -> Eval {
if pred.starts_with("iteration") {
eval_iteration(pred, state_file)
} else if pred.starts_with("same_test_failing_for") {
eval_stuck_test(pred, state_file)
} else if pred.starts_with("files_outside(plan_path)") {
eval_files_outside(pred, plan_path, git_bin)
} else if pred.starts_with("any_test_file_deleted") {
eval_test_file_deleted(pred, git_bin)
} else {
Eval::Malformed
}
}
fn run(args: &[String]) -> RunOutput {
let mut out = RunOutput {
code: 0,
stdout: String::new(),
stderr: String::new(),
};
let parsed = parse_args(args);
let plan_path = match &parsed.plan_path {
Some(p) if !p.is_empty() => p.clone(),
_ => {
log_warn(
&mut out,
&parsed,
"check_kill_criteria called without plan_path - skipping",
);
return out;
}
};
let block = match read_plan_block(&plan_path) {
Some(b) if !b.is_empty() => b,
_ => return out,
};
let entries = parse_entries(&block);
if entries.is_empty() {
return out;
}
let state_file = resolve_field_state_file(&parsed);
for ent in &entries {
if ent.name.is_empty() || ent.predicate.is_empty() {
log_warn(
&mut out,
&parsed,
&format!(
"kill_criteria entry {} missing required fields - skipping",
ent.idx
),
);
continue;
}
match dispatch_predicate(&ent.predicate, &plan_path, &state_file, &parsed.git_bin) {
Eval::Fired => {
let reason = if ent.reason.is_empty() {
ent.name.clone()
} else {
ent.reason.clone()
};
out.stdout
.push_str(&format!("KILL_CRITERIA_FIRED {}|{}\n", ent.name, reason));
out.code = 1;
return out;
}
Eval::Malformed => {
log_warn(
&mut out,
&parsed,
&format!(
"kill_criteria entry '{}' predicate '{}' is unparseable - skipping",
ent.name, ent.predicate
),
);
}
Eval::NotFired => {}
}
}
out
}
pub fn run_kill_check(args: &[String]) -> i32 {
let out = run(args);
if !out.stdout.is_empty() {
print!("{}", out.stdout);
}
if !out.stderr.is_empty() {
eprint!("{}", out.stderr);
}
out.code
}
pub fn run_kill_check_capture(args: &[String]) -> (i32, String, String) {
let out = run(args);
(out.code, out.stdout, out.stderr)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cmp_iteration_forms() {
assert_eq!(parse_cmp("iteration > 3", "iteration"), Some((">", 3)));
assert_eq!(parse_cmp("iteration>=10", "iteration"), Some((">=", 10)));
assert_eq!(parse_cmp("iteration > 5 ", "iteration"), Some((">", 5)));
assert_eq!(parse_cmp("iteration < 3", "iteration"), None);
assert_eq!(parse_cmp("iteration > x", "iteration"), None);
}
#[test]
fn strip_quotes_one_each_end() {
assert_eq!(strip_quotes("\"hello\""), "hello");
assert_eq!(strip_quotes("'hi'"), "hi");
assert_eq!(strip_quotes("plain"), "plain");
assert_eq!(strip_quotes("\"only-left"), "only-left");
}
#[test]
fn top_level_key_anchoring() {
assert!(starts_with_top_level_key("waves:"));
assert!(starts_with_top_level_key("kill_criteria:"));
assert!(!starts_with_top_level_key(" - name: foo"));
assert!(!starts_with_top_level_key(" predicate: x"));
assert!(!starts_with_top_level_key("123abc:"));
}
#[test]
fn parse_entries_inline_and_block() {
let block = " - name: iter\n predicate: iteration > 3\n reason: too many\n - name: stuck\n predicate: same_test_failing_for >= 5\n";
let entries = parse_entries(block);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "iter");
assert_eq!(entries[0].predicate, "iteration > 3");
assert_eq!(entries[0].reason, "too many");
assert_eq!(entries[1].name, "stuck");
assert_eq!(entries[1].predicate, "same_test_failing_for >= 5");
assert_eq!(entries[1].reason, "");
}
#[test]
fn test_path_classification() {
assert!(is_test_path("src/__tests__/foo.js"));
assert!(is_test_path("tests/test_x.py"));
assert!(is_test_path("a/b/spec/thing.rb"));
assert!(is_test_path("foo.test.ts"));
assert!(is_test_path("bar.spec.jsx"));
assert!(is_test_path("test_helper.py"));
assert!(is_test_path("widget_test.go"));
assert!(is_test_path("module_test.py"));
assert!(!is_test_path("src/main.rs"));
assert!(!is_test_path("README.md"));
assert!(!is_test_path("path/to/tests"));
}
#[test]
fn double_ext_matcher() {
assert!(has_double_ext(
"foo.test.ts",
&["test", "spec"],
&["ts", "tsx", "js", "jsx", "py", "sh"]
));
assert!(!has_double_ext(
"foo.test.rs",
&["test", "spec"],
&["ts", "tsx", "js", "jsx", "py", "sh"]
));
assert!(!has_double_ext(
"foo.ts",
&["test", "spec"],
&["ts", "tsx", "js", "jsx", "py", "sh"]
));
}
#[test]
fn porcelain_path_extraction() {
assert_eq!(
porcelain_candidate_path(" D tests/test_x.py").as_deref(),
Some("tests/test_x.py")
);
assert_eq!(
porcelain_candidate_path("D tests/test_x.py").as_deref(),
Some("tests/test_x.py")
);
assert_eq!(
porcelain_candidate_path("R old_test.py -> new.py").as_deref(),
Some("old_test.py")
);
assert_eq!(porcelain_candidate_path(" M src/main.rs"), None);
}
}