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