1use std::env;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::Mutex;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9static RUN_ID_SEQUENCE: AtomicU64 = AtomicU64::new(0);
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum TranscriptKind {
13 Text,
14 Jsonl,
15}
16
17pub struct RunArtifacts {
18 run_dir: PathBuf,
19 output_path: PathBuf,
20 transcript_path: PathBuf,
21 transcript_warning: Option<String>,
22 transcript: Mutex<Option<File>>,
23}
24
25impl RunArtifacts {
26 pub fn create(transcript_kind: TranscriptKind) -> io::Result<Self> {
27 Self::create_in(resolve_state_root(), transcript_kind)
28 }
29
30 pub fn create_for_runner(
31 runner_name: &str,
32 transcript_kind: TranscriptKind,
33 ) -> io::Result<Self> {
34 Self::create_in_for_runner(resolve_state_root(), transcript_kind, runner_name)
35 }
36
37 pub fn create_in(
38 state_root: impl AsRef<Path>,
39 transcript_kind: TranscriptKind,
40 ) -> io::Result<Self> {
41 Self::create_in_with_id_source(state_root, transcript_kind, default_run_id)
42 }
43
44 pub fn create_in_for_runner(
45 state_root: impl AsRef<Path>,
46 transcript_kind: TranscriptKind,
47 runner_name: &str,
48 ) -> io::Result<Self> {
49 Self::create_in_with_id_source_for_runner(
50 state_root,
51 transcript_kind,
52 runner_name,
53 default_run_id,
54 )
55 }
56
57 pub fn create_in_with_id_source<F>(
58 state_root: impl AsRef<Path>,
59 transcript_kind: TranscriptKind,
60 next_run_id: F,
61 ) -> io::Result<Self>
62 where
63 F: FnMut() -> String,
64 {
65 Self::create_in_with_id_source_and_transcript_opener(
66 state_root,
67 transcript_kind,
68 next_run_id,
69 |path| {
70 OpenOptions::new()
71 .create(true)
72 .truncate(true)
73 .write(true)
74 .open(path)
75 },
76 )
77 }
78
79 pub fn create_in_with_id_source_for_runner<F>(
80 state_root: impl AsRef<Path>,
81 transcript_kind: TranscriptKind,
82 runner_name: &str,
83 next_run_id: F,
84 ) -> io::Result<Self>
85 where
86 F: FnMut() -> String,
87 {
88 Self::create_in_with_id_source_and_transcript_opener_for_runner(
89 state_root,
90 transcript_kind,
91 runner_name,
92 next_run_id,
93 |path| {
94 OpenOptions::new()
95 .create(true)
96 .truncate(true)
97 .write(true)
98 .open(path)
99 },
100 )
101 }
102
103 pub fn create_in_with_id_source_and_transcript_opener<F, O>(
104 state_root: impl AsRef<Path>,
105 transcript_kind: TranscriptKind,
106 next_run_id: F,
107 transcript_opener: O,
108 ) -> io::Result<Self>
109 where
110 F: FnMut() -> String,
111 O: FnOnce(&Path) -> io::Result<File>,
112 {
113 Self::create_in_with_id_source_and_transcript_opener_internal(
114 state_root,
115 transcript_kind,
116 None,
117 next_run_id,
118 transcript_opener,
119 )
120 }
121
122 pub fn create_in_with_id_source_and_transcript_opener_for_runner<F, O>(
123 state_root: impl AsRef<Path>,
124 transcript_kind: TranscriptKind,
125 runner_name: &str,
126 next_run_id: F,
127 transcript_opener: O,
128 ) -> io::Result<Self>
129 where
130 F: FnMut() -> String,
131 O: FnOnce(&Path) -> io::Result<File>,
132 {
133 Self::create_in_with_id_source_and_transcript_opener_internal(
134 state_root,
135 transcript_kind,
136 Some(canonical_run_dir_prefix(runner_name)),
137 next_run_id,
138 transcript_opener,
139 )
140 }
141
142 fn create_in_with_id_source_and_transcript_opener_internal<F, O>(
143 state_root: impl AsRef<Path>,
144 transcript_kind: TranscriptKind,
145 run_dir_prefix: Option<String>,
146 mut next_run_id: F,
147 transcript_opener: O,
148 ) -> io::Result<Self>
149 where
150 F: FnMut() -> String,
151 O: FnOnce(&Path) -> io::Result<File>,
152 {
153 let runs_root = state_root.as_ref().join("ccc/runs");
154 fs::create_dir_all(&runs_root)?;
155 let run_dir_prefix = run_dir_prefix
156 .map(|value| canonical_run_dir_prefix(&value))
157 .filter(|value| !value.is_empty());
158
159 let run_dir = loop {
160 let run_id = next_run_id();
161 let candidate_name = match run_dir_prefix.as_deref() {
162 Some(prefix) => format!("{prefix}-{run_id}"),
163 None => run_id,
164 };
165 let candidate = runs_root.join(&candidate_name);
166 match fs::create_dir(&candidate) {
167 Ok(()) => break candidate,
168 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
169 Err(error) => return Err(error),
170 }
171 };
172
173 let transcript_path = run_dir.join(match transcript_kind {
174 TranscriptKind::Text => "transcript.txt",
175 TranscriptKind::Jsonl => "transcript.jsonl",
176 });
177 let (transcript, transcript_warning) = match transcript_opener(&transcript_path) {
178 Ok(file) => (Some(file), None),
179 Err(error) => (
180 None,
181 Some(transcript_io_warning("create", &transcript_path, &error)),
182 ),
183 };
184
185 Ok(Self {
186 output_path: run_dir.join("output.txt"),
187 transcript_path,
188 transcript_warning,
189 transcript: Mutex::new(transcript),
190 run_dir,
191 })
192 }
193
194 pub fn run_dir(&self) -> &Path {
195 &self.run_dir
196 }
197
198 pub fn output_path(&self) -> &Path {
199 &self.output_path
200 }
201
202 pub fn transcript_path(&self) -> &Path {
203 &self.transcript_path
204 }
205
206 pub fn transcript_warning(&self) -> Option<&str> {
207 self.transcript_warning.as_deref()
208 }
209
210 pub fn record_stdout(&self, text: &str) -> io::Result<()> {
211 let mut guard = self.transcript.lock().unwrap();
212 if let Some(file) = guard.as_mut() {
213 if let Err(error) = file.write_all(text.as_bytes()).and_then(|_| file.flush()) {
214 *guard = None;
215 return Err(error);
216 }
217 }
218 Ok(())
219 }
220
221 pub fn write_output_text(&self, text: &str) -> io::Result<()> {
222 fs::write(&self.output_path, text)
223 }
224
225 pub fn footer_line(&self) -> String {
226 format!(">> ccc:output-log >> {}", self.run_dir.display())
227 }
228}
229
230fn canonical_run_dir_prefix(run_dir_prefix: &str) -> String {
231 match run_dir_prefix.trim().to_ascii_lowercase().as_str() {
232 "oc" | "opencode" => "opencode".to_string(),
233 "cc" | "claude" => "claude".to_string(),
234 "c" | "cx" | "codex" => "codex".to_string(),
235 "k" | "kimi" => "kimi".to_string(),
236 "cr" | "crush" => "crush".to_string(),
237 "rc" | "roocode" => "roocode".to_string(),
238 "cu" | "cursor" => "cursor".to_string(),
239 "g" | "gemini" => "gemini".to_string(),
240 other => other.to_string(),
241 }
242}
243
244pub fn output_write_warning(error: &io::Error) -> String {
245 format!("warning: could not write output.txt: {error}")
246}
247
248pub fn transcript_io_warning(action: &str, transcript_path: &Path, error: &io::Error) -> String {
249 let transcript_name = transcript_path
250 .file_name()
251 .and_then(|value| value.to_str())
252 .unwrap_or("transcript.txt");
253 format!("warning: could not {action} {transcript_name}: {error}")
254}
255
256pub fn resolve_state_root() -> PathBuf {
257 #[cfg(target_os = "windows")]
258 {
259 if let Some(local_app_data) = env_path("LOCALAPPDATA") {
260 return local_app_data;
261 }
262 if let Some(home) = home_dir() {
263 return home.join("AppData/Local");
264 }
265 return PathBuf::from(".");
266 }
267
268 #[cfg(target_os = "macos")]
269 {
270 if let Some(home) = home_dir() {
271 return home.join("Library/Application Support");
272 }
273 return PathBuf::from(".");
274 }
275
276 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
277 {
278 if let Some(xdg_state_home) = env_path("XDG_STATE_HOME") {
279 return xdg_state_home;
280 }
281 if let Some(home) = home_dir() {
282 return home.join(".local/state");
283 }
284 PathBuf::from(".")
285 }
286}
287
288fn env_path(key: &str) -> Option<PathBuf> {
289 let value = env::var_os(key)?;
290 if value.is_empty() {
291 None
292 } else {
293 Some(PathBuf::from(value))
294 }
295}
296
297fn home_dir() -> Option<PathBuf> {
298 env_path("HOME")
299}
300
301fn default_run_id() -> String {
302 let timestamp = SystemTime::now()
303 .duration_since(UNIX_EPOCH)
304 .unwrap_or_default()
305 .as_millis();
306 let pid = std::process::id();
307 let sequence = RUN_ID_SEQUENCE.fetch_add(1, Ordering::Relaxed);
308 format!("{timestamp}-{pid}-{sequence}")
309}