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 ruling that a task's tests are wrong: one scenario's oracle
57/// tests (an oracle amendment, nothing else of the oracle), or the task's
58/// own test outside a story. The next run writes them again.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Ruling {
61    /// The feature.
62    pub feature: String,
63    /// The scenario (`US3-2`; OpenSpec: its name); none: the task's own
64    /// test (a task outside a story).
65    #[serde(default)]
66    pub scenario: Option<String>,
67    /// Its story (`US3`; OpenSpec: the change).
68    #[serde(default)]
69    pub story: String,
70    /// The task that met the tests.
71    pub task: String,
72    /// Why they are wrong (told to the author).
73    pub reason: String,
74    /// Who ruled.
75    pub by: String,
76    /// Through what.
77    pub via: Via,
78    /// When (Unix seconds).
79    pub at: u64,
80}
81
82fn rulings_file(plane: &Path) -> PathBuf {
83    plane.join("decisions").join("oracle-rulings.json")
84}
85
86/// Every ruling not yet acted on; an absent file is none, an unreadable
87/// one an error (never silently none).
88///
89/// # Errors
90///
91/// The file exists but cannot be read or parsed.
92pub fn rulings(plane: &Path) -> Result<Vec<Ruling>, String> {
93    let path = rulings_file(plane);
94    match std::fs::read_to_string(&path) {
95        Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: {e}", path.display())),
96        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
97        Err(e) => Err(format!("{}: {e}", path.display())),
98    }
99}
100
101/// Replaces the rulings whole, under a lock: a reader never sees half a list.
102fn write_rulings(plane: &Path, change: impl FnOnce(&mut Vec<Ruling>)) -> Result<PathBuf, String> {
103    let path = rulings_file(plane);
104    if let Some(dir) = path.parent() {
105        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
106    }
107    let lock = std::fs::File::create(path.with_extension("lock"))
108        .map_err(|e| format!("{}: {e}", path.display()))?;
109    lock.lock()
110        .map_err(|e| format!("{}: {e}", path.display()))?;
111    let mut list = rulings(plane)?;
112    change(&mut list);
113    let tmp = path.with_extension("json.tmp");
114    std::fs::write(
115        &tmp,
116        serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
117    )
118    .and_then(|()| std::fs::rename(&tmp, &path))
119    .map_err(|e| format!("{}: {e}", path.display()))?;
120    Ok(path)
121}
122
123/// The rulings on `story`'s scenarios of `feature`. An unreadable list is
124/// an error, never "none".
125///
126/// # Errors
127///
128/// The list exists but cannot be read.
129pub fn rulings_for(plane: &Path, feature: &str, story: &str) -> Result<Vec<Ruling>, String> {
130    Ok(rulings(plane)?
131        .into_iter()
132        .filter(|r| r.feature == feature && r.story == story && r.scenario.is_some())
133        .collect())
134}
135
136/// Forgets the rulings an amendment acted on.
137///
138/// # Errors
139///
140/// The list cannot be read or written.
141pub fn acted_on(plane: &Path, feature: &str, scenarios: &[String]) -> Result<(), String> {
142    write_rulings(plane, |l| {
143        l.retain(|r| {
144            !(r.feature == feature && r.scenario.as_ref().is_some_and(|s| scenarios.contains(s)))
145        });
146    })
147    .map(|_| ())
148}
149
150/// The rulings on `task`'s own test (outside a story), oldest first. An
151/// unreadable list is an error, never "none".
152///
153/// # Errors
154///
155/// The list exists but cannot be read.
156pub fn rulings_for_task(plane: &Path, feature: &str, task: &str) -> Result<Vec<Ruling>, String> {
157    Ok(rulings(plane)?
158        .into_iter()
159        .filter(|r| r.feature == feature && r.task == task && r.scenario.is_none())
160        .collect())
161}
162
163/// Forgets the rulings on `task`'s own test: written again, or ticked.
164///
165/// # Errors
166///
167/// The list cannot be read or written.
168pub fn acted_on_task(plane: &Path, feature: &str, task: &str) -> Result<(), String> {
169    write_rulings(plane, |l| {
170        l.retain(|r| !(r.feature == feature && r.task == task && r.scenario.is_none()));
171    })
172    .map(|_| ())
173}
174
175/// The story `task` of `feature` writes the acceptance tests of, if any.
176#[must_use]
177pub fn story_at(plane: &Path, feature: &str, task: &str) -> Option<String> {
178    all(plane)
179        .into_iter()
180        .find(|d| d.feature == feature && d.task == task)
181        .map(|d| d.story)
182}
183
184/// Records (or, with `revoke`, removes) the decision for `story` of the
185/// project at `root`; `state` is the state directory.
186#[must_use]
187pub fn decide(
188    root: &Path,
189    feature: Option<&str>,
190    d: (&str, &str, bool),
191    via: Via,
192    state: &Path,
193) -> CommandResult {
194    let (story, task, revoke) = d;
195    let refuse = |status, m: String| CommandResult::new("task", status, &m);
196    let Ok(opened) = crate::format::open(root, feature) else {
197        return refuse(
198            CommandStatus::NotAProject,
199            "no spec project here".to_owned(),
200        );
201    };
202    let Some(f) = opened.snapshot.feature.as_ref() else {
203        return refuse(
204            CommandStatus::SelectionNeeded,
205            "no feature selected".to_owned(),
206        );
207    };
208    let name = f.feature.name.clone();
209    let structure = opened.adapter.plan_structure(&opened.snapshot);
210    let Some(planned) = structure
211        .as_ref()
212        .and_then(|s| s.tasks.iter().find(|t| t.id == task))
213    else {
214        return refuse(
215            CommandStatus::NotFound,
216            format!("{task} is not a task of {name} with a phase structure"),
217        );
218    };
219    if !matches!(
220        planned.phase,
221        crate::format::PhaseKind::Setup | crate::format::PhaseKind::Foundational
222    ) {
223        return refuse(
224            CommandStatus::UsageError,
225            format!(
226                "{task} is not a Setup or Foundational task: its own story's acceptance applies"
227            ),
228        );
229    }
230    if !structure
231        .as_ref()
232        .is_some_and(|s| s.stories.contains_key(story))
233    {
234        return refuse(
235            CommandStatus::NotFound,
236            format!("{story} is not a story of {name}"),
237        );
238    }
239    let plane = crate::worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
240    let mut list = match read_all(&plane) {
241        Ok(l) => l,
242        Err(e) => {
243            return refuse(
244                CommandStatus::Error,
245                format!("the recorded decisions cannot be read, so none is changed: {e}"),
246            );
247        }
248    };
249    // One decision per story, and one story per task.
250    list.retain(|x| !(x.feature == name && (x.story == story || x.task == task)));
251    if !revoke {
252        list.push(Decision {
253            feature: name.clone(),
254            story: story.to_owned(),
255            task: task.to_owned(),
256            by: crate::controls::person(root),
257            via,
258            at: std::time::SystemTime::now()
259                .duration_since(std::time::UNIX_EPOCH)
260                .map_or(0, |d| d.as_secs()),
261        });
262    }
263    let path = file(&plane);
264    let written = path
265        .parent()
266        .map_or(Ok(()), std::fs::create_dir_all)
267        .and_then(|()| {
268            std::fs::write(
269                &path,
270                serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
271            )
272        });
273    if let Err(e) = written {
274        return refuse(
275            CommandStatus::Error,
276            format!("cannot write {}: {e}", path.display()),
277        );
278    }
279    let message = if revoke {
280        format!("{story}'s acceptance tests are written by its own tasks again")
281    } else {
282        format!(
283            "{task} writes {story}'s acceptance tests: its tick can be verified on {story}'s scenarios"
284        )
285    };
286    CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
287        Box::new(ControlReport {
288            feature: name,
289            task: task.to_owned(),
290            action: if revoke {
291                "acceptance_at_revoked"
292            } else {
293                "acceptance_at"
294            }
295            .to_owned(),
296            text: Some(story.to_owned()),
297            record: Some(path.display().to_string()),
298        }),
299    ))
300}
301
302/// Records a person's ruling that a task's tests are wrong: for a story
303/// task one `scenario`'s oracle tests, for a task outside a story its own
304/// test. The next run writes them again, the reason told to their author.
305/// Refused, with the choices there are, for a scenario the task's story
306/// does not have, a story task without one, a scenario on a task outside a
307/// story, or a task without tests of its own.
308#[must_use]
309pub fn rule_tests_wrong(
310    root: &Path,
311    feature: Option<&str>,
312    task: &str,
313    scenario: Option<&str>,
314    reason: &str,
315    via: Via,
316    state: &Path,
317) -> CommandResult {
318    let refuse = |status, m: String| CommandResult::new("task", status, &m);
319    if reason.trim().is_empty() {
320        return refuse(
321            CommandStatus::UsageError,
322            "a ruling needs a reason".to_owned(),
323        );
324    }
325    let Ok(opened) = crate::format::open(root, feature) else {
326        return refuse(
327            CommandStatus::NotAProject,
328            "no spec project here".to_owned(),
329        );
330    };
331    let snap = &opened.snapshot;
332    let Some(f) = snap.feature.as_ref() else {
333        return refuse(
334            CommandStatus::SelectionNeeded,
335            "no feature selected".to_owned(),
336        );
337    };
338    let name = f.feature.name.clone();
339    let Some(t) = f
340        .tasks
341        .as_ref()
342        .and_then(|ts| ts.tasks.iter().find(|t| t.id == task))
343    else {
344        return refuse(CommandStatus::NotFound, format!("no task {task} in {name}"));
345    };
346    let openspec = opened.adapter.kind() == speccoder_core::spec::FormatKind::Openspec;
347    let plane = crate::worktree::project_state_dir(state, Path::new(&snap.root));
348    // The story the run judges the task in: the one a person had its
349    // acceptance written at, its label; OpenSpec: the change.
350    let story = if openspec {
351        Some(name.clone())
352    } else {
353        story_at(&plane, &name, task).or_else(|| t.story.clone())
354    };
355    let known = opened.adapter.scenarios(snap, task);
356    let ids = || {
357        known
358            .iter()
359            .map(|s| s.id.as_str())
360            .collect::<Vec<_>>()
361            .join(", ")
362    };
363    let rules = opened.adapter.rules().judged;
364    let unit_dir = f.feature.dir.clone();
365    let scenario = match (&story, scenario) {
366        (Some(_), Some(id)) if known.iter().any(|s| s.id == id) => Some(id.to_owned()),
367        (Some(_), Some(id)) => {
368            return refuse(
369                CommandStatus::NotFound,
370                format!("{id} is not a scenario of {task}'s story ({})", ids()),
371            );
372        }
373        (Some(st), None) => {
374            return refuse(
375                CommandStatus::UsageError,
376                format!(
377                    "{task} is of {st}: name the scenario whose tests are wrong with --scenario ({})",
378                    ids()
379                ),
380            );
381        }
382        (None, Some(_)) => {
383            return refuse(
384                CommandStatus::UsageError,
385                format!("{task} is in no story: its own test is ruled, without --scenario"),
386            );
387        }
388        (None, None)
389            if known.is_empty()
390                || crate::pipeline::acceptance::checks_only(t, &unit_dir, &rules) =>
391        {
392            return refuse(
393                CommandStatus::UsageError,
394                format!("{task} has no tests of its own to rule on: its checks judge it"),
395            );
396        }
397        (None, None) => None,
398    };
399    let ruling = Ruling {
400        feature: name.clone(),
401        scenario: scenario.clone(),
402        story: story.unwrap_or_default(),
403        task: task.to_owned(),
404        reason: reason.trim().to_owned(),
405        by: crate::controls::person(root),
406        via,
407        at: std::time::SystemTime::now()
408            .duration_since(std::time::UNIX_EPOCH)
409            .map_or(0, |d| d.as_secs()),
410    };
411    let path = match write_rulings(&plane, |l| {
412        // A scenario is ruled once (the latest reason); a task's own test
413        // gathers every reason until it is written again.
414        if scenario.is_some() {
415            l.retain(|r| !(r.feature == name && r.scenario == scenario));
416        }
417        l.push(ruling);
418    }) {
419        Ok(p) => p,
420        Err(e) => {
421            return refuse(
422                CommandStatus::Error,
423                format!("the ruling cannot be recorded: {e}"),
424            );
425        }
426    };
427    let (what, rest) = match &scenario {
428        Some(id) => (
429            format!("{id}'s tests are"),
430            ", and nothing else of the story's oracle",
431        ),
432        None => (
433            format!("{task}'s own test is"),
434            ", your reason given to its author",
435        ),
436    };
437    CommandResult::new(
438        "task",
439        CommandStatus::Ok,
440        &format!(
441            "{what} ruled wrong: the next run writes them again{rest} — `speccoder run --task {task}`"
442        ),
443    )
444    .with_data(CommandData::Control(Box::new(ControlReport {
445        feature: name,
446        task: task.to_owned(),
447        action: "tests_ruled_wrong".to_owned(),
448        text: scenario.or_else(|| Some(task.to_owned())),
449        record: Some(path.display().to_string()),
450    })))
451}