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/// `speccoder story accept <scenario> --reason R`: a person accepts a
87/// scenario its test author called untestable.
88pub fn accept_untestable(
89    root: &Path,
90    feature: Option<&str>,
91    scenario: &str,
92    reason: &str,
93    via: Via,
94    state: &Path,
95) -> CommandResult {
96    let refuse = |status, m: String| CommandResult::new("story", status, &m);
97    if reason.trim().is_empty() {
98        return refuse(
99            CommandStatus::UsageError,
100            "accepting a scenario as untestable needs a reason".to_owned(),
101        );
102    }
103    let Ok(opened) = crate::format::open(root, feature) else {
104        return refuse(
105            CommandStatus::NotAProject,
106            "no spec project here".to_owned(),
107        );
108    };
109    let snap = &opened.snapshot;
110    let Some(f) = snap.feature.as_ref() else {
111        return refuse(
112            CommandStatus::SelectionNeeded,
113            "no feature selected".to_owned(),
114        );
115    };
116    let name = f.feature.name.clone();
117    let known: Vec<String> = opened
118        .adapter
119        .oracle_requirements(snap)
120        .into_iter()
121        .flat_map(|r| r.scenarios.into_iter().map(|s| s.id))
122        .collect();
123    if !known.iter().any(|k| k == scenario) {
124        return refuse(
125            CommandStatus::NotFound,
126            format!("{scenario} is no scenario of {name} ({})", known.join(", ")),
127        );
128    }
129    // Recorded on the feature's branch: the story's evidence, wherever it
130    // is cloned, whether or not a tick follows.
131    let wt = crate::pipeline::workspace(root, Some(&name), state, &crate::RunOptions::default());
132    let Some(CommandData::Worktree(w)) = wt.data() else {
133        return CommandResult::carried("story", &wt);
134    };
135    let Some(wt_path) = w.path.clone().filter(|_| wt.status() == CommandStatus::Ok) else {
136        return CommandResult::carried("story", &wt);
137    };
138    let by = crate::controls::person(root);
139    let message = format!(
140        "speccoder: {scenario} accepted as untestable\n\nUntestable-Accepted: {scenario} by {by} — {}\nVia: {}\n",
141        reason.trim().replace('\n', " "),
142        via.as_str()
143    );
144    let commit =
145        match crate::pipeline::commit::commit_paths(Path::new(&wt_path), &message, &[], true) {
146            Ok(c) => c,
147            Err(e) => {
148                return refuse(
149                    CommandStatus::GitPrecondition,
150                    format!("the acceptance cannot be committed: {e}"),
151                );
152            }
153        };
154    CommandResult::new(
155        "story",
156        CommandStatus::Ok,
157        &format!(
158            "{scenario} accepted as untestable: its story's checkpoint no longer waits for it — recorded on the feature's branch ({})",
159            commit.get(..7).unwrap_or(&commit)
160        ),
161    )
162    .with_data(CommandData::Control(Box::new(ControlReport {
163        feature: name,
164        task: String::new(),
165        action: "untestable_accepted".to_owned(),
166        text: Some(scenario.to_owned()),
167        record: Some(commit),
168    })))
169}
170
171/// Every ruling not yet acted on; an absent file is none, an unreadable
172/// one an error (never silently none).
173///
174/// # Errors
175///
176/// The file exists but cannot be read or parsed.
177pub fn rulings(plane: &Path) -> Result<Vec<Ruling>, String> {
178    read_list(&rulings_file(plane))
179}
180
181fn read_list<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Vec<T>, String> {
182    match std::fs::read_to_string(path) {
183        Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: {e}", path.display())),
184        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
185        Err(e) => Err(format!("{}: {e}", path.display())),
186    }
187}
188
189/// Replaces the rulings whole, under a lock: a reader never sees half a list.
190fn write_rulings(plane: &Path, change: impl FnOnce(&mut Vec<Ruling>)) -> Result<PathBuf, String> {
191    write_list(&rulings_file(plane), change)
192}
193
194fn write_list<T: serde::Serialize + serde::de::DeserializeOwned>(
195    path: &Path,
196    change: impl FnOnce(&mut Vec<T>),
197) -> Result<PathBuf, String> {
198    let path = path.to_path_buf();
199    if let Some(dir) = path.parent() {
200        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
201    }
202    let lock = std::fs::File::create(path.with_extension("lock"))
203        .map_err(|e| format!("{}: {e}", path.display()))?;
204    lock.lock()
205        .map_err(|e| format!("{}: {e}", path.display()))?;
206    let mut list = read_list(&path)?;
207    change(&mut list);
208    let tmp = path.with_extension("json.tmp");
209    std::fs::write(
210        &tmp,
211        serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
212    )
213    .and_then(|()| std::fs::rename(&tmp, &path))
214    .map_err(|e| format!("{}: {e}", path.display()))?;
215    Ok(path)
216}
217
218/// The rulings on `story`'s scenarios of `feature`. An unreadable list is
219/// an error, never "none".
220///
221/// # Errors
222///
223/// The list exists but cannot be read.
224pub fn rulings_for(plane: &Path, feature: &str, story: &str) -> Result<Vec<Ruling>, String> {
225    Ok(rulings(plane)?
226        .into_iter()
227        .filter(|r| r.feature == feature && r.story == story && r.scenario.is_some())
228        .collect())
229}
230
231/// Forgets the rulings an amendment acted on.
232///
233/// # Errors
234///
235/// The list cannot be read or written.
236pub fn acted_on(plane: &Path, feature: &str, scenarios: &[String]) -> Result<(), String> {
237    write_rulings(plane, |l| {
238        l.retain(|r| {
239            !(r.feature == feature && r.scenario.as_ref().is_some_and(|s| scenarios.contains(s)))
240        });
241    })
242    .map(|_| ())
243}
244
245/// The rulings on `task`'s own test (outside a story), oldest first. An
246/// unreadable list is an error, never "none".
247///
248/// # Errors
249///
250/// The list exists but cannot be read.
251pub fn rulings_for_task(plane: &Path, feature: &str, task: &str) -> Result<Vec<Ruling>, String> {
252    Ok(rulings(plane)?
253        .into_iter()
254        .filter(|r| r.feature == feature && r.task == task && r.scenario.is_none())
255        .collect())
256}
257
258/// Forgets the rulings on `task`'s own test: written again, or ticked.
259///
260/// # Errors
261///
262/// The list cannot be read or written.
263pub fn acted_on_task(plane: &Path, feature: &str, task: &str) -> Result<(), String> {
264    write_rulings(plane, |l| {
265        l.retain(|r| !(r.feature == feature && r.task == task && r.scenario.is_none()));
266    })
267    .map(|_| ())
268}
269
270/// The story `task` of `feature` writes the acceptance tests of, if any.
271#[must_use]
272pub fn story_at(plane: &Path, feature: &str, task: &str) -> Option<String> {
273    all(plane)
274        .into_iter()
275        .find(|d| d.feature == feature && d.task == task)
276        .map(|d| d.story)
277}
278
279/// Records (or, with `revoke`, removes) the decision for `story` of the
280/// project at `root`; `state` is the state directory.
281#[must_use]
282pub fn decide(
283    root: &Path,
284    feature: Option<&str>,
285    d: (&str, &str, bool),
286    via: Via,
287    state: &Path,
288) -> CommandResult {
289    let (story, task, revoke) = d;
290    let refuse = |status, m: String| CommandResult::new("task", status, &m);
291    let Ok(opened) = crate::format::open(root, feature) else {
292        return refuse(
293            CommandStatus::NotAProject,
294            "no spec project here".to_owned(),
295        );
296    };
297    let Some(f) = opened.snapshot.feature.as_ref() else {
298        return refuse(
299            CommandStatus::SelectionNeeded,
300            "no feature selected".to_owned(),
301        );
302    };
303    let name = f.feature.name.clone();
304    let structure = opened.adapter.plan_structure(&opened.snapshot);
305    let Some(planned) = structure
306        .as_ref()
307        .and_then(|s| s.tasks.iter().find(|t| t.id == task))
308    else {
309        return refuse(
310            CommandStatus::NotFound,
311            format!("{task} is not a task of {name} with a phase structure"),
312        );
313    };
314    if !matches!(
315        planned.phase,
316        crate::format::PhaseKind::Setup | crate::format::PhaseKind::Foundational
317    ) {
318        return refuse(
319            CommandStatus::UsageError,
320            format!(
321                "{task} is not a Setup or Foundational task: its own story's acceptance applies"
322            ),
323        );
324    }
325    if !structure
326        .as_ref()
327        .is_some_and(|s| s.stories.contains_key(story))
328    {
329        return refuse(
330            CommandStatus::NotFound,
331            format!("{story} is not a story of {name}"),
332        );
333    }
334    let plane = crate::worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
335    let mut list = match read_all(&plane) {
336        Ok(l) => l,
337        Err(e) => {
338            return refuse(
339                CommandStatus::Error,
340                format!("the recorded decisions cannot be read, so none is changed: {e}"),
341            );
342        }
343    };
344    // One decision per story, and one story per task.
345    list.retain(|x| !(x.feature == name && (x.story == story || x.task == task)));
346    if !revoke {
347        list.push(Decision {
348            feature: name.clone(),
349            story: story.to_owned(),
350            task: task.to_owned(),
351            by: crate::controls::person(root),
352            via,
353            at: std::time::SystemTime::now()
354                .duration_since(std::time::UNIX_EPOCH)
355                .map_or(0, |d| d.as_secs()),
356        });
357    }
358    let path = file(&plane);
359    let written = path
360        .parent()
361        .map_or(Ok(()), std::fs::create_dir_all)
362        .and_then(|()| {
363            std::fs::write(
364                &path,
365                serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
366            )
367        });
368    if let Err(e) = written {
369        return refuse(
370            CommandStatus::Error,
371            format!("cannot write {}: {e}", path.display()),
372        );
373    }
374    let message = if revoke {
375        format!("{story}'s acceptance tests are written by its own tasks again")
376    } else {
377        format!(
378            "{task} writes {story}'s acceptance tests: its tick can be verified on {story}'s scenarios"
379        )
380    };
381    CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
382        Box::new(ControlReport {
383            feature: name,
384            task: task.to_owned(),
385            action: if revoke {
386                "acceptance_at_revoked"
387            } else {
388                "acceptance_at"
389            }
390            .to_owned(),
391            text: Some(story.to_owned()),
392            record: Some(path.display().to_string()),
393        }),
394    ))
395}
396
397/// Records a person's ruling that a task's tests are wrong: for a story
398/// task one `scenario`'s oracle tests, for a task outside a story its own
399/// test. The next run writes them again, the reason told to their author.
400/// Refused, with the choices there are, for a scenario the task's story
401/// does not have, a story task without one, a scenario on a task outside a
402/// story, or a task without tests of its own.
403#[must_use]
404pub fn rule_tests_wrong(
405    root: &Path,
406    feature: Option<&str>,
407    task: &str,
408    scenario: Option<&str>,
409    reason: &str,
410    via: Via,
411    state: &Path,
412) -> CommandResult {
413    let refuse = |status, m: String| CommandResult::new("task", status, &m);
414    if reason.trim().is_empty() {
415        return refuse(
416            CommandStatus::UsageError,
417            "a ruling needs a reason".to_owned(),
418        );
419    }
420    let Ok(mut opened) = crate::format::open(root, feature) else {
421        return refuse(
422            CommandStatus::NotAProject,
423            "no spec project here".to_owned(),
424        );
425    };
426    // A convergence task is appended on the feature's branch: the run's
427    // task list, when the checkout's lags behind it.
428    let at = std::path::PathBuf::from(&opened.snapshot.root);
429    if let Some(f) = opened.snapshot.feature.as_mut()
430        && !f
431            .tasks
432            .as_ref()
433            .is_some_and(|ts| ts.tasks.iter().any(|t| t.id == task))
434        && let Some(branch) =
435            crate::current::branch_task_list(opened.adapter, &at, &f.feature.name, &f.feature.dir)
436    {
437        f.tasks = Some(branch);
438    }
439    let snap = &opened.snapshot;
440    let Some(f) = snap.feature.as_ref() else {
441        return refuse(
442            CommandStatus::SelectionNeeded,
443            "no feature selected".to_owned(),
444        );
445    };
446    let name = f.feature.name.clone();
447    let Some(t) = f
448        .tasks
449        .as_ref()
450        .and_then(|ts| ts.tasks.iter().find(|t| t.id == task))
451    else {
452        return refuse(CommandStatus::NotFound, format!("no task {task} in {name}"));
453    };
454    let openspec = opened.adapter.kind() == speccoder_core::spec::FormatKind::Openspec;
455    let plane = crate::worktree::project_state_dir(state, Path::new(&snap.root));
456    // The story the run judges the task in: the one a person had its
457    // acceptance written at, its label; OpenSpec: the change.
458    let rules = opened.adapter.rules().judged;
459    let unit_dir = f.feature.dir.clone();
460    let mut known = opened.adapter.scenarios(snap, task);
461    // A gap citing one story's scenarios is judged by that story's oracle:
462    // its tests are those scenarios', ruled as the story's.
463    let linked = if openspec || t.story.is_some() {
464        None
465    } else {
466        crate::pipeline::acceptance::linked_story(
467            t,
468            &known,
469            &unit_dir,
470            &rules,
471            &opened.adapter.oracle_requirements(snap),
472        )
473    };
474    if let Some((_, links)) = &linked {
475        known.retain(|s| links.contains(&s.id));
476    }
477    let story = if openspec {
478        Some(name.clone())
479    } else {
480        story_at(&plane, &name, task)
481            .or_else(|| t.story.clone())
482            .or_else(|| linked.map(|(r, _)| r.key))
483    };
484    let ids = || {
485        known
486            .iter()
487            .map(|s| s.id.as_str())
488            .collect::<Vec<_>>()
489            .join(", ")
490    };
491    let scenario = match (&story, scenario) {
492        (Some(_), Some(id)) if known.iter().any(|s| s.id == id) => Some(id.to_owned()),
493        (Some(_), Some(id)) => {
494            return refuse(
495                CommandStatus::NotFound,
496                format!("{id} is not a scenario of {task}'s story ({})", ids()),
497            );
498        }
499        (Some(st), None) => {
500            return refuse(
501                CommandStatus::UsageError,
502                format!(
503                    "{task} is of {st}: name the scenario whose tests are wrong with --scenario ({})",
504                    ids()
505                ),
506            );
507        }
508        (None, Some(_)) => {
509            return refuse(
510                CommandStatus::UsageError,
511                format!("{task} is in no story: its own test is ruled, without --scenario"),
512            );
513        }
514        (None, None)
515            if known.is_empty()
516                || crate::pipeline::acceptance::checks_only(t, &unit_dir, &rules) =>
517        {
518            return refuse(
519                CommandStatus::UsageError,
520                format!("{task} has no tests of its own to rule on: its checks judge it"),
521            );
522        }
523        (None, None) => None,
524    };
525    let ruling = Ruling {
526        feature: name.clone(),
527        scenario: scenario.clone(),
528        story: story.unwrap_or_default(),
529        task: task.to_owned(),
530        reason: reason.trim().to_owned(),
531        by: crate::controls::person(root),
532        via,
533        at: std::time::SystemTime::now()
534            .duration_since(std::time::UNIX_EPOCH)
535            .map_or(0, |d| d.as_secs()),
536    };
537    let path = match write_rulings(&plane, |l| {
538        // A scenario is ruled once (the latest reason); a task's own test
539        // gathers every reason until it is written again.
540        if scenario.is_some() {
541            l.retain(|r| !(r.feature == name && r.scenario == scenario));
542        }
543        l.push(ruling);
544    }) {
545        Ok(p) => p,
546        Err(e) => {
547            return refuse(
548                CommandStatus::Error,
549                format!("the ruling cannot be recorded: {e}"),
550            );
551        }
552    };
553    let (what, rest) = match &scenario {
554        Some(id) => (
555            format!("{id}'s tests are"),
556            ", and nothing else of the story's oracle",
557        ),
558        None => (
559            format!("{task}'s own test is"),
560            ", your reason given to its author",
561        ),
562    };
563    CommandResult::new(
564        "task",
565        CommandStatus::Ok,
566        &format!(
567            "{what} ruled wrong: the next run writes them again{rest} — `speccoder run --task {task}`"
568        ),
569    )
570    .with_data(CommandData::Control(Box::new(ControlReport {
571        feature: name,
572        task: task.to_owned(),
573        action: "tests_ruled_wrong".to_owned(),
574        text: scenario.or_else(|| Some(task.to_owned())),
575        record: Some(path.display().to_string()),
576    })))
577}