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