use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Lane {
#[default]
Code,
Planning,
Yours,
}
impl Lane {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Code => "code",
Self::Planning => "planning",
Self::Yours => "yours",
}
}
#[must_use]
pub const fn mark(self) -> &'static str {
match self {
Self::Code => "",
Self::Planning => "๐",
Self::Yours => "๐",
}
}
#[must_use]
pub const fn is_code(&self) -> bool {
matches!(self, Self::Code)
}
}
#[must_use]
pub fn by_paths(title: &str, unit_dir: &str, judged: &dyn Fn(&str) -> bool) -> Lane {
let dir = unit_dir.trim_matches('/');
let paths = task_paths(title, dir);
let inside = |p: &str| {
let p = p.trim_end_matches('/');
let rel = if p == dir {
Some("")
} else {
p.strip_prefix(dir).and_then(|r| r.strip_prefix('/'))
};
rel.is_some_and(|r| !r.split('/').any(|s| s == "..") && (r.is_empty() || !judged(r)))
};
if !dir.is_empty() && !paths.is_empty() && paths.iter().all(|p| inside(p)) {
Lane::Planning
} else {
Lane::Code
}
}
#[must_use]
pub fn task_paths(title: &str, unit_dir: &str) -> Vec<String> {
let dir = unit_dir.trim_matches('/');
let quoted = crate::scope::quoted_paths(title);
let bare = crate::scope::named_paths(title)
.into_iter()
.filter(|p| !quoted.contains(p))
.filter(|p| {
let file = p.rsplit('/').next().unwrap_or(p);
p.ends_with('/')
|| file
.rsplit_once('.')
.is_some_and(|(stem, _)| !stem.is_empty())
|| (!dir.is_empty() && (p == dir || p.starts_with(&format!("{dir}/"))))
});
let mut out = quoted.clone();
out.extend(bare);
out
}
#[must_use]
pub fn classify(
title: &str,
labelled_yours: bool,
stopped_blocked: bool,
unit_dir: &str,
judged: &dyn Fn(&str) -> bool,
) -> Lane {
if labelled_yours || stopped_blocked {
Lane::Yours
} else {
by_paths(title, unit_dir, judged)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Lanes {
pub code: usize,
pub planning: Vec<String>,
pub yours: Vec<String>,
}
impl Lanes {
#[must_use]
pub fn of<'a>(tasks: impl IntoIterator<Item = (&'a str, Lane)>) -> Self {
let mut out = Self::default();
for (id, lane) in tasks {
match lane {
Lane::Code => out.code += 1,
Lane::Planning => out.planning.push(id.to_owned()),
Lane::Yours => out.yours.push(id.to_owned()),
}
}
out
}
#[must_use]
pub fn notable(&self) -> bool {
!self.planning.is_empty() || !self.yours.is_empty()
}
#[must_use]
pub fn line(&self) -> String {
let total = self.code + self.planning.len() + self.yours.len();
let mut parts = vec![
format!("{total} task{}", if total == 1 { "" } else { "s" }),
format!("{} code", self.code),
];
for (ids, lane) in [(&self.planning, Lane::Planning), (&self.yours, Lane::Yours)] {
if !ids.is_empty() {
parts.push(format!(
"{} {} {} ({})",
ids.len(),
lane.mark(),
lane.as_str(),
ids.join(", ")
));
}
}
parts.join(" ยท ")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn judged(rel: &str) -> bool {
matches!(rel, "spec.md" | "tasks.md" | "review.json")
|| rel == "checklists"
|| rel.starts_with("checklists/")
}
const DIR: &str = "specs/001-x";
#[test]
fn a_task_inside_the_unit_is_planning() {
assert_eq!(
by_paths(
"Create the feature documentation scaffold under `/specs/001-x/contracts/` for input/output interface definitions",
DIR,
&judged
),
Lane::Planning
);
assert_eq!(
by_paths("Write specs/001-x/data-model.md", DIR, &judged),
Lane::Planning
);
assert_eq!(
by_paths("Fill in `specs/001-x/`", DIR, &judged),
Lane::Planning
);
}
#[test]
fn mixed_paths_judged_files_and_no_paths_are_code() {
assert_eq!(
by_paths(
"Create the directories in `/specs/001-x/plan.md` at `src/`, `tests/unit/`",
DIR,
&judged
),
Lane::Code
);
assert_eq!(
by_paths("Update specs/001-x/tasks.md", DIR, &judged),
Lane::Code
);
assert_eq!(
by_paths("Add specs/001-x/checklists/ux.md", DIR, &judged),
Lane::Code
);
assert_eq!(by_paths("Make it faster", DIR, &judged), Lane::Code);
assert_eq!(
by_paths("Write specs/002-y/contracts/a.md", DIR, &judged),
Lane::Code
);
assert_eq!(
by_paths("Write specs/001-xy/a.md", DIR, &judged),
Lane::Code
);
assert_eq!(
by_paths("Write specs/001-x/../../src/a.rs", DIR, &judged),
Lane::Code
);
assert_eq!(by_paths("Write specs/001-x/a.md", "", &judged), Lane::Code);
}
#[test]
fn a_task_naming_code_beside_a_design_doc_is_code() {
for line in [
"Implement the parser in src/parse.rs per `specs/001-x/contracts/parser.md`",
"Write tests in tests/cli.rs for `specs/001-x/quickstart.md`",
] {
assert_eq!(by_paths(line, DIR, &judged), Lane::Code, "{line}");
}
assert_eq!(
by_paths(
"Implement the CLI described in `specs/001-x/contracts/cli.md`",
DIR,
&judged
),
Lane::Planning
);
assert_eq!(
task_paths("Scaffold `specs/001-x/contracts/` for input/output", DIR),
["specs/001-x/contracts/"]
);
assert_eq!(
by_paths("Write specs/001-x/research", DIR, &judged),
Lane::Planning
);
}
#[test]
fn a_label_or_an_honest_stop_makes_it_yours() {
assert_eq!(
classify("Create the GitHub repository", true, false, DIR, &judged),
Lane::Yours
);
assert_eq!(
classify("Write specs/001-x/data-model.md", false, true, DIR, &judged),
Lane::Yours
);
assert_eq!(
classify(
"Write specs/001-x/data-model.md",
false,
false,
DIR,
&judged
),
Lane::Planning
);
}
#[test]
fn the_line_names_the_tasks_that_are_not_code() {
let mut tasks: Vec<(String, Lane)> =
(1..=25).map(|n| (format!("T{n:03}"), Lane::Code)).collect();
tasks[1].1 = Lane::Planning;
tasks[18].1 = Lane::Yours;
let lanes = Lanes::of(tasks.iter().map(|(id, l)| (id.as_str(), *l)));
assert!(lanes.notable());
assert_eq!(
lanes.line(),
"25 tasks ยท 23 code ยท 1 ๐ planning (T002) ยท 1 ๐ yours (T019)"
);
assert!(!Lanes::of([("T001", Lane::Code)]).notable());
assert_eq!(Lanes::of([("T001", Lane::Code)]).line(), "1 task ยท 1 code");
}
}