1use std::path::{Path, PathBuf};
10
11use serde_json::json;
12use speccoder_core::bug::{BugReport, Outcome, Stage};
13use speccoder_core::{CommandData, CommandResult, CommandStatus};
14
15use super::state;
16
17fn refuse(status: CommandStatus, message: &str) -> CommandResult {
18 CommandResult::new("bug", status, message)
19}
20
21#[must_use]
23pub fn keep(path: &Path, key: &str, reason: &str, state: Option<&Path>) -> CommandResult {
24 match state
25 .map(Path::to_path_buf)
26 .or_else(crate::worktree::state_dir)
27 {
28 Some(s) => keep_with(path, key, reason, &s),
29 None => refuse(
30 CommandStatus::ConfigError,
31 "no state directory (set SPECCODER_STATE_DIR or HOME)",
32 )
33 .with_code("NO-STATE-DIR"),
34 }
35}
36
37#[must_use]
39pub fn keep_with(path: &Path, key: &str, reason: &str, state: &Path) -> CommandResult {
40 let reason = crate::controls::one_line(reason);
41 if reason.is_empty() {
42 return refuse(
43 CommandStatus::UsageError,
44 "a reason is needed: it is the acceptance's record",
45 )
46 .with_code("USAGE");
47 }
48 let opened = match crate::format::open(path, None) {
49 Ok(o) => o,
50 Err(r) => return refuse(r.status, &r.message),
51 };
52 let root = PathBuf::from(&opened.snapshot.root);
53 let plane = crate::worktree::project_state_dir(state, &root);
54 let Some(mut bug) = state::find(&plane, key) else {
55 return refuse(CommandStatus::NotFound, &format!("no bug {key}"));
56 };
57 if bug.outcome != Outcome::AlreadySatisfied || bug.commit.is_some() {
58 return refuse(
59 CommandStatus::UsageError,
60 &format!(
61 "{key} is {}{}: only a bug whose reproductions already pass is kept as a regression test",
62 bug.outcome.as_str(),
63 if bug.commit.is_some() { " and kept" } else { "" }
64 ),
65 )
66 .with_code("USAGE");
67 }
68 let paths: Vec<String> = bug.reproductions.iter().map(|r| r.file.clone()).collect();
69 let files = state::repro(&plane, &bug.unit, &bug.task, &paths);
70 if files.len() != paths.len() || !bug.reproductions.iter().any(|r| r.reproduces()) {
71 return refuse(
72 CommandStatus::NeedsHuman,
73 &format!(
74 "{key}'s reproductions are not all kept in the state directory: reproduce it again (speccoder bug run --task {key})"
75 ),
76 );
77 }
78
79 let wt = if bug.home.kind.native() {
81 crate::commands::worktree_of(&opened.snapshot, &bug.unit, &bug.home.location, state)
82 } else {
83 crate::pipeline::workspace(path, Some(&bug.unit), state, &crate::RunOptions::default())
84 };
85 let Some(CommandData::Worktree(w)) = wt.data() else {
86 return CommandResult::carried("bug", &wt);
87 };
88 if wt.status() != CommandStatus::Ok {
89 return CommandResult::carried("bug", &wt);
90 }
91 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
92 return refuse(CommandStatus::Error, "worktree without a path");
93 };
94 let wt_root = PathBuf::from(&wt_path);
95 let project = PathBuf::from(&project_dir);
96 if crate::worktree::require_clean(&wt_root).is_err() {
97 return refuse(
98 CommandStatus::GitPrecondition,
99 &format!(
100 "the worktree {wt_path} has uncommitted changes; the next `speccoder run` sets them aside, or commit them yourself"
101 ),
102 )
103 .with_code("WORKTREE-DIRTY");
104 }
105 let format = crate::format::at(&project);
106
107 let task_line = if bug.home.kind.native() {
109 None
110 } else {
111 let Some(snap) = format.inspect(&project, Some(&bug.unit)) else {
112 return refuse(
113 CommandStatus::NotAProject,
114 format.messages().no_project_in_worktree,
115 );
116 };
117 let Some(tasks) = snap.feature.as_ref().and_then(|f| f.tasks.clone()) else {
118 return refuse(
119 CommandStatus::NotFound,
120 &format!("{} has no {}", bug.unit, format.messages().tasks_file),
121 );
122 };
123 let Some(t) = tasks.tasks.iter().find(|t| t.id == bug.task).cloned() else {
124 return refuse(
125 CommandStatus::NotFound,
126 &format!("no task {} in {}", bug.task, bug.unit),
127 );
128 };
129 if t.done {
130 return refuse(
131 CommandStatus::TaskNotReady,
132 &format!("{} is already done", bug.task),
133 )
134 .with_code("TASK-DONE");
135 }
136 let dir = snap
137 .feature
138 .as_ref()
139 .map(|f| f.feature.dir.clone())
140 .unwrap_or_default();
141 Some((t, project.join(&tasks.path), dir))
142 };
143
144 for (rel, content) in &files {
146 let full = project.join(rel);
147 match std::fs::read_to_string(&full) {
148 Ok(now) if now == *content => continue,
149 Ok(_) => {
150 return refuse(
151 CommandStatus::GitPrecondition,
152 &format!(
153 "`{rel}` exists and differs from the reproduction; it is not overwritten"
154 ),
155 );
156 }
157 Err(_) => {}
158 }
159 if let Some(parent) = full.parent() {
160 let _ = std::fs::create_dir_all(parent);
161 }
162 if let Err(e) = std::fs::write(&full, content) {
163 let _ = crate::git::Git::at(&wt_root).run(&["checkout", "--", "."]);
164 return refuse(CommandStatus::Error, &format!("`{rel}`: {e}"));
165 }
166 }
167 if let Some((t, file, _)) = &task_line
168 && let Err(e) = format.tick(file, &bug.task, &t.line_hash)
169 {
170 let _ = crate::git::Git::at(&wt_root).run(&["clean", "-fdq"]);
171 let _ = crate::git::Git::at(&wt_root).run(&["checkout", "--", "."]);
172 return refuse(CommandStatus::Error, &format!("tick refused: {e}"));
173 }
174 let gates = match crate::sandbox::runner_at(&project) {
176 Ok((runner, _)) => crate::hooks::gate::run(
177 &project,
178 &runner,
179 state,
180 crate::hooks::gate::Facts {
181 point: speccoder_core::hooks::Point::Tick,
182 feature: &bug.unit,
183 task: Some(&bug.task),
184 branch: None,
185 },
186 ),
187 Err(e) => return refuse(CommandStatus::ConfigError, &e),
188 };
189 if let Some(g) = speccoder_core::hooks::first_block(&gates) {
190 let _ = crate::git::Git::at(&wt_root).run(&["clean", "-fdq"]);
191 let _ = crate::git::Git::at(&wt_root).run(&["checkout", "--", "."]);
192 let code = g.code.clone().unwrap_or_else(|| "HOOK-BLOCKED".to_owned());
193 return refuse(
194 CommandStatus::NeedsHuman,
195 &format!("{}; {} is not kept", g.blocked_line(), bug.task),
196 )
197 .with_code(&code);
198 }
199 let gate_lines: String = gates
200 .iter()
201 .map(|g| format!("Gate: {} {}\n", g.name, g.outcome.as_str()))
202 .collect();
203 let by = crate::controls::person(&project);
204 let message = format!(
205 "speccoder: {task} kept as a regression test by {by}\n\n{title}\n\n{reason}\n\nSpec-Task: {unit}/{task}\nVerdict: attested\nAccepted-By: {by}\nReason: {reason}\nBug: already_satisfied ({home}) triad={triad}\n{gate_lines}",
206 task = bug.task,
207 title = bug.title,
208 unit = bug.unit,
209 home = bug.home.kind.as_str(),
210 triad = bug.triad.digest,
211 );
212 let commit = match crate::commit_attempt(&wt_root, &message) {
213 Ok(c) => c,
214 Err(e) => return refuse(CommandStatus::Error, &format!("commit failed: {e}")),
215 };
216 let attested = match &task_line {
217 Some((t, _, dir)) => crate::controls::attest_decision(
218 &wt_root,
219 &project,
220 t,
221 (&bug.unit, dir),
222 (&commit, "attested"),
223 (&by, &reason),
224 ),
225 None => attest_trailer_only(&wt_root, &project, &bug, &commit, (&by, &reason)),
226 };
227 bug.commit = Some(commit.clone());
228 bug.stage = Stage::Done;
229 bug.code = None;
230 let saved = state::save(&plane, &bug);
231 let mut said = format!(
232 "{} kept as a regression test by {by} (attested, not verified): the reproductions are committed in {}{attested}",
233 bug.key(),
234 commit.chars().take(7).collect::<String>()
235 );
236 if let Err(e) = &saved {
237 said.push_str(&format!("; the bug's state could not be updated: {e}"));
238 }
239 CommandResult::new(
240 "bug",
241 if saved.is_ok() {
242 CommandStatus::Ok
243 } else {
244 CommandStatus::Error
245 },
246 &said,
247 )
248 .with_next(super::handoff::next(&bug, bug.outcome))
249 .with_data(CommandData::Bug(Box::new(bug)))
250}
251
252fn attest_trailer_only(
255 repo: &Path,
256 project: &Path,
257 bug: &BugReport,
258 commit: &str,
259 decider: (&str, &str),
260) -> String {
261 let config = crate::verify::find_config(project)
262 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
263 .unwrap_or_default();
264 let Ok(run_config) = crate::substrate::run_config::parse(&config) else {
265 return String::new();
266 };
267 if !run_config.attest.enabled {
268 return String::new();
269 }
270 let format = format!("{:?}", crate::format::at(project).kind()).to_lowercase();
271 let story = format!(
272 "A bug, in the reporter's words.\n\n{}",
273 bug.triad.labelled()
274 );
275 let tick = crate::attest::Tick {
276 repo,
277 config: &config,
278 attest: &run_config.attest,
279 format: &format,
280 feature: &bug.unit,
281 feature_dir: &bug.home.location,
282 task: &bug.task,
283 title: &bug.title,
284 line_hash: "",
285 section: None,
286 section_text: &story,
287 requirements: &[],
288 commit,
289 verdict: "attested",
290 sandbox: serde_json::Value::Null,
291 executor: serde_json::Value::Null,
292 model: serde_json::Value::Null,
293 bug: Some((
294 json!({
295 "home": bug.home.kind.as_str(),
296 "triad_sha256": bug.triad.digest,
297 "outcome": bug.outcome.as_str(),
298 "reproductions": bug.reproductions.iter().map(|r| json!({
299 "statement": r.statement, "baseline": r.baseline, "final": r.final_, "revert": r.revert,
300 })).collect::<Vec<_>>(),
301 }),
302 json!({ "source": "kept_regression_test" }),
303 )),
304 };
305 let a = crate::attest::record_decision(&tick, decider.0, decider.1);
306 match (a["state"].as_str(), a["reason"].as_str()) {
307 (Some("signed"), _) => format!(
308 "; attested: signed ({})",
309 a["signer"].as_str().unwrap_or("?")
310 ),
311 (Some(state), Some(why)) => format!("; attestation {state}: {why}"),
312 (Some(state), None) => format!("; attestation {state}"),
313 _ => String::new(),
314 }
315}