1use std::path::Path;
14
15use crate::git::{Git, GitError};
16use crate::worktree::{self, Checkpoint};
17
18pub const PREFIX: &str = "refs/speccoder/attempts";
20
21const FS: char = '\u{1f}';
23const RS: char = '\u{1e}';
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Saved {
28 pub task: String,
30 pub outcome: String,
32 pub reason: String,
34 pub run_id: Option<String>,
36 pub pending: Option<serde_json::Value>,
38 pub usage: Option<serde_json::Value>,
40 pub story_open: Vec<String>,
42 pub own_test: Option<String>,
45 pub reference: String,
47 pub commit: String,
49}
50
51pub(crate) const IDENTITY: [&str; 6] = [
52 "-c",
53 "user.name=speccoder",
54 "-c",
55 "user.email=speccoder@users.noreply.invalid",
56 "-c",
57 "commit.gpgsign=false",
58];
59
60#[derive(Debug, Clone, Copy)]
62pub struct Record<'a> {
63 pub run: Option<&'a str>,
65 pub outcome: &'a str,
67 pub reason: &'a str,
69 pub pending: Option<&'a serde_json::Value>,
71 pub usage: Option<&'a serde_json::Value>,
73 pub story_open: &'a [String],
75 pub own_test: Option<&'a str>,
77 pub extra: &'a str,
79}
80
81#[must_use]
83pub fn usage_trailer(usage: Option<&serde_json::Value>) -> String {
84 let Some(u) = usage.filter(|u| u.is_object()) else {
85 return String::new();
86 };
87 let n = |k: &str| u[k].as_u64().unwrap_or(0);
88 let usd = u["cost_usd"]
90 .as_f64()
91 .map(|c| format!(" usd={c}"))
92 .unwrap_or_default();
93 format!(
94 "Usage: input={} output={} cache_read={} cache_write={}{usd}\n",
95 n("input_tokens"),
96 n("output_tokens"),
97 n("cache_read_tokens"),
98 n("cache_write_tokens")
99 )
100}
101
102#[must_use]
104pub fn parse_usage(value: &str) -> Option<serde_json::Value> {
105 let mut out = serde_json::Map::new();
106 for part in value.split_whitespace() {
107 let (k, v) = part.split_once('=')?;
108 let key = match k {
109 "input" => "input_tokens",
110 "output" => "output_tokens",
111 "cache_read" => "cache_read_tokens",
112 "cache_write" => "cache_write_tokens",
113 "usd" => {
114 out.insert(
115 "cost_usd".to_owned(),
116 serde_json::json!(v.parse::<f64>().ok()?),
117 );
118 continue;
119 }
120 _ => continue,
121 };
122 out.insert(key.to_owned(), serde_json::json!(v.parse::<u64>().ok()?));
123 }
124 (!out.is_empty()).then_some(serde_json::Value::Object(out))
125}
126
127pub fn save(
129 dir: &Path,
130 tmp: &Path,
131 feature: &str,
132 task: &str,
133 record: &Record<'_>,
134) -> Result<Saved, GitError> {
135 let Record {
136 run,
137 outcome,
138 reason,
139 pending,
140 usage,
141 story_open,
142 own_test,
143 extra,
144 } = *record;
145 let git = Git::at(dir);
146 let tree = worktree::checkpoint(dir, tmp)?;
147 let head = git.line(&["rev-parse", "--verify", "HEAD^{commit}"])?;
148 let (tree, left_out) = without_secrets(&git, tmp, &head, tree)?;
152 let reason = if left_out.is_empty() {
153 reason.to_owned()
154 } else {
155 format!(
156 "{reason} ({} left out: it held a value copied from your checkout)",
157 left_out.join(", ")
158 )
159 };
160 let reason = reason.as_str();
161 let message = format!(
162 "speccoder attempt: {task} {outcome}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nOutcome: {outcome}\n{}{}{}{}{}{}",
163 run.map(|r| format!("Run: {r}\n")).unwrap_or_default(),
164 extra,
165 Some(
166 story_open
167 .iter()
168 .filter(|t| t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
169 .map(String::as_str)
170 .collect::<Vec<_>>()
171 .join(", ")
172 )
173 .filter(|t| !t.is_empty())
174 .map(|t| format!("Story-Open: {t}\n"))
175 .unwrap_or_default(),
176 own_test
177 .filter(|p| !p.contains('\n'))
178 .map(|p| format!("Own-Test: {p}\n"))
179 .unwrap_or_default(),
180 usage_trailer(usage),
181 pending
183 .map(|p| format!("Pending: {p}\n"))
184 .unwrap_or_default()
185 );
186 let mut args = IDENTITY.to_vec();
187 args.extend(["commit-tree", &tree.0, "-p", &head, "-m", &message]);
188 let commit = git.line(&args)?;
189 let name = run
190 .filter(|r| {
191 r.chars()
192 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
193 })
194 .map_or_else(
195 || format!("{outcome}-{}", commit.get(..12).unwrap_or(&commit)),
196 str::to_owned,
197 );
198 let reference = format!("{PREFIX}/{feature}/{task}/{name}");
199 git.run(&["update-ref", &reference, &commit])?;
200 Ok(Saved {
201 task: task.to_owned(),
202 outcome: outcome.to_owned(),
203 reason: reason.to_owned(),
204 run_id: run.map(str::to_owned),
205 pending: pending.cloned(),
206 usage: usage.cloned(),
207 story_open: story_open.to_vec(),
208 own_test: own_test.map(str::to_owned),
209 reference,
210 commit,
211 })
212}
213
214fn without_secrets(
217 git: &Git,
218 tmp: &Path,
219 head: &str,
220 tree: Checkpoint,
221) -> Result<(Checkpoint, Vec<String>), GitError> {
222 let changed = crate::git::nul_fields(&git.run(&[
223 "diff",
224 "--name-only",
225 "-z",
226 "--no-renames",
227 head,
228 &tree.0,
229 ])?);
230 let held: Vec<String> = changed
231 .into_iter()
232 .filter(|f| {
233 git.run(&["diff", "-U0", "--no-color", head, &tree.0, "--", f])
234 .is_ok_and(|p| crate::include::added_secret(&String::from_utf8_lossy(&p)).is_some())
235 })
236 .collect();
237 if held.is_empty() {
238 return Ok((tree, held));
239 }
240 let index = tmp.join(format!("attempt-index-{}", std::process::id()));
241 let _ = std::fs::create_dir_all(tmp);
242 git.run_with_index(&index, &["read-tree", &tree.0], None)?;
243 let mut rm = vec!["update-index", "--force-remove", "--"];
244 rm.extend(held.iter().map(String::as_str));
245 git.run_with_index(&index, &rm, None)?;
246 let out = git.run_with_index(&index, &["write-tree"], None);
247 let _ = std::fs::remove_file(&index);
248 let kept = String::from_utf8_lossy(&out?).trim().to_owned();
249 Ok((Checkpoint(kept), held))
250}
251
252pub fn restore_head(dir: &Path, tmp: &Path) -> Result<(), GitError> {
254 let tree = Git::at(dir).line(&["rev-parse", "HEAD^{tree}"])?;
255 worktree::restore(dir, tmp, &Checkpoint(tree)).map(|_| ())
256}
257
258pub fn list(repo: &Path, feature: &str) -> Vec<Saved> {
260 let format = format!(
261 "--format=%(refname){FS}%(objectname){FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Outcome,valueonly){FS}%(trailers:key=Run,valueonly){FS}%(trailers:key=Pending,valueonly){FS}%(trailers:key=Usage,valueonly){FS}%(trailers:key=Story-Open,valueonly){FS}%(trailers:key=Own-Test,valueonly){FS}%(contents:body){RS}"
262 );
263 let out = Git::at(repo)
264 .run(&[
265 "for-each-ref",
266 "--sort=-committerdate",
267 &format,
268 &format!("{PREFIX}/{feature}/"),
269 ])
270 .map(|b| String::from_utf8_lossy(&b).into_owned())
271 .unwrap_or_default();
272 out.split(RS)
273 .filter_map(|record| {
274 let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
275 let [
276 reference,
277 commit,
278 task,
279 outcome,
280 run,
281 pending,
282 usage,
283 story_open,
284 own_test,
285 body,
286 ] = f[..]
287 else {
288 return None;
289 };
290 let task = task.trim().strip_prefix(&format!("{feature}/"))?.to_owned();
291 let reason = body
293 .split("\n\nSpec-Task:")
294 .next()
295 .unwrap_or_default()
296 .trim()
297 .to_owned();
298 let run = run.trim();
299 Some(Saved {
300 task,
301 outcome: outcome.trim().to_owned(),
302 reason,
303 run_id: (!run.is_empty()).then(|| run.to_owned()),
304 pending: serde_json::from_str(pending.trim()).ok(),
305 usage: parse_usage(usage.trim()),
306 story_open: story_open
307 .split(',')
308 .map(str::trim)
309 .filter(|t| !t.is_empty())
310 .map(str::to_owned)
311 .collect(),
312 own_test: Some(own_test.trim())
313 .filter(|p| !p.is_empty())
314 .map(str::to_owned),
315 reference: reference.to_owned(),
316 commit: commit.to_owned(),
317 })
318 })
319 .collect()
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn a_file_holding_a_copied_value_is_left_out_of_a_saved_attempt() {
328 let dir = tempfile::tempdir().unwrap();
329 let r = dir.path();
330 let git = |args: &[&str]| {
331 assert!(
332 std::process::Command::new("git")
333 .args([
334 "-c",
335 "user.name=t",
336 "-c",
337 "user.email=t@e",
338 "-c",
339 "commit.gpgsign=false"
340 ])
341 .args(args)
342 .current_dir(r)
343 .status()
344 .unwrap()
345 .success()
346 );
347 };
348 std::fs::write(r.join("a.txt"), "a\n").unwrap();
349 git(&["init", "-q"]);
350 git(&["add", "-A"]);
351 git(&["commit", "-qm", "init"]);
352 crate::secrets::register("SNAP_TOKEN", "attempt-snap-Vb3 value");
353 std::fs::write(r.join("a.txt"), "a\nb\n").unwrap();
354 std::fs::write(r.join("snap.txt"), "token: attempt-snap-Vb3 value\n").unwrap();
355 let tmp = tempfile::tempdir().unwrap();
356 let saved = save(
357 r,
358 tmp.path(),
359 "001-x",
360 "T001",
361 &Record {
362 run: None,
363 outcome: "failed",
364 reason: "red",
365 pending: None,
366 usage: None,
367 story_open: &[],
368 own_test: None,
369 extra: "",
370 },
371 )
372 .unwrap();
373 let g = Git::at(r);
374 let files = g
375 .run(&["ls-tree", "-r", "--name-only", &saved.commit])
376 .unwrap();
377 assert_eq!(String::from_utf8_lossy(&files), "a.txt\n");
378 assert!(
379 saved.reason.contains("snap.txt left out"),
380 "{}",
381 saved.reason
382 );
383 }
384
385 #[test]
386 fn usage_trailers_round_trip_with_a_reported_cost() {
387 let u = serde_json::json!({ "input_tokens": 100, "output_tokens": 5, "cache_read_tokens": 90, "cache_write_tokens": 3, "cost_usd": 0.0125 });
388 let t = usage_trailer(Some(&u));
389 assert_eq!(
390 t,
391 "Usage: input=100 output=5 cache_read=90 cache_write=3 usd=0.0125\n"
392 );
393 let back = parse_usage(t.trim().trim_start_matches("Usage: ")).unwrap_or_default();
394 assert_eq!(back, u);
395 let native = parse_usage("input=1 output=2 cache_read=0 cache_write=0").unwrap_or_default();
396 assert!(native.get("cost_usd").is_none());
397 }
398}