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 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 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
171pub 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
189fn 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
218pub 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
231pub 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
245pub 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
258pub 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#[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#[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 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#[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(opened) = crate::format::open(root, feature) else {
421 return refuse(
422 CommandStatus::NotAProject,
423 "no spec project here".to_owned(),
424 );
425 };
426 let snap = &opened.snapshot;
427 let Some(f) = snap.feature.as_ref() else {
428 return refuse(
429 CommandStatus::SelectionNeeded,
430 "no feature selected".to_owned(),
431 );
432 };
433 let name = f.feature.name.clone();
434 let Some(t) = f
435 .tasks
436 .as_ref()
437 .and_then(|ts| ts.tasks.iter().find(|t| t.id == task))
438 else {
439 return refuse(CommandStatus::NotFound, format!("no task {task} in {name}"));
440 };
441 let openspec = opened.adapter.kind() == speccoder_core::spec::FormatKind::Openspec;
442 let plane = crate::worktree::project_state_dir(state, Path::new(&snap.root));
443 let rules = opened.adapter.rules().judged;
446 let unit_dir = f.feature.dir.clone();
447 let mut known = opened.adapter.scenarios(snap, task);
448 let linked = if openspec || t.story.is_some() {
451 None
452 } else {
453 crate::pipeline::acceptance::linked_story(
454 t,
455 &known,
456 &unit_dir,
457 &rules,
458 &opened.adapter.oracle_requirements(snap),
459 )
460 };
461 if let Some((_, links)) = &linked {
462 known.retain(|s| links.contains(&s.id));
463 }
464 let story = if openspec {
465 Some(name.clone())
466 } else {
467 story_at(&plane, &name, task)
468 .or_else(|| t.story.clone())
469 .or_else(|| linked.map(|(r, _)| r.key))
470 };
471 let ids = || {
472 known
473 .iter()
474 .map(|s| s.id.as_str())
475 .collect::<Vec<_>>()
476 .join(", ")
477 };
478 let scenario = match (&story, scenario) {
479 (Some(_), Some(id)) if known.iter().any(|s| s.id == id) => Some(id.to_owned()),
480 (Some(_), Some(id)) => {
481 return refuse(
482 CommandStatus::NotFound,
483 format!("{id} is not a scenario of {task}'s story ({})", ids()),
484 );
485 }
486 (Some(st), None) => {
487 return refuse(
488 CommandStatus::UsageError,
489 format!(
490 "{task} is of {st}: name the scenario whose tests are wrong with --scenario ({})",
491 ids()
492 ),
493 );
494 }
495 (None, Some(_)) => {
496 return refuse(
497 CommandStatus::UsageError,
498 format!("{task} is in no story: its own test is ruled, without --scenario"),
499 );
500 }
501 (None, None)
502 if known.is_empty()
503 || crate::pipeline::acceptance::checks_only(t, &unit_dir, &rules) =>
504 {
505 return refuse(
506 CommandStatus::UsageError,
507 format!("{task} has no tests of its own to rule on: its checks judge it"),
508 );
509 }
510 (None, None) => None,
511 };
512 let ruling = Ruling {
513 feature: name.clone(),
514 scenario: scenario.clone(),
515 story: story.unwrap_or_default(),
516 task: task.to_owned(),
517 reason: reason.trim().to_owned(),
518 by: crate::controls::person(root),
519 via,
520 at: std::time::SystemTime::now()
521 .duration_since(std::time::UNIX_EPOCH)
522 .map_or(0, |d| d.as_secs()),
523 };
524 let path = match write_rulings(&plane, |l| {
525 if scenario.is_some() {
528 l.retain(|r| !(r.feature == name && r.scenario == scenario));
529 }
530 l.push(ruling);
531 }) {
532 Ok(p) => p,
533 Err(e) => {
534 return refuse(
535 CommandStatus::Error,
536 format!("the ruling cannot be recorded: {e}"),
537 );
538 }
539 };
540 let (what, rest) = match &scenario {
541 Some(id) => (
542 format!("{id}'s tests are"),
543 ", and nothing else of the story's oracle",
544 ),
545 None => (
546 format!("{task}'s own test is"),
547 ", your reason given to its author",
548 ),
549 };
550 CommandResult::new(
551 "task",
552 CommandStatus::Ok,
553 &format!(
554 "{what} ruled wrong: the next run writes them again{rest} — `speccoder run --task {task}`"
555 ),
556 )
557 .with_data(CommandData::Control(Box::new(ControlReport {
558 feature: name,
559 task: task.to_owned(),
560 action: "tests_ruled_wrong".to_owned(),
561 text: scenario.or_else(|| Some(task.to_owned())),
562 record: Some(path.display().to_string()),
563 })))
564}