1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Ruling {
61 pub feature: String,
63 pub scenario: String,
65 #[serde(default)]
67 pub story: String,
68 pub task: String,
70 pub reason: String,
72 pub by: String,
74 pub via: Via,
76 pub at: u64,
78}
79
80fn rulings_file(plane: &Path) -> PathBuf {
81 plane.join("decisions").join("oracle-rulings.json")
82}
83
84pub 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
99fn 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
121pub 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
134pub 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#[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#[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 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#[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 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}