1use std::path::Path;
5
6use speccoder_core::snapshot::{ProjectSnapshot, Severity};
7use speccoder_core::{CommandData, CommandResult, CommandStatus};
8
9#[cfg(feature = "engine")]
10use crate::pipeline::*;
11#[cfg(feature = "engine")]
12use crate::{context, git, substrate};
13
14#[must_use]
17pub fn inspect(path: &Path, feature: Option<&str>) -> CommandResult {
18 match crate::format::open(path, feature) {
19 Ok(opened) => {
20 let snapshot = opened.snapshot;
21 let status = status_of(&snapshot);
22 let message = summary(&snapshot, opened.adapter.messages());
23 CommandResult::new("inspect", status, &message)
24 .with_data(CommandData::Inspect(Box::new(snapshot)))
25 }
26 Err(r) => CommandResult::new("inspect", r.status, &r.message),
27 }
28}
29
30pub(crate) fn status_of(s: &ProjectSnapshot) -> CommandStatus {
32 let selection_failed = s.feature.is_none()
33 && s.diagnostics
34 .iter()
35 .any(|d| d.severity == Severity::Error && d.code.starts_with("select."));
36 if selection_failed {
37 CommandStatus::SelectionNeeded
38 } else if s.has_errors() {
39 CommandStatus::DiagnosticsError
40 } else {
41 CommandStatus::Ok
42 }
43}
44
45pub(crate) fn summary(s: &ProjectSnapshot, m: &crate::format::Messages) -> String {
46 let features = match s.features.len() {
47 1 => "1 feature".to_owned(),
48 n => format!("{n} features"),
49 };
50 let selected = match &s.feature {
51 Some(f) => match &f.tasks {
52 Some(t) => format!(
53 "{} selected: {} tasks, {} done, {} open",
54 f.feature.name, t.totals.total, t.totals.done, t.totals.open
55 ),
56 None => format!("{} selected: no {}", f.feature.name, m.tasks_file),
57 },
58 None => "none selected".to_owned(),
59 };
60 let errors = s
61 .diagnostics
62 .iter()
63 .filter(|d| d.severity == Severity::Error)
64 .count();
65 let warnings = s
66 .diagnostics
67 .iter()
68 .filter(|d| d.severity == Severity::Warning)
69 .count();
70 format!("{features}, {selected}; {errors} errors, {warnings} warnings")
71}
72
73#[cfg(feature = "engine")]
74#[must_use]
77pub fn worktree(path: &Path, feature: Option<&str>) -> CommandResult {
78 match crate::worktree::state_dir() {
79 Some(state) => worktree_with(path, feature, &state),
80 None => CommandResult::new(
81 "worktree",
82 CommandStatus::GitPrecondition,
83 "no state directory (set SPECCODER_STATE_DIR or HOME)",
84 ),
85 }
86}
87
88#[cfg(feature = "engine")]
89pub fn worktree_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
91 let inspected = inspect(path, feature);
92 let snapshot = match inspected.data() {
93 Some(CommandData::Inspect(s)) => s.clone(),
94 _ => return CommandResult::carried("worktree", &inspected),
95 };
96 let Some(f) = &snapshot.feature else {
97 return CommandResult::new(
98 "worktree",
99 CommandStatus::SelectionNeeded,
100 inspected.message(),
101 );
102 };
103 worktree_of(&snapshot, &f.feature.name, &f.feature.dir, state)
104}
105
106#[cfg(feature = "engine")]
107pub fn worktree_of(
111 snapshot: &speccoder_core::snapshot::ProjectSnapshot,
112 name: &str,
113 unit_dir: &str,
114 state: &Path,
115) -> CommandResult {
116 use speccoder_core::{RefusedPath, WorktreeReport};
117 let format = crate::format::of(snapshot);
118 let name = name.to_owned();
119 let mut report = WorktreeReport {
120 feature: name.clone(),
121 path: None,
122 project_dir: None,
123 branch: None,
124 base: None,
125 created: false,
126 refused_paths: Vec::new(),
127 };
128 let refuse = |report: WorktreeReport, code: &str, message: &str, hint: &str| {
129 let mut fix = vec![format!("→ {hint}")];
130 if let Some(e) = speccoder_core::codes::lookup(code) {
131 fix.extend(speccoder_core::codes::fixes(e, "run", None, Some(&name)));
132 }
133 CommandResult::new("worktree", CommandStatus::GitPrecondition, message)
134 .with_code(code)
135 .with_fix(fix)
136 .with_data(CommandData::Worktree(report))
137 };
138
139 let project = Path::new(&snapshot.root);
140 let repo_root = match git::Git::at(project).toplevel() {
141 Ok(Some(root)) => std::fs::canonicalize(&root).unwrap_or(root),
142 Ok(None) => {
143 return refuse(
144 report,
145 "NO-REPOSITORY",
146 format.messages().not_in_git,
147 "run `git init` and commit the project",
148 );
149 }
150 Err(e) => return refuse(report, "GIT", &e.0, "install git"),
151 };
152 let rel = project.strip_prefix(&repo_root).unwrap_or(Path::new(""));
154 let join = |p: &str| {
155 let joined = rel.join(p);
156 let s = joined.to_string_lossy().replace('\\', "/");
157 if s.is_empty() { ".".to_owned() } else { s }
158 };
159 let precondition = format.commit_precondition(project, &join, unit_dir);
160
161 match crate::worktree::ensure_worktree(&repo_root, state, &name, precondition) {
162 Ok(wt) => {
163 report.project_dir = Some(wt.path.join(rel).display().to_string());
164 report.path = Some(wt.path.display().to_string());
165 report.branch = Some(wt.branch);
166 report.base = Some(wt.base);
167 report.created = wt.created;
168 let message = if report.created {
169 "worktree created"
170 } else {
171 "worktree reused"
172 };
173 CommandResult::new("worktree", CommandStatus::Ok, message)
174 .with_data(CommandData::Worktree(report))
175 }
176 Err(r) => {
177 report.refused_paths = r
178 .paths
179 .into_iter()
180 .map(|(path, reason)| RefusedPath { path, reason })
181 .collect();
182 refuse(report, r.code, &r.message, &r.hint)
183 }
184 }
185}
186
187#[cfg(feature = "engine")]
188#[must_use]
191pub fn propose(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
192 let Some(state) = crate::worktree::state_dir() else {
193 return CommandResult::new(
194 "propose",
195 CommandStatus::ConfigError,
196 "no state directory (set SPECCODER_STATE_DIR or HOME)",
197 )
198 .with_code("NO-STATE-DIR");
199 };
200 match setup(path, feature, &state, "propose") {
203 Err(r) => return *r,
204 Ok(s) => {
205 if let Err(e) = s.format.bundle(&s.snapshot, task) {
206 let msg = match e {
207 context::BundleError::NotFound(id) => {
208 format!("no task {id} in the selected feature")
209 }
210 context::BundleError::NoTasks => {
211 format!(
212 "the selected feature has no {}",
213 s.format.messages().tasks_file
214 )
215 }
216 context::BundleError::NoFeature => {
217 "no feature selected; pass --feature".to_owned()
218 }
219 };
220 return CommandResult::new("propose", CommandStatus::NotFound, &msg);
221 }
222 }
223 }
224 let model = match setup(path, feature, &state, "propose") {
225 Ok(s) => s.model,
226 Err(r) => return *r,
227 };
228 let (driver, key) = match substrate::model_config::open_driver(&model) {
229 Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
230 Err(e) => return substrate::model_config::refused("propose", &e),
231 };
232 let provider = match driver {
233 Ok(p) => p,
234 Err(e) => {
235 return CommandResult::new(
236 "propose",
237 CommandStatus::ConfigError,
238 &redact(&e, Some(&key)),
239 );
240 }
241 };
242 propose_with(path, feature, task, &state, provider, Some(&key))
243}
244
245#[cfg(feature = "engine")]
246pub fn propose_with(
249 path: &Path,
250 feature: Option<&str>,
251 task: &str,
252 state: &Path,
253 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
254 secret: Option<&str>,
255) -> CommandResult {
256 use speccoder_core::ProposeReport;
257
258 let s = match setup(path, feature, state, "propose") {
259 Ok(s) => s,
260 Err(r) => return *r,
261 };
262 let bundle = match s.format.bundle(&s.snapshot, task) {
263 Ok(b) => b,
264 Err(context::BundleError::NotFound(id)) => {
265 return CommandResult::new(
266 "propose",
267 CommandStatus::NotFound,
268 &format!("no task {id} in the selected feature"),
269 );
270 }
271 Err(context::BundleError::NoTasks) => {
272 return CommandResult::new(
273 "propose",
274 CommandStatus::NotFound,
275 &format!(
276 "the selected feature has no {}",
277 s.format.messages().tasks_file
278 ),
279 );
280 }
281 Err(context::BundleError::NoFeature) => {
282 return CommandResult::new(
283 "propose",
284 CommandStatus::SelectionNeeded,
285 "no feature selected; pass --feature",
286 );
287 }
288 };
289 let feature_name = bundle.feature.clone();
290 let input = match serde_json::to_value(&bundle) {
291 Ok(v) => v,
292 Err(e) => return CommandResult::new("propose", CommandStatus::Error, &e.to_string()),
293 };
294 let fail =
295 |m: String| CommandResult::new("propose", CommandStatus::RunFailed, &redact(&m, secret));
296
297 let rt = match tokio_runtime() {
298 Ok(rt) => rt,
299 Err(e) => return fail(e),
300 };
301 let outcome = rt.block_on(async {
302 let plane = substrate::Plane::open_with(
303 &s.plane_dir,
304 &s.model,
305 provider,
306 None,
307 substrate::Hooks {
308 format: Some(s.format),
309 ..substrate::Hooks::default()
310 },
311 )?;
312 plane
314 .propose(
315 &feature_name,
316 task,
317 agentplane::core::Tainted::trusted(input),
318 )
319 .await
320 });
321 let outcome = match outcome {
322 Ok(o) => o,
323 Err(e) => return fail(e.0),
324 };
325 let output = outcome.output.as_ref().map(|o| o.peek().clone());
326 let report = ProposeReport {
327 run_id: outcome.run_id.to_string(),
328 feature: feature_name,
329 task: task.to_owned(),
330 run_status: outcome.status.as_str().to_owned(),
331 proposal: output
332 .as_ref()
333 .and_then(|o| o.get("proposal").cloned())
334 .filter(|p| !p.is_null()),
335 usage: output.as_ref().and_then(|o| o.get("usage").cloned()),
336 };
337 let (status, message) = match (&outcome.status, &report.proposal) {
338 (agentplane::prelude::RunStatus::Succeeded, Some(_)) => (
339 CommandStatus::Ok,
340 format!("proposal recorded in run {}", report.run_id),
341 ),
342 (agentplane::prelude::RunStatus::Succeeded, None) => (
343 CommandStatus::RunFailed,
344 format!(
345 "run {} succeeded but the model's answer is not a proposal",
346 report.run_id
347 ),
348 ),
349 (other, _) => (
350 CommandStatus::RunFailed,
351 redact(
352 &format!("run {} ended {}: {other:?}", report.run_id, other.as_str()),
353 secret,
354 ),
355 ),
356 };
357 CommandResult::new("propose", status, &message)
358 .with_data(CommandData::Propose(Box::new(report)))
359}
360
361#[cfg(feature = "engine")]
362#[must_use]
364pub fn replay(path: &Path, run: &str) -> CommandResult {
365 let Some(state) = crate::worktree::state_dir() else {
366 return CommandResult::new(
367 "replay",
368 CommandStatus::ConfigError,
369 "no state directory (set SPECCODER_STATE_DIR or HOME)",
370 )
371 .with_code("NO-STATE-DIR");
372 };
373 replay_with(path, run, &state)
374}
375
376#[cfg(feature = "engine")]
377pub fn replay_with(path: &Path, run: &str, state: &Path) -> CommandResult {
379 use speccoder_core::ReplayReport;
380
381 let s = match setup(path, None, state, "replay") {
382 Ok(s) => s,
383 Err(r) => return *r,
384 };
385 let rt = match tokio_runtime() {
386 Ok(rt) => rt,
387 Err(e) => return CommandResult::new("replay", CommandStatus::RunFailed, &e),
388 };
389 let report = |result: &str, detail: String| ReplayReport {
390 run_id: run.to_owned(),
391 result: result.to_owned(),
392 detail,
393 };
394 let result = rt.block_on(substrate::replay_strict(
395 &s.plane_dir,
396 &s.model,
397 run,
398 s.format,
399 ));
400 match result {
401 Ok(Some(v)) => {
402 let (result, status, message) = replay_verdict(&v.finding);
403 CommandResult::new("replay", status, &message)
404 .with_data(CommandData::Replay(report(result, message.clone())))
405 }
406 Ok(None) => CommandResult::new(
407 "replay",
408 CommandStatus::NotFound,
409 &format!("no run {run} in this project's journal"),
410 )
411 .with_data(CommandData::Replay(report("not_found", String::new()))),
412 Err(e) => CommandResult::new("replay", CommandStatus::RunFailed, &e.0)
415 .with_data(CommandData::Replay(report("error", e.0.clone()))),
416 }
417}
418
419#[cfg(feature = "engine")]
420#[must_use]
423pub fn eval(
424 dir: &Path,
425 only: Option<&str>,
426 keep: Option<&Path>,
427 repeat: usize,
428 executor: Option<&str>,
429) -> CommandResult {
430 let suite = match crate::eval::suite_config(dir) {
431 Ok(s) => s,
432 Err(e) => return substrate::model_config::refused("eval", &e),
433 };
434 let model = match substrate::model_config::parse(&suite) {
435 Ok(Some(m)) => m,
436 Ok(None) => {
437 return CommandResult::new(
438 "eval",
439 CommandStatus::ConfigError,
440 "eval.toml has no [model]",
441 );
442 }
443 Err(e) => {
444 return CommandResult::new(
445 "eval",
446 CommandStatus::ConfigError,
447 &format!("eval.toml: {e}"),
448 );
449 }
450 };
451 let (driver, key) = match substrate::model_config::open_driver(&model) {
452 Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
453 Err(e) => return CommandResult::new("eval", CommandStatus::ConfigError, &e),
454 };
455 match driver {
456 Ok(p) => crate::eval::run_suite_as(dir, only, &p, Some(&key), keep, repeat, executor),
457 Err(e) => CommandResult::new("eval", CommandStatus::ConfigError, &redact(&e, Some(&key))),
458 }
459}
460
461#[cfg(feature = "engine")]
462pub(crate) fn replay_verdict(
466 finding: &agentplane::runtime::Finding,
467) -> (&'static str, CommandStatus, String) {
468 use agentplane::runtime::Finding;
469 match finding {
470 Finding::Verified { outcome } => (
471 "reproduced",
472 CommandStatus::Ok,
473 format!(
474 "reproduced: every recorded effect again, and the recorded ending ({outcome}); no model was called"
475 ),
476 ),
477 Finding::Diverged(d) => (
478 "diverged",
479 CommandStatus::ReplayDiverged,
480 format!("diverged: {d:?}"),
481 ),
482 Finding::OutcomeDiffers { recorded, replayed } => (
483 "diverged",
484 CommandStatus::ReplayDiverged,
485 format!(
486 "diverged: every effect matched, but the run ended {replayed} instead of {recorded}"
487 ),
488 ),
489 Finding::CannotReplay(why) => (
490 "not_replayable",
491 CommandStatus::NotReplayable,
492 format!("this build cannot re-derive the run: {why:?}"),
493 ),
494 other => (
495 "not_replayable",
496 CommandStatus::NotReplayable,
497 format!("{other:?}"),
498 ),
499 }
500}
501
502#[cfg(all(test, feature = "engine"))]
503mod replay_tests {
504 use super::*;
505 use agentplane::runtime::{CannotReplay, Finding};
506
507 #[test]
508 fn findings_become_reproduced_diverged_or_not_replayable() {
509 let (r, s, _) = replay_verdict(&Finding::Verified {
510 outcome: "failed".to_owned(),
511 });
512 assert_eq!((r, s), ("reproduced", CommandStatus::Ok));
513 let (r, s, m) = replay_verdict(&Finding::OutcomeDiffers {
514 recorded: "succeeded".to_owned(),
515 replayed: "failed".to_owned(),
516 });
517 assert_eq!((r, s), ("diverged", CommandStatus::ReplayDiverged));
518 assert!(m.contains("instead of succeeded"));
519 let (r, s, _) = replay_verdict(&Finding::CannotReplay(CannotReplay::NotConcluded));
520 assert_eq!((r, s), ("not_replayable", CommandStatus::NotReplayable));
521 assert_eq!(s.exit_code().code(), 25);
522 }
523}