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/// The story `task` of `feature` writes the acceptance tests of, if any.
57#[must_use]
58pub fn story_at(plane: &Path, feature: &str, task: &str) -> Option<String> {
59    all(plane)
60        .into_iter()
61        .find(|d| d.feature == feature && d.task == task)
62        .map(|d| d.story)
63}
64
65/// Records (or, with `revoke`, removes) the decision for `story` of the
66/// project at `root`; `state` is the state directory.
67#[must_use]
68pub fn decide(
69    root: &Path,
70    feature: Option<&str>,
71    d: (&str, &str, bool),
72    via: Via,
73    state: &Path,
74) -> CommandResult {
75    let (story, task, revoke) = d;
76    let refuse = |status, m: String| CommandResult::new("task", status, &m);
77    let Ok(opened) = crate::format::open(root, feature) else {
78        return refuse(
79            CommandStatus::NotAProject,
80            "no spec project here".to_owned(),
81        );
82    };
83    let Some(f) = opened.snapshot.feature.as_ref() else {
84        return refuse(
85            CommandStatus::SelectionNeeded,
86            "no feature selected".to_owned(),
87        );
88    };
89    let name = f.feature.name.clone();
90    let structure = opened.adapter.plan_structure(&opened.snapshot);
91    let Some(planned) = structure
92        .as_ref()
93        .and_then(|s| s.tasks.iter().find(|t| t.id == task))
94    else {
95        return refuse(
96            CommandStatus::NotFound,
97            format!("{task} is not a task of {name} with a phase structure"),
98        );
99    };
100    if !matches!(
101        planned.phase,
102        crate::format::PhaseKind::Setup | crate::format::PhaseKind::Foundational
103    ) {
104        return refuse(
105            CommandStatus::UsageError,
106            format!(
107                "{task} is not a Setup or Foundational task: its own story's acceptance applies"
108            ),
109        );
110    }
111    if !structure
112        .as_ref()
113        .is_some_and(|s| s.stories.contains_key(story))
114    {
115        return refuse(
116            CommandStatus::NotFound,
117            format!("{story} is not a story of {name}"),
118        );
119    }
120    let plane = crate::worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
121    let mut list = match read_all(&plane) {
122        Ok(l) => l,
123        Err(e) => {
124            return refuse(
125                CommandStatus::Error,
126                format!("the recorded decisions cannot be read, so none is changed: {e}"),
127            );
128        }
129    };
130    // One decision per story, and one story per task.
131    list.retain(|x| !(x.feature == name && (x.story == story || x.task == task)));
132    if !revoke {
133        list.push(Decision {
134            feature: name.clone(),
135            story: story.to_owned(),
136            task: task.to_owned(),
137            by: crate::controls::person(root),
138            via,
139            at: std::time::SystemTime::now()
140                .duration_since(std::time::UNIX_EPOCH)
141                .map_or(0, |d| d.as_secs()),
142        });
143    }
144    let path = file(&plane);
145    let written = path
146        .parent()
147        .map_or(Ok(()), std::fs::create_dir_all)
148        .and_then(|()| {
149            std::fs::write(
150                &path,
151                serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
152            )
153        });
154    if let Err(e) = written {
155        return refuse(
156            CommandStatus::Error,
157            format!("cannot write {}: {e}", path.display()),
158        );
159    }
160    let message = if revoke {
161        format!("{story}'s acceptance tests are written by its own tasks again")
162    } else {
163        format!(
164            "{task} writes {story}'s acceptance tests: its tick can be verified on {story}'s scenarios"
165        )
166    };
167    CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
168        Box::new(ControlReport {
169            feature: name,
170            task: task.to_owned(),
171            action: if revoke {
172                "acceptance_at_revoked"
173            } else {
174                "acceptance_at"
175            }
176            .to_owned(),
177            text: Some(story.to_owned()),
178            record: Some(path.display().to_string()),
179        }),
180    ))
181}