1use serde::{Deserialize, Serialize};
7
8#[derive(
10 Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
11)]
12#[serde(rename_all = "snake_case")]
13pub enum Lane {
14 #[default]
16 Code,
17 Planning,
20 Yours,
23}
24
25impl Lane {
26 #[must_use]
28 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::Code => "code",
31 Self::Planning => "planning",
32 Self::Yours => "yours",
33 }
34 }
35
36 #[must_use]
38 pub const fn mark(self) -> &'static str {
39 match self {
40 Self::Code => "",
41 Self::Planning => "๐",
42 Self::Yours => "๐",
43 }
44 }
45
46 #[must_use]
48 pub const fn is_code(&self) -> bool {
49 matches!(self, Self::Code)
50 }
51}
52
53#[must_use]
59pub fn by_paths(title: &str, unit_dir: &str, judged: &dyn Fn(&str) -> bool) -> Lane {
60 let dir = unit_dir.trim_matches('/');
61 let paths = task_paths(title, dir);
62 let inside = |p: &str| {
63 let p = p.trim_end_matches('/');
64 let rel = if p == dir {
65 Some("")
66 } else {
67 p.strip_prefix(dir).and_then(|r| r.strip_prefix('/'))
68 };
69 rel.is_some_and(|r| !r.split('/').any(|s| s == "..") && (r.is_empty() || !judged(r)))
70 };
71 if !dir.is_empty() && !paths.is_empty() && paths.iter().all(|p| inside(p)) {
72 Lane::Planning
73 } else {
74 Lane::Code
75 }
76}
77
78#[must_use]
83pub fn task_paths(title: &str, unit_dir: &str) -> Vec<String> {
84 let dir = unit_dir.trim_matches('/');
85 let quoted = crate::scope::quoted_paths(title);
86 let bare = crate::scope::named_paths(title)
87 .into_iter()
88 .filter(|p| !quoted.contains(p))
89 .filter(|p| {
90 let file = p.rsplit('/').next().unwrap_or(p);
91 p.ends_with('/')
92 || file
93 .rsplit_once('.')
94 .is_some_and(|(stem, _)| !stem.is_empty())
95 || (!dir.is_empty() && (p == dir || p.starts_with(&format!("{dir}/"))))
96 });
97 let mut out = quoted.clone();
98 out.extend(bare);
99 out
100}
101
102#[must_use]
105pub fn classify(
106 title: &str,
107 labelled_yours: bool,
108 stopped_blocked: bool,
109 unit_dir: &str,
110 judged: &dyn Fn(&str) -> bool,
111) -> Lane {
112 if labelled_yours || stopped_blocked {
113 Lane::Yours
114 } else {
115 by_paths(title, unit_dir, judged)
116 }
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
121pub struct Lanes {
122 pub code: usize,
124 pub planning: Vec<String>,
126 pub yours: Vec<String>,
128}
129
130impl Lanes {
131 #[must_use]
133 pub fn of<'a>(tasks: impl IntoIterator<Item = (&'a str, Lane)>) -> Self {
134 let mut out = Self::default();
135 for (id, lane) in tasks {
136 match lane {
137 Lane::Code => out.code += 1,
138 Lane::Planning => out.planning.push(id.to_owned()),
139 Lane::Yours => out.yours.push(id.to_owned()),
140 }
141 }
142 out
143 }
144
145 #[must_use]
147 pub fn notable(&self) -> bool {
148 !self.planning.is_empty() || !self.yours.is_empty()
149 }
150
151 #[must_use]
153 pub fn line(&self) -> String {
154 let total = self.code + self.planning.len() + self.yours.len();
155 let mut parts = vec![
156 format!("{total} task{}", if total == 1 { "" } else { "s" }),
157 format!("{} code", self.code),
158 ];
159 for (ids, lane) in [(&self.planning, Lane::Planning), (&self.yours, Lane::Yours)] {
160 if !ids.is_empty() {
161 parts.push(format!(
162 "{} {} {} ({})",
163 ids.len(),
164 lane.mark(),
165 lane.as_str(),
166 ids.join(", ")
167 ));
168 }
169 }
170 parts.join(" ยท ")
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn judged(rel: &str) -> bool {
179 matches!(rel, "spec.md" | "tasks.md" | "review.json")
180 || rel == "checklists"
181 || rel.starts_with("checklists/")
182 }
183
184 const DIR: &str = "specs/001-x";
185
186 #[test]
187 fn a_task_inside_the_unit_is_planning() {
188 assert_eq!(
190 by_paths(
191 "Create the feature documentation scaffold under `/specs/001-x/contracts/` for input/output interface definitions",
192 DIR,
193 &judged
194 ),
195 Lane::Planning
196 );
197 assert_eq!(
198 by_paths("Write specs/001-x/data-model.md", DIR, &judged),
199 Lane::Planning
200 );
201 assert_eq!(
202 by_paths("Fill in `specs/001-x/`", DIR, &judged),
203 Lane::Planning
204 );
205 }
206
207 #[test]
208 fn mixed_paths_judged_files_and_no_paths_are_code() {
209 assert_eq!(
211 by_paths(
212 "Create the directories in `/specs/001-x/plan.md` at `src/`, `tests/unit/`",
213 DIR,
214 &judged
215 ),
216 Lane::Code
217 );
218 assert_eq!(
219 by_paths("Update specs/001-x/tasks.md", DIR, &judged),
220 Lane::Code
221 );
222 assert_eq!(
223 by_paths("Add specs/001-x/checklists/ux.md", DIR, &judged),
224 Lane::Code
225 );
226 assert_eq!(by_paths("Make it faster", DIR, &judged), Lane::Code);
227 assert_eq!(
229 by_paths("Write specs/002-y/contracts/a.md", DIR, &judged),
230 Lane::Code
231 );
232 assert_eq!(
233 by_paths("Write specs/001-xy/a.md", DIR, &judged),
234 Lane::Code
235 );
236 assert_eq!(
237 by_paths("Write specs/001-x/../../src/a.rs", DIR, &judged),
238 Lane::Code
239 );
240 assert_eq!(by_paths("Write specs/001-x/a.md", "", &judged), Lane::Code);
241 }
242
243 #[test]
244 fn a_task_naming_code_beside_a_design_doc_is_code() {
245 for line in [
246 "Implement the parser in src/parse.rs per `specs/001-x/contracts/parser.md`",
247 "Write tests in tests/cli.rs for `specs/001-x/quickstart.md`",
248 ] {
249 assert_eq!(by_paths(line, DIR, &judged), Lane::Code, "{line}");
250 }
251 assert_eq!(
254 by_paths(
255 "Implement the CLI described in `specs/001-x/contracts/cli.md`",
256 DIR,
257 &judged
258 ),
259 Lane::Planning
260 );
261 assert_eq!(
263 task_paths("Scaffold `specs/001-x/contracts/` for input/output", DIR),
264 ["specs/001-x/contracts/"]
265 );
266 assert_eq!(
267 by_paths("Write specs/001-x/research", DIR, &judged),
268 Lane::Planning
269 );
270 }
271
272 #[test]
273 fn a_label_or_an_honest_stop_makes_it_yours() {
274 assert_eq!(
275 classify("Create the GitHub repository", true, false, DIR, &judged),
276 Lane::Yours
277 );
278 assert_eq!(
279 classify("Write specs/001-x/data-model.md", false, true, DIR, &judged),
280 Lane::Yours
281 );
282 assert_eq!(
283 classify(
284 "Write specs/001-x/data-model.md",
285 false,
286 false,
287 DIR,
288 &judged
289 ),
290 Lane::Planning
291 );
292 }
293
294 #[test]
295 fn the_line_names_the_tasks_that_are_not_code() {
296 let mut tasks: Vec<(String, Lane)> =
297 (1..=25).map(|n| (format!("T{n:03}"), Lane::Code)).collect();
298 tasks[1].1 = Lane::Planning;
299 tasks[18].1 = Lane::Yours;
300 let lanes = Lanes::of(tasks.iter().map(|(id, l)| (id.as_str(), *l)));
301 assert!(lanes.notable());
302 assert_eq!(
303 lanes.line(),
304 "25 tasks ยท 23 code ยท 1 ๐ planning (T002) ยท 1 ๐ yours (T019)"
305 );
306 assert!(!Lanes::of([("T001", Lane::Code)]).notable());
307 assert_eq!(Lanes::of([("T001", Lane::Code)]).line(), "1 task ยท 1 code");
308 }
309}