Skip to main content

speccoder_runtime/
acceptance_at.rs

1//! A person's decision that a story's acceptance tests are written at a
2//! Setup or Foundational task: the fix for a plan that implements
3//! a story's behaviour before the story. Recorded in the state directory —
4//! never an edit of the spec (constitution I) — and changing no order or
5//! dependency (IV): only which task's acceptance author receives the
6//! story's scenarios.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use speccoder_core::actions::Via;
12use speccoder_core::{CommandData, CommandResult, CommandStatus, ControlReport};
13
14/// One decision.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Decision {
17    /// The feature.
18    pub feature: String,
19    /// The story.
20    pub story: String,
21    /// The task whose acceptance carries the story's scenarios.
22    pub task: String,
23    /// Who decided.
24    pub by: String,
25    /// Through what.
26    pub via: Via,
27    /// When (Unix seconds).
28    pub at: u64,
29}
30
31fn file(plane: &Path) -> PathBuf {
32    plane.join("decisions").join("acceptance-at.json")
33}
34
35/// Every recorded decision of the project whose state is `plane`; an
36/// absent file is none, an unreadable one an error (never silently none).
37///
38/// # Errors
39///
40/// The file exists but cannot be read or parsed.
41pub fn read_all(plane: &Path) -> Result<Vec<Decision>, String> {
42    let path = file(plane);
43    match std::fs::read_to_string(&path) {
44        Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: {e}", path.display())),
45        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
46        Err(e) => Err(format!("{}: {e}", path.display())),
47    }
48}
49
50/// [`read_all`], an unreadable file read as no decision.
51#[must_use]
52pub fn all(plane: &Path) -> Vec<Decision> {
53    read_all(plane).unwrap_or_default()
54}
55
56/// A person's decision that a task's acceptance moves to its story's
57/// checkpoint: it is judged by its regression checks, its scenarios at
58/// the checkpoint.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Deferral {
61    /// The feature.
62    pub feature: String,
63    /// The task deferred.
64    pub task: String,
65    /// The checkpoint when it was decided.
66    pub checkpoint: String,
67    /// Who decided.
68    pub by: String,
69    /// Through what.
70    pub via: Via,
71    /// When (Unix seconds).
72    pub at: u64,
73}
74
75fn deferrals_file(plane: &Path) -> PathBuf {
76    plane.join("decisions").join("acceptance-deferred.json")
77}
78
79/// Every recorded deferral; an absent file is none, an unreadable one an
80/// error (never silently none).
81///
82/// # Errors
83///
84/// The file exists but cannot be read or parsed.
85pub fn read_deferrals(plane: &Path) -> Result<Vec<Deferral>, String> {
86    let path = deferrals_file(plane);
87    match std::fs::read_to_string(&path) {
88        Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: {e}", path.display())),
89        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
90        Err(e) => Err(format!("{}: {e}", path.display())),
91    }
92}
93
94/// The deferral of `task` of `feature`, if a person made one.
95#[must_use]
96pub fn deferred(plane: &Path, feature: &str, task: &str) -> Option<Deferral> {
97    read_deferrals(plane)
98        .unwrap_or_default()
99        .into_iter()
100        .find(|d| d.feature == feature && d.task == task)
101}
102
103/// The story `task` of `feature` writes the acceptance tests of, if any.
104#[must_use]
105pub fn story_at(plane: &Path, feature: &str, task: &str) -> Option<String> {
106    all(plane)
107        .into_iter()
108        .find(|d| d.feature == feature && d.task == task)
109        .map(|d| d.story)
110}
111
112/// Records (or, with `revoke`, removes) the decision for `story` of the
113/// project at `root`; `state` is the state directory.
114#[must_use]
115pub fn decide(
116    root: &Path,
117    feature: Option<&str>,
118    d: (&str, &str, bool),
119    via: Via,
120    state: &Path,
121) -> CommandResult {
122    let (story, task, revoke) = d;
123    let refuse = |status, m: String| CommandResult::new("task", status, &m);
124    let Ok(opened) = crate::format::open(root, feature) else {
125        return refuse(
126            CommandStatus::NotAProject,
127            "no spec project here".to_owned(),
128        );
129    };
130    let Some(f) = opened.snapshot.feature.as_ref() else {
131        return refuse(
132            CommandStatus::SelectionNeeded,
133            "no feature selected".to_owned(),
134        );
135    };
136    let name = f.feature.name.clone();
137    let structure = opened.adapter.plan_structure(&opened.snapshot);
138    let Some(planned) = structure
139        .as_ref()
140        .and_then(|s| s.tasks.iter().find(|t| t.id == task))
141    else {
142        return refuse(
143            CommandStatus::NotFound,
144            format!("{task} is not a task of {name} with a phase structure"),
145        );
146    };
147    if !matches!(
148        planned.phase,
149        crate::format::PhaseKind::Setup | crate::format::PhaseKind::Foundational
150    ) {
151        return refuse(
152            CommandStatus::UsageError,
153            format!(
154                "{task} is not a Setup or Foundational task: its own story's acceptance applies"
155            ),
156        );
157    }
158    if !structure
159        .as_ref()
160        .is_some_and(|s| s.stories.contains_key(story))
161    {
162        return refuse(
163            CommandStatus::NotFound,
164            format!("{story} is not a story of {name}"),
165        );
166    }
167    let plane = crate::worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
168    let mut list = match read_all(&plane) {
169        Ok(l) => l,
170        Err(e) => {
171            return refuse(
172                CommandStatus::Error,
173                format!("the recorded decisions cannot be read, so none is changed: {e}"),
174            );
175        }
176    };
177    // One decision per story, and one story per task.
178    list.retain(|x| !(x.feature == name && (x.story == story || x.task == task)));
179    if !revoke {
180        list.push(Decision {
181            feature: name.clone(),
182            story: story.to_owned(),
183            task: task.to_owned(),
184            by: crate::controls::person(root),
185            via,
186            at: std::time::SystemTime::now()
187                .duration_since(std::time::UNIX_EPOCH)
188                .map_or(0, |d| d.as_secs()),
189        });
190    }
191    let path = file(&plane);
192    let written = path
193        .parent()
194        .map_or(Ok(()), std::fs::create_dir_all)
195        .and_then(|()| {
196            std::fs::write(
197                &path,
198                serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
199            )
200        });
201    if let Err(e) = written {
202        return refuse(
203            CommandStatus::Error,
204            format!("cannot write {}: {e}", path.display()),
205        );
206    }
207    let message = if revoke {
208        format!("{story}'s acceptance tests are written by its own tasks again")
209    } else {
210        format!(
211            "{task} writes {story}'s acceptance tests: its tick can be verified on {story}'s scenarios"
212        )
213    };
214    CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
215        Box::new(ControlReport {
216            feature: name,
217            task: task.to_owned(),
218            action: if revoke {
219                "acceptance_at_revoked"
220            } else {
221                "acceptance_at"
222            }
223            .to_owned(),
224            text: Some(story.to_owned()),
225            record: Some(path.display().to_string()),
226        }),
227    ))
228}
229
230/// Records (or, with `revoke`, removes) a person's decision that `task`'s
231/// acceptance moves to its story's checkpoint. Refused for a task of no
232/// story, a task that names its scenarios, and the checkpoint itself —
233/// nothing later would judge them.
234#[must_use]
235pub fn defer(
236    root: &Path,
237    feature: Option<&str>,
238    task: &str,
239    revoke: bool,
240    via: Via,
241    state: &Path,
242) -> CommandResult {
243    use speccoder_core::story::Share;
244    let refuse = |status, m: String| CommandResult::new("task", status, &m);
245    let Ok(opened) = crate::format::open(root, feature) else {
246        return refuse(
247            CommandStatus::NotAProject,
248            "no spec project here".to_owned(),
249        );
250    };
251    let snap = &opened.snapshot;
252    let Some(f) = snap.feature.as_ref() else {
253        return refuse(
254            CommandStatus::SelectionNeeded,
255            "no feature selected".to_owned(),
256        );
257    };
258    let name = f.feature.name.clone();
259    let Some(t) = f
260        .tasks
261        .as_ref()
262        .and_then(|ts| ts.tasks.iter().find(|t| t.id == task))
263    else {
264        return refuse(
265            CommandStatus::NotFound,
266            format!("{task} is not a task of {name}"),
267        );
268    };
269    let plane = crate::worktree::project_state_dir(state, Path::new(&snap.root));
270    // One writer at a time (the app and a terminal may both decide): the
271    // list is read, changed and replaced under a lock.
272    let path = deferrals_file(&plane);
273    if let Some(dir) = path.parent()
274        && let Err(e) = std::fs::create_dir_all(dir)
275    {
276        return refuse(
277            CommandStatus::Error,
278            format!("cannot write {}: {e}", dir.display()),
279        );
280    }
281    let lock = match std::fs::File::create(path.with_extension("lock")) {
282        Ok(f) => f,
283        Err(e) => {
284            return refuse(
285                CommandStatus::Error,
286                format!("cannot lock {}: {e}", path.display()),
287            );
288        }
289    };
290    if let Err(e) = lock.lock() {
291        return refuse(
292            CommandStatus::Error,
293            format!("cannot lock {}: {e}", path.display()),
294        );
295    }
296    let mut list = match read_deferrals(&plane) {
297        Ok(l) => l,
298        Err(e) => {
299            return refuse(
300                CommandStatus::Error,
301                format!("the recorded decisions cannot be read, so none is changed: {e}"),
302            );
303        }
304    };
305    let checkpoint = if revoke {
306        String::new()
307    } else {
308        let scenarios = opened.adapter.scenarios(snap, task);
309        let judged = opened.adapter.rules().judged;
310        let share =
311            crate::pipeline::acceptance::share_of(&crate::pipeline::acceptance::ShareInput {
312                repo: root,
313                plane: &plane,
314                feature: &name,
315                task,
316                story: t.story.as_deref(),
317                whole_unit: opened.adapter.kind() == speccoder_core::spec::FormatKind::Openspec,
318                tasks: f.tasks.as_ref(),
319                unit_dir: &f.feature.dir,
320                judged: &judged,
321                scenarios: &scenarios,
322                as_deferred: true,
323            });
324        match share {
325            Some(Share::Deferred { checkpoint }) => checkpoint,
326            Some(Share::Checkpoint { .. }) => {
327                return refuse(
328                    CommandStatus::UsageError,
329                    format!(
330                        "{task} is its story's checkpoint: no later task would judge its scenarios"
331                    ),
332                );
333            }
334            Some(Share::Linked { scenarios }) => {
335                return refuse(
336                    CommandStatus::UsageError,
337                    format!(
338                        "{task} names its scenarios ({}): it is judged by them — change the link in the task list to move them",
339                        scenarios.join(", ")
340                    ),
341                );
342            }
343            Some(Share::Authored { .. }) | None => {
344                return refuse(
345                    CommandStatus::UsageError,
346                    format!("{task} is not a task of a story with a later code task"),
347                );
348            }
349        }
350    };
351    let had = list.iter().any(|d| d.feature == name && d.task == task);
352    if revoke && !had {
353        return refuse(
354            CommandStatus::Ok,
355            format!("{task} was not deferred: nothing to take back"),
356        );
357    }
358    list.retain(|d| !(d.feature == name && d.task == task));
359    if !revoke {
360        list.push(Deferral {
361            feature: name.clone(),
362            task: task.to_owned(),
363            checkpoint: checkpoint.clone(),
364            by: crate::controls::person(root),
365            via,
366            at: std::time::SystemTime::now()
367                .duration_since(std::time::UNIX_EPOCH)
368                .map_or(0, |d| d.as_secs()),
369        });
370    }
371    // Replaced whole: a reader never sees half a list.
372    let tmp = path.with_extension("json.tmp");
373    let written = std::fs::write(
374        &tmp,
375        serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
376    )
377    .and_then(|()| std::fs::rename(&tmp, &path));
378    if let Err(e) = written {
379        return refuse(
380            CommandStatus::Error,
381            format!("cannot write {}: {e}", path.display()),
382        );
383    }
384    let message = if revoke {
385        format!("{task} is judged by its own acceptance tests again")
386    } else {
387        format!(
388            "{task} is judged by its regression checks; its story's scenarios at {checkpoint} — run it again: `speccoder run --task {task}`"
389        )
390    };
391    CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
392        Box::new(ControlReport {
393            feature: name,
394            task: task.to_owned(),
395            action: if revoke { "defer_revoked" } else { "deferred" }.to_owned(),
396            text: (!revoke).then_some(checkpoint),
397            record: Some(path.display().to_string()),
398        }),
399    ))
400}