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 #[serde(default)]
66 pub scenario: Option<String>,
67 #[serde(default)]
69 pub story: String,
70 pub task: String,
72 pub reason: String,
74 pub by: String,
76 pub via: Via,
78 pub at: u64,
80}
81
82fn rulings_file(plane: &Path) -> PathBuf {
83 plane.join("decisions").join("oracle-rulings.json")
84}
85
86pub fn rulings(plane: &Path) -> Result<Vec<Ruling>, String> {
93 let path = rulings_file(plane);
94 match std::fs::read_to_string(&path) {
95 Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: {e}", path.display())),
96 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
97 Err(e) => Err(format!("{}: {e}", path.display())),
98 }
99}
100
101fn write_rulings(plane: &Path, change: impl FnOnce(&mut Vec<Ruling>)) -> Result<PathBuf, String> {
103 let path = rulings_file(plane);
104 if let Some(dir) = path.parent() {
105 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
106 }
107 let lock = std::fs::File::create(path.with_extension("lock"))
108 .map_err(|e| format!("{}: {e}", path.display()))?;
109 lock.lock()
110 .map_err(|e| format!("{}: {e}", path.display()))?;
111 let mut list = rulings(plane)?;
112 change(&mut list);
113 let tmp = path.with_extension("json.tmp");
114 std::fs::write(
115 &tmp,
116 serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
117 )
118 .and_then(|()| std::fs::rename(&tmp, &path))
119 .map_err(|e| format!("{}: {e}", path.display()))?;
120 Ok(path)
121}
122
123pub fn rulings_for(plane: &Path, feature: &str, story: &str) -> Result<Vec<Ruling>, String> {
130 Ok(rulings(plane)?
131 .into_iter()
132 .filter(|r| r.feature == feature && r.story == story && r.scenario.is_some())
133 .collect())
134}
135
136pub fn acted_on(plane: &Path, feature: &str, scenarios: &[String]) -> Result<(), String> {
142 write_rulings(plane, |l| {
143 l.retain(|r| {
144 !(r.feature == feature && r.scenario.as_ref().is_some_and(|s| scenarios.contains(s)))
145 });
146 })
147 .map(|_| ())
148}
149
150pub fn rulings_for_task(plane: &Path, feature: &str, task: &str) -> Result<Vec<Ruling>, String> {
157 Ok(rulings(plane)?
158 .into_iter()
159 .filter(|r| r.feature == feature && r.task == task && r.scenario.is_none())
160 .collect())
161}
162
163pub fn acted_on_task(plane: &Path, feature: &str, task: &str) -> Result<(), String> {
169 write_rulings(plane, |l| {
170 l.retain(|r| !(r.feature == feature && r.task == task && r.scenario.is_none()));
171 })
172 .map(|_| ())
173}
174
175#[must_use]
177pub fn story_at(plane: &Path, feature: &str, task: &str) -> Option<String> {
178 all(plane)
179 .into_iter()
180 .find(|d| d.feature == feature && d.task == task)
181 .map(|d| d.story)
182}
183
184#[must_use]
187pub fn decide(
188 root: &Path,
189 feature: Option<&str>,
190 d: (&str, &str, bool),
191 via: Via,
192 state: &Path,
193) -> CommandResult {
194 let (story, task, revoke) = d;
195 let refuse = |status, m: String| CommandResult::new("task", status, &m);
196 let Ok(opened) = crate::format::open(root, feature) else {
197 return refuse(
198 CommandStatus::NotAProject,
199 "no spec project here".to_owned(),
200 );
201 };
202 let Some(f) = opened.snapshot.feature.as_ref() else {
203 return refuse(
204 CommandStatus::SelectionNeeded,
205 "no feature selected".to_owned(),
206 );
207 };
208 let name = f.feature.name.clone();
209 let structure = opened.adapter.plan_structure(&opened.snapshot);
210 let Some(planned) = structure
211 .as_ref()
212 .and_then(|s| s.tasks.iter().find(|t| t.id == task))
213 else {
214 return refuse(
215 CommandStatus::NotFound,
216 format!("{task} is not a task of {name} with a phase structure"),
217 );
218 };
219 if !matches!(
220 planned.phase,
221 crate::format::PhaseKind::Setup | crate::format::PhaseKind::Foundational
222 ) {
223 return refuse(
224 CommandStatus::UsageError,
225 format!(
226 "{task} is not a Setup or Foundational task: its own story's acceptance applies"
227 ),
228 );
229 }
230 if !structure
231 .as_ref()
232 .is_some_and(|s| s.stories.contains_key(story))
233 {
234 return refuse(
235 CommandStatus::NotFound,
236 format!("{story} is not a story of {name}"),
237 );
238 }
239 let plane = crate::worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
240 let mut list = match read_all(&plane) {
241 Ok(l) => l,
242 Err(e) => {
243 return refuse(
244 CommandStatus::Error,
245 format!("the recorded decisions cannot be read, so none is changed: {e}"),
246 );
247 }
248 };
249 list.retain(|x| !(x.feature == name && (x.story == story || x.task == task)));
251 if !revoke {
252 list.push(Decision {
253 feature: name.clone(),
254 story: story.to_owned(),
255 task: task.to_owned(),
256 by: crate::controls::person(root),
257 via,
258 at: std::time::SystemTime::now()
259 .duration_since(std::time::UNIX_EPOCH)
260 .map_or(0, |d| d.as_secs()),
261 });
262 }
263 let path = file(&plane);
264 let written = path
265 .parent()
266 .map_or(Ok(()), std::fs::create_dir_all)
267 .and_then(|()| {
268 std::fs::write(
269 &path,
270 serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
271 )
272 });
273 if let Err(e) = written {
274 return refuse(
275 CommandStatus::Error,
276 format!("cannot write {}: {e}", path.display()),
277 );
278 }
279 let message = if revoke {
280 format!("{story}'s acceptance tests are written by its own tasks again")
281 } else {
282 format!(
283 "{task} writes {story}'s acceptance tests: its tick can be verified on {story}'s scenarios"
284 )
285 };
286 CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
287 Box::new(ControlReport {
288 feature: name,
289 task: task.to_owned(),
290 action: if revoke {
291 "acceptance_at_revoked"
292 } else {
293 "acceptance_at"
294 }
295 .to_owned(),
296 text: Some(story.to_owned()),
297 record: Some(path.display().to_string()),
298 }),
299 ))
300}
301
302#[must_use]
309pub fn rule_tests_wrong(
310 root: &Path,
311 feature: Option<&str>,
312 task: &str,
313 scenario: Option<&str>,
314 reason: &str,
315 via: Via,
316 state: &Path,
317) -> CommandResult {
318 let refuse = |status, m: String| CommandResult::new("task", status, &m);
319 if reason.trim().is_empty() {
320 return refuse(
321 CommandStatus::UsageError,
322 "a ruling needs a reason".to_owned(),
323 );
324 }
325 let Ok(opened) = crate::format::open(root, feature) else {
326 return refuse(
327 CommandStatus::NotAProject,
328 "no spec project here".to_owned(),
329 );
330 };
331 let snap = &opened.snapshot;
332 let Some(f) = snap.feature.as_ref() else {
333 return refuse(
334 CommandStatus::SelectionNeeded,
335 "no feature selected".to_owned(),
336 );
337 };
338 let name = f.feature.name.clone();
339 let Some(t) = f
340 .tasks
341 .as_ref()
342 .and_then(|ts| ts.tasks.iter().find(|t| t.id == task))
343 else {
344 return refuse(CommandStatus::NotFound, format!("no task {task} in {name}"));
345 };
346 let openspec = opened.adapter.kind() == speccoder_core::spec::FormatKind::Openspec;
347 let plane = crate::worktree::project_state_dir(state, Path::new(&snap.root));
348 let story = if openspec {
351 Some(name.clone())
352 } else {
353 story_at(&plane, &name, task).or_else(|| t.story.clone())
354 };
355 let known = opened.adapter.scenarios(snap, task);
356 let ids = || {
357 known
358 .iter()
359 .map(|s| s.id.as_str())
360 .collect::<Vec<_>>()
361 .join(", ")
362 };
363 let rules = opened.adapter.rules().judged;
364 let unit_dir = f.feature.dir.clone();
365 let scenario = match (&story, scenario) {
366 (Some(_), Some(id)) if known.iter().any(|s| s.id == id) => Some(id.to_owned()),
367 (Some(_), Some(id)) => {
368 return refuse(
369 CommandStatus::NotFound,
370 format!("{id} is not a scenario of {task}'s story ({})", ids()),
371 );
372 }
373 (Some(st), None) => {
374 return refuse(
375 CommandStatus::UsageError,
376 format!(
377 "{task} is of {st}: name the scenario whose tests are wrong with --scenario ({})",
378 ids()
379 ),
380 );
381 }
382 (None, Some(_)) => {
383 return refuse(
384 CommandStatus::UsageError,
385 format!("{task} is in no story: its own test is ruled, without --scenario"),
386 );
387 }
388 (None, None)
389 if known.is_empty()
390 || crate::pipeline::acceptance::checks_only(t, &unit_dir, &rules) =>
391 {
392 return refuse(
393 CommandStatus::UsageError,
394 format!("{task} has no tests of its own to rule on: its checks judge it"),
395 );
396 }
397 (None, None) => None,
398 };
399 let ruling = Ruling {
400 feature: name.clone(),
401 scenario: scenario.clone(),
402 story: story.unwrap_or_default(),
403 task: task.to_owned(),
404 reason: reason.trim().to_owned(),
405 by: crate::controls::person(root),
406 via,
407 at: std::time::SystemTime::now()
408 .duration_since(std::time::UNIX_EPOCH)
409 .map_or(0, |d| d.as_secs()),
410 };
411 let path = match write_rulings(&plane, |l| {
412 if scenario.is_some() {
415 l.retain(|r| !(r.feature == name && r.scenario == scenario));
416 }
417 l.push(ruling);
418 }) {
419 Ok(p) => p,
420 Err(e) => {
421 return refuse(
422 CommandStatus::Error,
423 format!("the ruling cannot be recorded: {e}"),
424 );
425 }
426 };
427 let (what, rest) = match &scenario {
428 Some(id) => (
429 format!("{id}'s tests are"),
430 ", and nothing else of the story's oracle",
431 ),
432 None => (
433 format!("{task}'s own test is"),
434 ", your reason given to its author",
435 ),
436 };
437 CommandResult::new(
438 "task",
439 CommandStatus::Ok,
440 &format!(
441 "{what} ruled wrong: the next run writes them again{rest} — `speccoder run --task {task}`"
442 ),
443 )
444 .with_data(CommandData::Control(Box::new(ControlReport {
445 feature: name,
446 task: task.to_owned(),
447 action: "tests_ruled_wrong".to_owned(),
448 text: scenario.or_else(|| Some(task.to_owned())),
449 record: Some(path.display().to_string()),
450 })))
451}