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 Deferral {
61 pub feature: String,
63 pub task: String,
65 pub checkpoint: String,
67 pub by: String,
69 pub via: Via,
71 pub at: u64,
73}
74
75fn deferrals_file(plane: &Path) -> PathBuf {
76 plane.join("decisions").join("acceptance-deferred.json")
77}
78
79pub fn read_deferrals(plane: &Path) -> Result<Vec<Deferral>, String> {
86 let path = deferrals_file(plane);
87 match std::fs::read_to_string(&path) {
88 Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: {e}", path.display())),
89 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
90 Err(e) => Err(format!("{}: {e}", path.display())),
91 }
92}
93
94#[must_use]
96pub fn deferred(plane: &Path, feature: &str, task: &str) -> Option<Deferral> {
97 read_deferrals(plane)
98 .unwrap_or_default()
99 .into_iter()
100 .find(|d| d.feature == feature && d.task == task)
101}
102
103#[must_use]
105pub fn story_at(plane: &Path, feature: &str, task: &str) -> Option<String> {
106 all(plane)
107 .into_iter()
108 .find(|d| d.feature == feature && d.task == task)
109 .map(|d| d.story)
110}
111
112#[must_use]
115pub fn decide(
116 root: &Path,
117 feature: Option<&str>,
118 d: (&str, &str, bool),
119 via: Via,
120 state: &Path,
121) -> CommandResult {
122 let (story, task, revoke) = d;
123 let refuse = |status, m: String| CommandResult::new("task", status, &m);
124 let Ok(opened) = crate::format::open(root, feature) else {
125 return refuse(
126 CommandStatus::NotAProject,
127 "no spec project here".to_owned(),
128 );
129 };
130 let Some(f) = opened.snapshot.feature.as_ref() else {
131 return refuse(
132 CommandStatus::SelectionNeeded,
133 "no feature selected".to_owned(),
134 );
135 };
136 let name = f.feature.name.clone();
137 let structure = opened.adapter.plan_structure(&opened.snapshot);
138 let Some(planned) = structure
139 .as_ref()
140 .and_then(|s| s.tasks.iter().find(|t| t.id == task))
141 else {
142 return refuse(
143 CommandStatus::NotFound,
144 format!("{task} is not a task of {name} with a phase structure"),
145 );
146 };
147 if !matches!(
148 planned.phase,
149 crate::format::PhaseKind::Setup | crate::format::PhaseKind::Foundational
150 ) {
151 return refuse(
152 CommandStatus::UsageError,
153 format!(
154 "{task} is not a Setup or Foundational task: its own story's acceptance applies"
155 ),
156 );
157 }
158 if !structure
159 .as_ref()
160 .is_some_and(|s| s.stories.contains_key(story))
161 {
162 return refuse(
163 CommandStatus::NotFound,
164 format!("{story} is not a story of {name}"),
165 );
166 }
167 let plane = crate::worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
168 let mut list = match read_all(&plane) {
169 Ok(l) => l,
170 Err(e) => {
171 return refuse(
172 CommandStatus::Error,
173 format!("the recorded decisions cannot be read, so none is changed: {e}"),
174 );
175 }
176 };
177 list.retain(|x| !(x.feature == name && (x.story == story || x.task == task)));
179 if !revoke {
180 list.push(Decision {
181 feature: name.clone(),
182 story: story.to_owned(),
183 task: task.to_owned(),
184 by: crate::controls::person(root),
185 via,
186 at: std::time::SystemTime::now()
187 .duration_since(std::time::UNIX_EPOCH)
188 .map_or(0, |d| d.as_secs()),
189 });
190 }
191 let path = file(&plane);
192 let written = path
193 .parent()
194 .map_or(Ok(()), std::fs::create_dir_all)
195 .and_then(|()| {
196 std::fs::write(
197 &path,
198 serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
199 )
200 });
201 if let Err(e) = written {
202 return refuse(
203 CommandStatus::Error,
204 format!("cannot write {}: {e}", path.display()),
205 );
206 }
207 let message = if revoke {
208 format!("{story}'s acceptance tests are written by its own tasks again")
209 } else {
210 format!(
211 "{task} writes {story}'s acceptance tests: its tick can be verified on {story}'s scenarios"
212 )
213 };
214 CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
215 Box::new(ControlReport {
216 feature: name,
217 task: task.to_owned(),
218 action: if revoke {
219 "acceptance_at_revoked"
220 } else {
221 "acceptance_at"
222 }
223 .to_owned(),
224 text: Some(story.to_owned()),
225 record: Some(path.display().to_string()),
226 }),
227 ))
228}
229
230#[must_use]
235pub fn defer(
236 root: &Path,
237 feature: Option<&str>,
238 task: &str,
239 revoke: bool,
240 via: Via,
241 state: &Path,
242) -> CommandResult {
243 use speccoder_core::story::Share;
244 let refuse = |status, m: String| CommandResult::new("task", status, &m);
245 let Ok(opened) = crate::format::open(root, feature) else {
246 return refuse(
247 CommandStatus::NotAProject,
248 "no spec project here".to_owned(),
249 );
250 };
251 let snap = &opened.snapshot;
252 let Some(f) = snap.feature.as_ref() else {
253 return refuse(
254 CommandStatus::SelectionNeeded,
255 "no feature selected".to_owned(),
256 );
257 };
258 let name = f.feature.name.clone();
259 let Some(t) = f
260 .tasks
261 .as_ref()
262 .and_then(|ts| ts.tasks.iter().find(|t| t.id == task))
263 else {
264 return refuse(
265 CommandStatus::NotFound,
266 format!("{task} is not a task of {name}"),
267 );
268 };
269 let plane = crate::worktree::project_state_dir(state, Path::new(&snap.root));
270 let path = deferrals_file(&plane);
273 if let Some(dir) = path.parent()
274 && let Err(e) = std::fs::create_dir_all(dir)
275 {
276 return refuse(
277 CommandStatus::Error,
278 format!("cannot write {}: {e}", dir.display()),
279 );
280 }
281 let lock = match std::fs::File::create(path.with_extension("lock")) {
282 Ok(f) => f,
283 Err(e) => {
284 return refuse(
285 CommandStatus::Error,
286 format!("cannot lock {}: {e}", path.display()),
287 );
288 }
289 };
290 if let Err(e) = lock.lock() {
291 return refuse(
292 CommandStatus::Error,
293 format!("cannot lock {}: {e}", path.display()),
294 );
295 }
296 let mut list = match read_deferrals(&plane) {
297 Ok(l) => l,
298 Err(e) => {
299 return refuse(
300 CommandStatus::Error,
301 format!("the recorded decisions cannot be read, so none is changed: {e}"),
302 );
303 }
304 };
305 let checkpoint = if revoke {
306 String::new()
307 } else {
308 let scenarios = opened.adapter.scenarios(snap, task);
309 let judged = opened.adapter.rules().judged;
310 let share =
311 crate::pipeline::acceptance::share_of(&crate::pipeline::acceptance::ShareInput {
312 repo: root,
313 plane: &plane,
314 feature: &name,
315 task,
316 story: t.story.as_deref(),
317 whole_unit: opened.adapter.kind() == speccoder_core::spec::FormatKind::Openspec,
318 tasks: f.tasks.as_ref(),
319 unit_dir: &f.feature.dir,
320 judged: &judged,
321 scenarios: &scenarios,
322 as_deferred: true,
323 });
324 match share {
325 Some(Share::Deferred { checkpoint }) => checkpoint,
326 Some(Share::Checkpoint { .. }) => {
327 return refuse(
328 CommandStatus::UsageError,
329 format!(
330 "{task} is its story's checkpoint: no later task would judge its scenarios"
331 ),
332 );
333 }
334 Some(Share::Linked { scenarios }) => {
335 return refuse(
336 CommandStatus::UsageError,
337 format!(
338 "{task} names its scenarios ({}): it is judged by them — change the link in the task list to move them",
339 scenarios.join(", ")
340 ),
341 );
342 }
343 Some(Share::Authored { .. }) | None => {
344 return refuse(
345 CommandStatus::UsageError,
346 format!("{task} is not a task of a story with a later code task"),
347 );
348 }
349 }
350 };
351 let had = list.iter().any(|d| d.feature == name && d.task == task);
352 if revoke && !had {
353 return refuse(
354 CommandStatus::Ok,
355 format!("{task} was not deferred: nothing to take back"),
356 );
357 }
358 list.retain(|d| !(d.feature == name && d.task == task));
359 if !revoke {
360 list.push(Deferral {
361 feature: name.clone(),
362 task: task.to_owned(),
363 checkpoint: checkpoint.clone(),
364 by: crate::controls::person(root),
365 via,
366 at: std::time::SystemTime::now()
367 .duration_since(std::time::UNIX_EPOCH)
368 .map_or(0, |d| d.as_secs()),
369 });
370 }
371 let tmp = path.with_extension("json.tmp");
373 let written = std::fs::write(
374 &tmp,
375 serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_owned()),
376 )
377 .and_then(|()| std::fs::rename(&tmp, &path));
378 if let Err(e) = written {
379 return refuse(
380 CommandStatus::Error,
381 format!("cannot write {}: {e}", path.display()),
382 );
383 }
384 let message = if revoke {
385 format!("{task} is judged by its own acceptance tests again")
386 } else {
387 format!(
388 "{task} is judged by its regression checks; its story's scenarios at {checkpoint} — run it again: `speccoder run --task {task}`"
389 )
390 };
391 CommandResult::new("task", CommandStatus::Ok, &message).with_data(CommandData::Control(
392 Box::new(ControlReport {
393 feature: name,
394 task: task.to_owned(),
395 action: if revoke { "defer_revoked" } else { "deferred" }.to_owned(),
396 text: (!revoke).then_some(checkpoint),
397 record: Some(path.display().to_string()),
398 }),
399 ))
400}