use std::path::Path;
use crate::plan::Node;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Checkable {
criterion: String,
file: BranchPath,
literal: String,
}
impl Checkable {
pub(crate) fn criterion(&self) -> &str {
&self.criterion
}
pub(crate) fn file(&self) -> &str {
self.file.as_str()
}
pub(crate) fn literal(&self) -> &str {
&self.literal
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BranchPath(String);
impl BranchPath {
fn named(span: &str) -> Option<Self> {
if span.is_empty()
|| span.chars().any(char::is_whitespace)
|| span.starts_with('/')
|| span.contains('\\')
|| span.contains(':')
|| span.split('/').any(|segment| segment == "..")
{
return None;
}
let lettered_extension = span
.rsplit('/')
.next()
.and_then(|name| name.rsplit_once('.'))
.is_some_and(|(stem, extension)| {
!stem.is_empty()
&& !extension.is_empty()
&& extension.chars().all(|c| c.is_ascii_alphabetic())
});
(span.contains('/') || lettered_extension).then(|| Self(span.to_string()))
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Answer {
Match,
Mismatch { holds: String },
Unread { reason: String },
}
impl Answer {
pub(crate) const fn as_str(&self) -> &'static str {
match self {
Self::Match => "match",
Self::Mismatch { .. } => "mismatch",
Self::Unread { .. } => "unread",
}
}
}
const MAX_FILE_BYTES: u64 = 1 << 20;
const CRITERIA_HEADING: &str = "## Acceptance criteria";
pub(crate) fn checkable_of(node: &Node) -> Vec<Checkable> {
let steps = node
.steps
.iter()
.flatten()
.filter_map(|step| step.task.as_deref());
let mut found: Vec<Checkable> = Vec::new();
for task in node
.task
.as_deref()
.into_iter()
.chain(node.amendment.as_deref())
.chain(steps)
{
for check in checkable(task) {
if !found.contains(&check) {
found.push(check);
}
}
}
found
}
pub(crate) fn checkable(task: &str) -> Vec<Checkable> {
criteria_in(task)
.into_iter()
.filter_map(|criterion| parse(&criterion))
.collect()
}
pub(crate) fn answer(root: &Path, check: &Checkable) -> Answer {
let declined = |reason: String| Answer::Unread { reason };
let (root, path) = match (root.canonicalize(), root.join(check.file()).canonicalize()) {
(Ok(root), Ok(path)) => (root, path),
(Err(error), _) | (_, Err(error)) => return declined(error.to_string()),
};
if !path.starts_with(&root) {
return declined(format!(
"`{}` resolves to {}, which is outside the node's branch",
check.file(),
path.display()
));
}
match std::fs::metadata(&path).map(|file| file.len()) {
Err(error) => return declined(error.to_string()),
Ok(bytes) if bytes > MAX_FILE_BYTES => {
return declined(format!(
"`{}` is {bytes} bytes, past the {MAX_FILE_BYTES} this reads",
check.file()
))
}
Ok(_) => {}
}
match std::fs::read_to_string(&path) {
Err(error) => declined(error.to_string()),
Ok(text) if text.contains(check.literal()) => Answer::Match,
Ok(text) => Answer::Mismatch {
holds: holds(&text, check.literal()),
},
}
}
fn holds(text: &str, literal: &str) -> String {
let key = literal
.split_once([':', '='])
.map_or(literal, |(key, _)| key)
.trim();
let named = (!key.is_empty())
.then(|| text.lines().find(|line| line.contains(key)))
.flatten();
match named {
Some(line) => format!("`{}`", one_line(line.trim())),
None => format!("nothing naming `{}`", one_line(key)),
}
}
fn one_line(text: &str) -> String {
const LIMIT: usize = 200;
let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
match flattened.char_indices().nth(LIMIT) {
None => flattened,
Some((at, _)) => format!("{}…", &flattened[..at]),
}
}
fn criteria_in(task: &str) -> Vec<String> {
let mut criteria: Vec<String> = Vec::new();
let mut inside = false;
let mut open = false;
for line in task.lines() {
let trimmed = line.trim();
if trimmed.starts_with("##") {
inside = trimmed == CRITERIA_HEADING;
open = false;
continue;
}
if !inside {
continue;
}
match trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
Some(bullet) => {
criteria.push(bullet.trim().to_string());
open = true;
}
None if trimmed.is_empty() || line.starts_with(char::is_alphanumeric) => open = false,
None => {
if let (true, Some(last)) = (open, criteria.last_mut()) {
last.push(' ');
last.push_str(trimmed);
}
}
}
}
criteria
}
fn parse(criterion: &str) -> Option<Checkable> {
if negated(criterion) || !criterion.matches('`').count().is_multiple_of(2) {
return None;
}
let spans: Vec<&str> = criterion
.split('`')
.skip(1)
.step_by(2)
.map(str::trim)
.collect();
let [first, second] = spans[..] else {
return None;
};
let (file, literal) = match (BranchPath::named(first), BranchPath::named(second)) {
(Some(file), None) => (file, second),
(None, Some(file)) => (file, first),
_ => return None,
};
(!literal.is_empty()).then(|| Checkable {
criterion: criterion.to_string(),
file,
literal: literal.to_string(),
})
}
fn negated(criterion: &str) -> bool {
const WORDS: &[&str] = &[
"no", "not", "never", "neither", "nor", "nothing", "none", "without", "cannot",
];
const PHRASES: &[&str] = &["rather than", "instead of"];
let lowered = criterion.to_lowercase();
PHRASES.iter().any(|phrase| lowered.contains(phrase))
|| lowered
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.any(|word| WORDS.contains(&word) || word.ends_with("n't"))
}
#[cfg(test)]
mod tests {
use super::*;
fn task(criterion: &str) -> String {
format!("## What\nShip it.\n\n## Acceptance criteria\n\n- {criterion}\n")
}
#[test]
fn a_criterion_naming_a_file_and_a_literal_is_the_one_shape_this_reads() {
let found = checkable(&task(
"the shared journey row in `tests/e2e/shared.rs` is `complete_dataset: true`",
));
assert_eq!(
found,
vec![Checkable {
criterion: "the shared journey row in `tests/e2e/shared.rs` is \
`complete_dataset: true`"
.to_string(),
file: BranchPath("tests/e2e/shared.rs".to_string()),
literal: "complete_dataset: true".to_string(),
}]
);
}
#[test]
fn the_file_and_the_literal_are_read_off_the_sentence_in_either_order() {
let found = checkable(&task("`0.17.5` is the version `Cargo.toml` declares"));
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].file.as_str(), "Cargo.toml");
assert_eq!(found[0].literal, "0.17.5");
}
#[test]
fn a_criterion_this_cannot_parse_is_silence() {
for prose in [
"the run is faster than it was",
"`src/engine.rs` is tidier",
"the row is `complete_dataset: true`",
"`src/a.rs` and `src/b.rs` both hold `version: 1`",
"`src/a.rs` matches `src/b.rs`",
"`version: 1` is not `version: 2`",
"`src/engine.rs` no longer holds `unwrap()`",
"`src/engine.rs` does not hold `panic!(`",
"`src/engine.rs` holds `expect(` rather than `unwrap(`",
"`/etc/passwd` holds `root: yes`",
"`../elsewhere/notes.md` holds `state: done`",
"`C:\\elsewhere\\notes.md` holds `state: done`",
"`C:notes.md` holds `state: done`",
"`` is `state: done`",
"`notes.md` holds `state: done",
] {
assert_eq!(
checkable(&task(prose)),
vec![],
"read a bar out of: {prose}"
);
}
}
#[test]
fn only_the_acceptance_criteria_section_is_a_bar() {
let task = "## What\nThe row in `notes.md` is `state: done`.\n\n\
## Acceptance criteria\n\n- it ships\n\n\
## Additional info\n\n- `other.md` holds `state: done`\n";
assert_eq!(checkable(task), vec![]);
}
#[test]
fn a_bullet_ends_at_a_blank_line_and_prose_after_it_joins_nothing() {
let task = "## Acceptance criteria\n\n- it ships\n\nAnd separately:\n the row in `notes.md` is `state: done`\n";
assert_eq!(checkable(task), vec![]);
}
#[test]
fn a_criterion_wrapped_over_lines_is_one_criterion() {
let task = "## Acceptance criteria\n\n- the row in `notes.md`\n is `state: done`\n";
let found = checkable(task);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].literal, "state: done");
assert!(
found[0]
.criterion
.contains("the row in `notes.md` is `state: done`"),
"the criterion was not rejoined: {found:?}"
);
}
#[test]
fn a_node_states_its_bar_in_its_task_its_amendment_and_its_steps_and_says_each_once() {
let node = Node {
id: "service".into(),
task: Some(task("`notes.md` holds `state: done`")),
amendment: Some(task("`version.txt` holds `v: 2`")),
steps: Some(vec![
crate::plan::Step {
id: "one".into(),
task: Some(task("`notes.md` holds `state: done`")),
..crate::plan::Step::default()
},
crate::plan::Step {
id: "two".into(),
task: Some(task("`rows.csv` holds `count: 3`")),
..crate::plan::Step::default()
},
]),
..Node::default()
};
let files: Vec<String> = checkable_of(&node)
.into_iter()
.map(|check| check.file.as_str().to_string())
.collect();
assert_eq!(files, ["notes.md", "version.txt", "rows.csv"]);
}
#[test]
fn a_file_that_holds_the_literal_matches_and_one_that_does_not_says_what_it_holds() {
let dir = tempdir("holds");
std::fs::write(dir.join("notes.md"), "state: done\n").expect("the file writes");
let check = |literal: &str| Checkable {
criterion: format!("`notes.md` holds `{literal}`"),
file: BranchPath("notes.md".into()),
literal: literal.into(),
};
assert_eq!(answer(&dir, &check("state: done")), Answer::Match);
assert_eq!(
answer(&dir, &check("state: shipped")),
Answer::Mismatch {
holds: "`state: done`".into()
}
);
assert_eq!(
answer(&dir, &check("owner: nobody")),
Answer::Mismatch {
holds: "nothing naming `owner`".into()
}
);
}
#[test]
fn a_file_the_branch_will_not_give_up_is_neither_answer() {
let dir = tempdir("unread");
std::fs::create_dir(dir.join("rows.md")).expect("a directory where a file was named");
let check = Checkable {
criterion: "`rows.md` holds `state: done`".into(),
file: BranchPath("rows.md".into()),
literal: "state: done".into(),
};
let answered = answer(&dir, &check);
assert_eq!(answered.as_str(), "unread", "{answered:?}");
let absent = Checkable {
file: BranchPath("gone.md".into()),
..check
};
assert_eq!(answer(&dir, &absent).as_str(), "unread");
}
#[test]
fn a_file_past_the_bound_is_not_read() {
let dir = tempdir("bounded-file");
let check = Checkable {
criterion: "`vendor/blob.rlib` holds `state: done`".into(),
file: BranchPath("vendor/blob.rlib".into()),
literal: "state: done".into(),
};
std::fs::create_dir_all(dir.join("vendor")).expect("a directory");
let path = dir.join("vendor/blob.rlib");
let padding = "-".repeat(usize::try_from(MAX_FILE_BYTES).expect("the bound fits"));
std::fs::write(&path, format!("state: done\n{padding}")).expect("the file writes");
let answered = answer(&dir, &check);
assert_eq!(answered.as_str(), "unread", "{answered:?}");
let Answer::Unread { reason } = answered else {
panic!("the check did not decline")
};
assert!(
reason.contains("vendor/blob.rlib") && reason.contains("past the"),
"the refusal does not say the file was too big to read: {reason}"
);
std::fs::write(&path, "state: done\n").expect("the file writes");
assert_eq!(answer(&dir, &check), Answer::Match);
}
#[cfg(unix)]
#[test]
fn a_path_the_branch_resolves_outside_the_worktree_is_not_read() {
let dir = tempdir("symlink");
let outside = dir.join("outside");
std::fs::create_dir_all(&outside).expect("somewhere off the branch");
std::fs::write(outside.join("secret.md"), "state: done\n").expect("the file writes");
let branch = dir.join("worktree");
std::fs::create_dir_all(&branch).expect("a worktree");
std::os::unix::fs::symlink(outside.join("secret.md"), branch.join("notes.md"))
.expect("a symlink out of the worktree");
let check = Checkable {
criterion: "`notes.md` holds `state: done`".into(),
file: BranchPath("notes.md".into()),
literal: "state: done".into(),
};
let answered = answer(&branch, &check);
assert_eq!(answered.as_str(), "unread", "{answered:?}");
let Answer::Unread { reason } = answered else {
panic!("the check did not decline")
};
assert!(
reason.contains("outside the node's branch"),
"the refusal does not say the path left the branch: {reason}"
);
}
#[test]
fn what_a_file_holds_is_bounded_to_one_readable_line() {
let dir = tempdir("bounded");
let long = format!("state: {}\n", "x".repeat(500));
std::fs::write(dir.join("notes.md"), &long).expect("the file writes");
let Answer::Mismatch { holds } = answer(
&dir,
&Checkable {
criterion: "`notes.md` holds `state: done`".into(),
file: BranchPath("notes.md".into()),
literal: "state: done".into(),
},
) else {
panic!("a file holding another value is a mismatch");
};
assert!(holds.ends_with("…`") || holds.ends_with('…'), "{holds}");
assert!(holds.chars().count() < 220, "{holds}");
}
#[test]
fn the_three_answers_are_the_ones_the_divergence_record_names() {
let record = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract-divergences.md"),
)
.expect("the divergence record ships");
let entry = record
.split("\n## ")
.find(|entry| entry.starts_with("47."))
.expect("the record still carries entry 47");
let block = entry
.split("```json")
.nth(1)
.and_then(|rest| rest.split("```").next())
.expect("entry 47 carries the json block this test drives");
let named: Vec<String> = serde_json::from_str::<serde_json::Value>(block)
.ok()
.and_then(|block| serde_json::from_value(block["answers"].clone()).ok())
.expect("entry 47 names its answers");
let spelled = |answer: &Answer| match answer {
Answer::Match => "match",
Answer::Mismatch { .. } => "mismatch",
Answer::Unread { .. } => "unread",
};
let mine: Vec<String> = [
Answer::Match,
Answer::Mismatch {
holds: String::new(),
},
Answer::Unread {
reason: String::new(),
},
]
.iter()
.map(|answer| {
assert_eq!(spelled(answer), answer.as_str(), "{answer:?}");
answer.as_str().to_string()
})
.collect();
assert_eq!(mine, named);
}
fn tempdir(case: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"onepipeline-criteria-{}-{case}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch directory");
dir
}
}