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