Skip to main content

call_coding_clis/
artifacts.rs

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}