speccoder_runtime/
acceptance_at.rs1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use speccoder_core::actions::Via;
12use speccoder_core::{CommandData, CommandResult, CommandStatus, ControlReport};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Decision {
17 pub feature: String,
19 pub story: String,
21 pub task: String,
23 pub by: String,
25 pub via: Via,
27 pub at: u64,
29}
30
31fn file(plane: &Path) -> PathBuf {
32 plane.join("decisions").join("acceptance-at.json")
33}
34
35pub 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#[must_use]
52pub fn all(plane: &Path) -> Vec<Decision> {
53 read_all(plane).unwrap_or_default()
54}
55
56#[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#[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 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}