Skip to main content

speccoder_core/
lane.rs

1//! Who does a task: the implementer (`code`), the plan's author
2//! (`planning`: design documents inside the unit's directory) or a person
3//! (`yours`). Derived, never stored โ€” from the task's line, the unit's
4//! directory, the format's judged files and an honest stop.
5
6use serde::{Deserialize, Serialize};
7
8/// A task's lane.
9#[derive(
10    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema,
11)]
12#[serde(rename_all = "snake_case")]
13pub enum Lane {
14    /// The implementer's: code, judged by the checks and its acceptance tests.
15    #[default]
16    Code,
17    /// Design documents the plan should have written: every path the task
18    /// names lies inside the unit's directory.
19    Planning,
20    /// A step only a person can take: a `[YOU]` label, or an attempt that
21    /// stopped `BLOCKED`.
22    Yours,
23}
24
25impl Lane {
26    /// Its recorded name.
27    #[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    /// The mark beside a task (none for code).
37    #[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    /// Whether it is the default, `code` (left out of a report).
47    #[must_use]
48    pub const fn is_code(&self) -> bool {
49        matches!(self, Self::Code)
50    }
51}
52
53/// The lane a task's named paths give it ([`task_paths`]): `planning`
54/// when there is at least one and every one lies inside
55/// `unit_dir` (repository- or project-relative, a leading `/` read as the
56/// root) and none is a judged file โ€” `judged` is asked with the path
57/// relative to `unit_dir`, without a trailing `/`; else `code`.
58#[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/// The paths a task's lane is read from: every backticked path, and every
79/// bare one that reads as a path rather than prose โ€” a file with an
80/// extension, a directory written with its `/`, or a path in `unit_dir`
81/// (`input/output` is none of them).
82#[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/// A task's lane: `yours` for a `[YOU]` label or after an honest
103/// `BLOCKED:` stop, else [`by_paths`].
104#[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/// A unit's open tasks by lane, for *Before you run*.
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
121pub struct Lanes {
122    /// Open tasks in the `code` lane.
123    pub code: usize,
124    /// The `planning` tasks, in document order.
125    pub planning: Vec<String>,
126    /// The `yours` tasks, in document order.
127    pub yours: Vec<String>,
128}
129
130impl Lanes {
131    /// Counted from `(task, lane)` pairs.
132    #[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    /// Whether any task is not code (the line is worth saying).
146    #[must_use]
147    pub fn notable(&self) -> bool {
148        !self.planning.is_empty() || !self.yours.is_empty()
149    }
150
151    /// `25 tasks ยท 23 code ยท 1 ๐Ÿ“ planning (T002) ยท 1 ๐Ÿ™‹ yours (T019)`.
152    #[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        // The owner's POC: a repository-root path with a leading `/`.
189        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        // Spec folder and code: the normal profile.
210        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        // Another unit's folder, a prefix that is not a directory, an escape.
228        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        // It names no code path: read as planning, and an attempt that
252        // changes code anyway fails its verdict (the run's own check).
253        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        // Prose with a slash is no path; a bare path in the unit is one.
262        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}