1use std::path::Path;
5use std::sync::Arc;
6
7use speccoder_core::snapshot::{Diagnostic, ProjectSnapshot, Severity};
8use speccoder_core::{CommandData, CommandResult, CommandStatus};
9
10use crate::commands::*;
11use crate::{approvals, progress, substrate, verify, worktree};
12
13pub mod acceptance;
14pub mod commit;
15pub mod feature;
16pub mod full;
17pub mod judge;
18pub mod lanes;
19pub mod outcome;
20pub mod samples;
21pub mod task;
22
23pub(crate) use acceptance::*;
24pub(crate) use commit::*;
25pub use feature::*;
26pub(crate) use judge::*;
27pub(crate) use lanes::*;
28pub(crate) use outcome::*;
29pub(crate) use samples::*;
30pub use task::*;
31
32pub(crate) struct Setup {
34 pub(crate) snapshot: Box<speccoder_core::snapshot::ProjectSnapshot>,
35 pub(crate) model: substrate::model_config::ModelConfig,
36 pub(crate) plane_dir: std::path::PathBuf,
37 pub(crate) format: &'static dyn crate::format::Adapter,
39}
40
41pub(crate) fn setup(
42 path: &Path,
43 feature: Option<&str>,
44 state: &Path,
45 command: &str,
46) -> Result<Setup, Box<CommandResult>> {
47 let inspected = inspect(path, feature);
48 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
49 return Err(Box::new(CommandResult::carried(command, &inspected)));
50 };
51 let snapshot = snapshot.clone();
52 preflight(&snapshot, command)?;
53 let config_error =
54 |m: String| Box::new(CommandResult::new(command, CommandStatus::ConfigError, &m));
55 let root = Path::new(&snapshot.root);
56 let config_dir = verify::find_config(root).ok_or_else(|| {
57 config_error(format!(
58 "no speccoder.toml at or above {}; add [model] provider = \"anthropic\", model = \"…\"",
59 root.display()
60 ))
61 })?;
62 let text = std::fs::read_to_string(config_dir.join(verify::config::FILE))
63 .map_err(|e| config_error(format!("speccoder.toml: {e}")))?;
64 let model = substrate::model_config::parse(&text)
65 .map_err(|e| config_error(format!("speccoder.toml: {e}")))?
66 .ok_or_else(|| config_error("speccoder.toml has no [model] section".to_owned()))?;
67 Ok(Setup {
68 format: crate::format::of(&snapshot),
69 plane_dir: worktree::project_state_dir(state, root),
70 snapshot,
71 model,
72 })
73}
74
75fn preflight(snapshot: &ProjectSnapshot, command: &str) -> Result<(), Box<CommandResult>> {
79 if !matches!(command, "run" | "propose") {
80 return Ok(());
81 }
82 let format = crate::format::of(snapshot);
83 let mut extra = Vec::new();
84 if command == "run" {
85 match crate::sandbox::runner_at(Path::new(&snapshot.root)) {
86 Ok((runner, _)) => extra = format.crosscheck(snapshot, &runner),
87 Err(e) => extra.push(Diagnostic::new(
88 Severity::Info,
89 "crosscheck_skipped",
90 format!("cross-check skipped: {e}"),
91 )),
92 }
93 }
94 let problems = format.blocking(snapshot).len()
95 + extra
96 .iter()
97 .filter(|d| d.severity == Severity::Error)
98 .count();
99 if problems == 0 {
100 return Ok(());
101 }
102 let mut shown = snapshot.clone();
103 shown.diagnostics.extend(extra);
104 let name = shown
105 .feature
106 .as_ref()
107 .map(|f| f.feature.name.clone())
108 .unwrap_or_default();
109 Err(Box::new(
110 CommandResult::new(
111 command,
112 CommandStatus::DiagnosticsError,
113 &format!(
114 "{} {name} cannot run: {problems} problem(s)",
115 format.wording().unit
116 ),
117 )
118 .with_data(CommandData::Inspect(Box::new(shown))),
119 ))
120}
121
122pub(crate) fn tokio_runtime() -> Result<tokio::runtime::Runtime, String> {
123 tokio::runtime::Builder::new_current_thread()
124 .enable_all()
125 .build()
126 .map_err(|e| format!("cannot start the async runtime: {e}"))
127}
128
129pub(crate) fn redact(text: &str, secret: Option<&str>) -> String {
131 match secret {
132 Some(s) if !s.is_empty() => text.replace(s, "[redacted]"),
133 _ => text.to_owned(),
134 }
135}
136
137#[derive(Debug, Clone, Default)]
139pub struct RunOptions {
140 pub max_tasks: Option<usize>,
142 pub approver: Option<Arc<dyn approvals::Approver>>,
145 pub progress: Option<Arc<dyn progress::Progress>>,
147 pub parallel: usize,
149 pub lane: Option<String>,
151 pub cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
154 pub ignore_checklists: bool,
157 pub fresh_acceptance: bool,
160 pub allow_failing_baseline: bool,
164 pub run_grants: Arc<std::sync::Mutex<Vec<crate::tools::policy::Grant>>>,
167 pub in_place: bool,
170 pub session: Option<Arc<std::sync::Mutex<crate::in_place::Session>>>,
172}
173
174pub(crate) fn workspace(
177 path: &Path,
178 feature: Option<&str>,
179 state: &Path,
180 options: &RunOptions,
181) -> CommandResult {
182 let Some(session) = options
183 .session
184 .as_ref()
185 .and_then(|s| s.lock().ok().map(|s| s.clone()))
186 else {
187 if let Some(e) = crate::in_place::session_elsewhere(path, feature, state) {
188 return e.result("run");
189 }
190 return worktree_with(path, feature, state);
191 };
192 CommandResult::new("worktree", CommandStatus::Ok, "in place").with_data(CommandData::Worktree(
193 speccoder_core::WorktreeReport {
194 feature: session.feature.clone(),
195 path: Some(session.checkout.display().to_string()),
196 project_dir: Some(session.project.display().to_string()),
197 branch: Some(session.branch.clone()),
198 base: Some(session.tip.clone()),
199 created: false,
200 refused_paths: Vec::new(),
201 },
202 ))
203}
204
205pub(crate) fn in_session(
209 command: &str,
210 path: &Path,
211 feature: Option<&str>,
212 state: &Path,
213 options: &RunOptions,
214 f: impl FnOnce(&RunOptions) -> CommandResult,
215) -> CommandResult {
216 if !options.in_place || options.session.is_some() {
217 return f(options);
218 }
219 if options.parallel > 1 {
220 return crate::in_place::InPlaceError {
221 code: "IN-PLACE-PARALLEL",
222 message: "an in-place run has one checkout; it cannot run tasks in parallel".to_owned(),
223 paths: Vec::new(),
224 fix: vec!["speccoder run --in-place (without --parallel)".to_owned()],
225 }
226 .result(command);
227 }
228 let opened = match crate::format::open(path, feature) {
229 Ok(o) => o,
230 Err(r) => return CommandResult::new(command, r.status, &r.message),
231 };
232 let Some(f_name) = opened
233 .snapshot
234 .feature
235 .as_ref()
236 .map(|f| f.feature.name.clone())
237 else {
238 return CommandResult::new(
239 command,
240 CommandStatus::SelectionNeeded,
241 "no feature selected; pass --feature",
242 );
243 };
244 let root = std::path::PathBuf::from(&opened.snapshot.root);
245 let plane_dir = worktree::project_state_dir(state, &root);
246 let policy = crate::tools::policy::Policy::new(&[], &[]).rules(opened.adapter.rules());
247 let mut session = match crate::in_place::Session::begin(&root, &plane_dir, &f_name, &policy) {
248 Ok(s) => s,
249 Err(e) => return e.result(command),
250 };
251 let format = crate::format::at(&root);
253 if let Some(tasks) = format
254 .inspect(&root, Some(&f_name))
255 .and_then(|snap| snap.feature.and_then(|f| f.tasks))
256 {
257 let file = session.project.join(&tasks.path);
258 session.reconcile(&plane_dir, &file, &tasks, |file, task, hash| {
259 format.tick(file, task, hash)
260 });
261 }
262 let original = session.original.describe();
263 if let Some(p) = &options.progress {
264 p.event(&progress::Event::Notice {
265 text: format!(
266 "in place: on {} in {}; returns to {original} at the end — do not use this checkout until then",
267 session.branch,
268 session.checkout.display()
269 ),
270 });
271 for note in &session.notes {
272 p.event(&progress::Event::Notice { text: note.clone() });
273 }
274 }
275 let shared = Arc::new(std::sync::Mutex::new(session));
276 let inner = RunOptions {
277 session: Some(Arc::clone(&shared)),
278 ..options.clone()
279 };
280 let res = f(&inner);
281 let session = shared.lock().map(|s| s.clone());
282 if let Some(task) = waiting_task(&res) {
285 let notes: String = session
286 .as_ref()
287 .map(|s| s.notes.iter().map(|n| format!("\n{n}")).collect())
288 .unwrap_or_default();
289 let message = format!(
290 "{}{notes}\nthe checkout stays on {} while {task} waits: decide it (`speccoder task approve {task}` or `reject`), then `speccoder run --in-place` continues it — or `speccoder run --in-place --return` gives the checkout back",
291 res.message(),
292 session
293 .as_ref()
294 .map(|s| s.branch.as_str())
295 .unwrap_or("the speccoder branch"),
296 );
297 let status = res.status();
298 return res.reworded(status, &message);
299 }
300 let (back, notes) = match session {
301 Ok(s) => (s.finish(&plane_dir), s.notes),
302 Err(_) => (Ok(()), Vec::new()),
303 };
304 let told: String = notes.iter().map(|n| format!("\n{n}")).collect();
305 match back {
306 Ok(()) => {
307 let message = format!("{} — back on {original}{told}", res.message());
308 let status = res.status();
309 res.reworded(status, &message)
310 }
311 Err(e) => {
312 let message = format!("{}{told}\n{}", res.message(), e.line());
313 res.reworded(CommandStatus::NeedsHuman, &message)
314 .with_code(e.code)
315 .with_fix(e.fix.clone())
316 }
317 }
318}
319
320fn waiting_task(res: &CommandResult) -> Option<String> {
322 let waits = |r: &speccoder_core::RunReport| {
323 r.pending
324 .as_ref()
325 .is_some_and(|p| p["waiting"] == true)
326 .then(|| r.task.clone())
327 };
328 match res.data() {
329 Some(CommandData::Run(r)) => waits(r),
330 Some(CommandData::Feature(f)) => f.attempts.last().and_then(waits),
331 _ => None,
332 }
333}
334
335pub(crate) fn checklist_gate(
339 project: &Path,
340 feature: Option<&str>,
341 ignore: bool,
342) -> Result<bool, Box<CommandResult>> {
343 let format = crate::format::at(project);
344 let Some(snap) = format.inspect(project, feature) else {
345 return Ok(false);
346 };
347 let Some(f) = snap.feature.as_ref() else {
348 return Ok(false);
349 };
350 let lists = format.checklists(Path::new(&snap.root), &f.feature.dir);
351 let open: Vec<String> = lists
352 .iter()
353 .filter(|c| c.open > 0)
354 .map(|c| format!("{} ({} of {} open)", c.file, c.open, c.total))
355 .collect();
356 if open.is_empty() {
357 return Ok(false);
358 }
359 if ignore {
360 return Ok(true);
361 }
362 Err(Box::new(
363 CommandResult::new(
364 "run",
365 CommandStatus::NeedsHuman,
366 &(format.messages().checklists)(&open.join(", ")),
367 )
368 .with_data(CommandData::Checklists(lists)),
369 ))
370}
371
372pub(crate) fn stage(
375 live: &dyn crate::progress::Progress,
376 task: &str,
377 stage: speccoder_core::stage::Stage,
378 state: speccoder_core::stage::StageState,
379 detail: Option<String>,
380 since: Option<std::time::Instant>,
381) {
382 stage_with_tail(live, task, stage, state, detail, since, Vec::new());
383}
384
385pub(crate) fn stage_with_tail(
387 live: &dyn crate::progress::Progress,
388 task: &str,
389 stage: speccoder_core::stage::Stage,
390 state: speccoder_core::stage::StageState,
391 detail: Option<String>,
392 since: Option<std::time::Instant>,
393 tail: Vec<String>,
394) {
395 live.event(&crate::progress::Event::Stage {
396 task: task.to_owned(),
397 stage,
398 state,
399 detail,
400 elapsed_ms: since.map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX)),
401 tail,
402 });
403}
404
405pub(crate) fn failing_tail(report: &speccoder_core::verify::VerifyReport) -> Vec<String> {
407 report
408 .checks
409 .iter()
410 .find(|c| c.status.is_failure())
411 .map(|c| crate::progress::tail(&format!("{}\n{}", c.stdout_tail, c.stderr_tail)))
412 .unwrap_or_default()
413}