1use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use speccoder_core::{
16 CommandData, CommandResult, CommandStatus, SampleRecording, SampleReport, SampleRun,
17};
18
19const COMMAND: &str = "sample";
20
21#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
23pub struct Manifest {
24 pub case: String,
26 pub run_ids: Vec<String>,
28 pub provider: String,
30 pub model: String,
32 pub agentplane: String,
34 pub speccoder: String,
36 pub recorded_at: String,
38 pub expect: String,
40}
41
42#[derive(Debug, Clone)]
44pub struct Asset {
45 pub bundle: Vec<u8>,
47 pub journal: Vec<u8>,
49 pub manifest: String,
51}
52
53#[must_use]
55pub fn bundled() -> Option<Asset> {
56 bundled_asset()
57}
58
59#[cfg(speccoder_sample)]
60fn bundled_asset() -> Option<Asset> {
61 Some(Asset {
62 bundle: include_bytes!(concat!(
63 env!("CARGO_MANIFEST_DIR"),
64 "/assets/sample/sample.bundle"
65 ))
66 .to_vec(),
67 journal: gunzip(include_bytes!(concat!(
69 env!("CARGO_MANIFEST_DIR"),
70 "/assets/sample/journal.redb.gz"
71 )))?,
72 manifest: include_str!(concat!(
73 env!("CARGO_MANIFEST_DIR"),
74 "/assets/sample/manifest.toml"
75 ))
76 .to_owned(),
77 })
78}
79
80#[cfg_attr(not(speccoder_sample), allow(dead_code))]
82fn gunzip(bytes: &[u8]) -> Option<Vec<u8>> {
83 use std::io::Read as _;
84 let mut out = Vec::new();
85 flate2::read::GzDecoder::new(bytes)
86 .read_to_end(&mut out)
87 .ok()?;
88 Some(out)
89}
90
91#[cfg(not(speccoder_sample))]
92const fn bundled_asset() -> Option<Asset> {
93 None
94}
95
96#[must_use]
98pub fn available() -> bool {
99 cfg!(speccoder_sample)
100}
101
102#[must_use]
104pub fn home(state: &Path) -> PathBuf {
105 state.join("sample").join(env!("CARGO_PKG_VERSION"))
106}
107
108#[must_use]
110pub fn copy(state: &Path) -> Option<PathBuf> {
111 let text = std::fs::read_to_string(home(state).join("sample.json")).ok()?;
112 let v: serde_json::Value = serde_json::from_str(&text).ok()?;
113 let p = PathBuf::from(v["path"].as_str()?);
114 p.is_dir().then_some(p)
115}
116
117#[must_use]
119pub fn missing() -> CommandResult {
120 CommandResult::new(
121 COMMAND,
122 CommandStatus::NotFound,
123 "this build has no sample: its recorded run is bundled into release builds once it is recorded",
124 )
125 .with_code("SAMPLE-MISSING")
126}
127
128#[must_use]
130pub fn open(state: &Path, fresh: bool) -> CommandResult {
131 match bundled() {
132 Some(a) => open_from(&a, state, fresh),
133 None => missing(),
134 }
135}
136
137fn failed(m: &str) -> CommandResult {
138 CommandResult::new(COMMAND, CommandStatus::Error, m)
139}
140
141#[must_use]
145pub fn open_from(asset: &Asset, state: &Path, fresh: bool) -> CommandResult {
146 let manifest: Manifest = match toml::from_str(&asset.manifest) {
147 Ok(m) => m,
148 Err(e) => return failed(&format!("the sample's manifest is unreadable: {e}")),
149 };
150 let base = home(state);
151 let dir = base.join(&manifest.case);
152 let made = fresh || !dir.join(".git").exists();
153 if made {
154 if dir.exists()
155 && let Err(e) = std::fs::remove_dir_all(&dir)
156 {
157 return failed(&format!("cannot replace the sample's copy: {e}"));
158 }
159 if let Err(e) = std::fs::create_dir_all(&base) {
160 return failed(&format!("{}: {e}", base.display()));
161 }
162 let bundle = base.join("sample.bundle");
163 if let Err(e) = std::fs::write(&bundle, &asset.bundle) {
164 return failed(&format!("{}: {e}", bundle.display()));
165 }
166 let cloned = crate::git::Git::at(&base).run(&[
167 "clone",
168 "--quiet",
169 "--",
170 &bundle.display().to_string(),
171 &dir.display().to_string(),
172 ]);
173 let cloned = cloned.and_then(|_| {
176 crate::git::Git::at(&dir).run(&[
177 "fetch",
178 "--quiet",
179 "--update-head-ok",
180 &bundle.display().to_string(),
181 "+refs/*:refs/*",
182 ])
183 });
184 let _ = std::fs::remove_file(&bundle);
185 if let Err(e) = cloned {
186 return failed(&format!(
187 "the sample's history could not be unpacked: {}",
188 e.0
189 ));
190 }
191 let plane = crate::worktree::project_state_dir(state, &dir);
192 if let Err(e) = std::fs::create_dir_all(&plane)
193 .and_then(|()| std::fs::write(plane.join(crate::substrate::JOURNAL), &asset.journal))
194 {
195 return failed(&format!("the sample's journal could not be placed: {e}"));
196 }
197 }
198 let runs: Vec<SampleRun> = manifest
199 .run_ids
200 .iter()
201 .map(|id| {
202 let r = crate::replay_with(&dir, id, state);
203 let result = match r.data() {
204 Some(CommandData::Replay(rep)) => rep.result.clone(),
205 _ => "not_replayable".to_owned(),
206 };
207 SampleRun {
208 id: id.clone(),
209 finding: (result != "reproduced").then(|| r.message().to_owned()),
210 result,
211 }
212 })
213 .collect();
214 let report = SampleReport {
215 path: dir.display().to_string(),
216 fresh: made,
217 recorded: SampleRecording {
218 model: manifest.model.clone(),
219 recorded_at: manifest.recorded_at.clone(),
220 agentplane: manifest.agentplane.clone(),
221 },
222 runs,
223 };
224 if let Err(e) = std::fs::write(
225 base.join("sample.json"),
226 serde_json::json!({
227 "version": env!("CARGO_PKG_VERSION"),
228 "path": report.path,
229 "recorded": report.recorded,
230 "runs": report.runs,
231 })
232 .to_string(),
233 ) {
234 return failed(&format!(
235 "the sample's record (sample.json) could not be written, so the app cannot show it: {e}"
236 ));
237 }
238 let diverged = report.runs.iter().find(|r| r.result != "reproduced");
239 let result = match diverged {
240 Some(r) => CommandResult::new(
241 COMMAND,
242 CommandStatus::ReplayDiverged,
243 &format!(
244 "this build cannot reproduce the sample's recorded run — please report it: {}",
245 r.finding.clone().unwrap_or_default()
246 ),
247 )
248 .with_code("SAMPLE-DIVERGED"),
249 None => CommandResult::new(
250 COMMAND,
251 CommandStatus::Ok,
252 &format!(
253 "sample ready at {} — {} recorded run{} reproduced (no model called)",
254 crate::welcome::tilde(&dir),
255 report.runs.len(),
256 if report.runs.len() == 1 { "" } else { "s" }
257 ),
258 )
259 .with_next(vec![
260 speccoder_core::next::NextStep {
261 command: format!("speccoder ui -C {}", crate::welcome::tilde(&dir)),
262 why: "watch the recorded run play back".to_owned(),
263 },
264 speccoder_core::next::NextStep {
265 command: "speccoder new \"…\"".to_owned(),
266 why: "start your own".to_owned(),
267 },
268 ]),
269 };
270 result.with_data(CommandData::Sample(Box::new(report)))
271}
272
273#[must_use]
276pub fn missing_tool(copy: &Path) -> Option<String> {
277 let text = std::fs::read_to_string(copy.join(crate::verify::config::FILE)).ok()?;
278 let checks = crate::verify::config::parse(&text).ok()?;
279 let env: Vec<(String, String)> = std::env::vars().collect();
280 checks
281 .iter()
282 .filter_map(|c| c.argv.first())
283 .find(|p| crate::sandbox::resolve_executable(p, copy, &env).is_none())
284 .map(|p| match p.as_str() {
285 "cargo" => "cargo is not installed — install it: https://rustup.rs".to_owned(),
286 other => format!("{other} is not installed"),
287 })
288}
289
290#[must_use]
293pub fn run_checks(state: &Path, root: &Path) -> CommandResult {
294 let copy = copy(state).and_then(|c| std::fs::canonicalize(c).ok());
295 if copy.is_none() || copy != std::fs::canonicalize(root).ok() {
296 return CommandResult::new(
297 COMMAND,
298 CommandStatus::UsageError,
299 "these are the sample's checks: run them in its copy (speccoder sample)",
300 )
301 .with_code("USAGE");
302 }
303 if let Some(why) = missing_tool(root) {
304 return CommandResult::new(COMMAND, CommandStatus::ConfigError, &why).with_code("CONFIG");
305 }
306 let runner = match crate::sandbox::runner_at(root) {
307 Ok((r, _)) => r,
308 Err(e) => return CommandResult::new(COMMAND, CommandStatus::ConfigError, &e),
309 };
310 match crate::verify::run_checks(root, Some(crate::verify::config::Tier::Fast), &runner) {
311 Ok(report) => {
312 let (status, message) = if report.passed {
313 (CommandStatus::Ok, "the sample's checks pass here, offline")
314 } else {
315 (
316 CommandStatus::VerificationFailed,
317 "a check of the sample fails here",
318 )
319 };
320 CommandResult::new(COMMAND, status, message)
321 .with_data(CommandData::Verify(Box::new(report)))
322 }
323 Err(e) => CommandResult::new(COMMAND, CommandStatus::Error, &e),
324 }
325}
326
327#[cfg(test)]
328mod gzip_tests {
329 #[test]
330 fn a_compressed_journal_unpacks_and_anything_else_is_refused() {
331 use std::io::Write as _;
332 let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
333 gz.write_all(b"journal bytes").unwrap();
334 let packed = gz.finish().unwrap();
335 assert_eq!(
336 super::gunzip(&packed).as_deref(),
337 Some(&b"journal bytes"[..])
338 );
339 assert_eq!(super::gunzip(b"not gzip"), None);
340 }
341}