codexctl 0.8.0

Codex Controller - Full control plane for Codex CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result};

use crate::utils::files::{copy_dir_recursive, copy_profile_files};

/// Atomic profile switcher using a stage-then-rename strategy.
///
/// The staging directory is created in the same parent as the target so that
/// `std::fs::rename` is always a same-filesystem, atomic operation.
///
/// # Lifecycle
///
/// 1. [`ProfileTransaction::new`] – allocate staging space.
/// 2. [`stage_profile`] – populate staging with profile files.
/// 3. [`commit`] – atomically swap staging → target (old target saved internally).
/// 4. [`cleanup_original`] – drop the pre-commit snapshot when no longer needed.
/// 5. [`rollback`] – restore the pre-commit snapshot (can be called after commit too).
///
/// On `Drop`, any un-committed staging directory is removed automatically.
#[allow(dead_code)]
pub struct ProfileTransaction {
    target_dir: PathBuf,
    /// Temp directory for the incoming profile (same filesystem as target).
    staging_dir: PathBuf,
    /// Where the original `target_dir` was moved to during `commit()` (enables rollback).
    original_dir: Option<PathBuf>,
    staged: bool,
    committed: bool,
}

#[allow(dead_code)]
impl ProfileTransaction {
    /// Create a new transaction targeting `target_dir`.
    ///
    /// The staging directory is placed in the same parent directory as `target_dir`
    /// to guarantee a same-filesystem rename on commit.
    ///
    /// # Errors
    ///
    /// Returns an error if the staging directory cannot be created.
    pub fn new(target_dir: impl Into<PathBuf>) -> Result<Self> {
        let target_dir = target_dir.into();
        let parent = target_dir
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .to_path_buf();

        std::fs::create_dir_all(&parent)
            .with_context(|| format!("Failed to create parent directory: {}", parent.display()))?;

        let staging_dir = unique_sibling_path(&parent, ".codex_txn_staging")?;
        std::fs::create_dir_all(&staging_dir).with_context(|| {
            format!(
                "Failed to create staging directory: {}",
                staging_dir.display()
            )
        })?;

        Ok(Self {
            target_dir,
            staging_dir,
            original_dir: None,
            staged: false,
            committed: false,
        })
    }

    /// Copy profile files from `src` into the staging directory.
    ///
    /// Only the listed `files` patterns are copied; the staging directory starts
    /// empty, so any file absent from the profile is absent after commit.
    ///
    /// # Errors
    ///
    /// Returns an error if any file copy fails.
    pub fn stage_profile(&mut self, src: &Path, files: &[&str]) -> Result<Vec<String>> {
        let copied = copy_profile_files(src, &self.staging_dir, files)
            .context("Failed to stage profile files")?;
        self.staged = true;
        Ok(copied)
    }

    /// Stage an entire directory tree into the staging directory.
    ///
    /// Used by `run` to restore the original codex state after a command.
    ///
    /// # Errors
    ///
    /// Returns an error if any file copy fails.
    #[allow(dead_code)]
    pub fn stage_dir(&mut self, src: &Path) -> Result<()> {
        copy_dir_recursive(src, &self.staging_dir).context("Failed to stage directory")?;
        self.staged = true;
        Ok(())
    }

    /// Atomically commit the staged profile to the target directory.
    ///
    /// The sequence is:
    /// 1. Rename target → `original_dir` (saves current state for rollback).
    /// 2. Rename staging → target (installs new profile atomically).
    ///
    /// Both renames are same-filesystem operations and therefore atomic on
    /// POSIX systems.
    ///
    /// # Errors
    ///
    /// Returns an error if either rename fails.
    pub fn commit(&mut self) -> Result<()> {
        if !self.staged {
            anyhow::bail!("Cannot commit: no profile has been staged");
        }

        let parent = self.target_dir.parent().unwrap_or_else(|| Path::new("."));

        // Move existing target out of the way so we can rename staging into its place.
        if self.target_dir.exists() {
            let orig_path = unique_sibling_path(parent, ".codex_txn_orig")?;
            std::fs::rename(&self.target_dir, &orig_path).with_context(|| {
                format!(
                    "Failed to move current profile aside: {}",
                    self.target_dir.display()
                )
            })?;
            self.original_dir = Some(orig_path);
        }

        // Atomic rename: staging → target.
        std::fs::rename(&self.staging_dir, &self.target_dir).with_context(|| {
            format!(
                "Failed to rename staging dir to target: {}",
                self.target_dir.display()
            )
        })?;

        self.committed = true;
        Ok(())
    }

    /// Rollback to the state before `commit()`.
    ///
    /// - If called before `commit()`: removes the staging directory.
    /// - If called after `commit()`: removes the committed state and restores
    ///   the original, or just removes the target when there was no original.
    ///
    /// # Errors
    ///
    /// Returns an error if any filesystem operation fails.
    pub fn rollback(&mut self) -> Result<()> {
        if self.committed {
            // Remove what we committed.
            if self.target_dir.exists() {
                std::fs::remove_dir_all(&self.target_dir)
                    .context("Failed to remove committed profile during rollback")?;
            }

            // Restore original if there was one.
            if let Some(ref orig) = self.original_dir.take()
                && orig.exists()
            {
                std::fs::rename(orig, &self.target_dir)
                    .context("Failed to restore original profile during rollback")?;
            }

            self.committed = false;
        } else {
            // Not yet committed; just wipe the staging directory.
            if self.staging_dir.exists() {
                std::fs::remove_dir_all(&self.staging_dir)
                    .context("Failed to clean up staging directory during rollback")?;
            }
            self.staged = false;
        }

        Ok(())
    }

    /// Remove the pre-commit snapshot saved during `commit()`.
    ///
    /// Call this once the committed state is confirmed good and the original
    /// is no longer needed for rollback.
    ///
    /// # Errors
    ///
    /// Returns an error if the directory cannot be removed.
    pub fn cleanup_original(&self) -> Result<()> {
        if let Some(ref orig) = self.original_dir
            && orig.exists()
        {
            std::fs::remove_dir_all(orig)
                .context("Failed to remove original backup after commit")?;
        }
        Ok(())
    }

    /// Path where the original target was saved after `commit()`, if any.
    #[must_use]
    #[cfg(test)]
    pub fn original_backup_path(&self) -> Option<&Path> {
        self.original_dir.as_deref()
    }

    /// The staging directory path.
    #[must_use]
    pub fn staging_dir(&self) -> &Path {
        &self.staging_dir
    }
}

impl Drop for ProfileTransaction {
    /// Ensure staging leftovers are cleaned up if the transaction is dropped
    /// without being committed (e.g. on early error return).
    fn drop(&mut self) {
        if !self.committed && self.staging_dir.exists() {
            let _ = std::fs::remove_dir_all(&self.staging_dir);
        }
    }
}

/// Generate a path with a unique suffix so it does not collide with
/// any existing entry in `parent`.
///
/// Uses a combination of timestamp and random suffix to avoid collisions
/// under concurrent access.
#[allow(dead_code, clippy::unnecessary_wraps)]
fn unique_sibling_path(parent: &Path, prefix: &str) -> Result<PathBuf> {
    use std::time::{SystemTime, UNIX_EPOCH};

    let ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.as_nanos());

    let rand_suffix: u64 = rand::random::<u64>();

    let path = parent.join(format!("{prefix}_{ts}_{rand_suffix:016x}"));
    Ok(path)
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::*;

    fn make_profile(dir: &TempDir) -> PathBuf {
        let profile = dir.path().join("profile");
        std::fs::create_dir_all(&profile).unwrap();
        std::fs::write(profile.join("auth.json"), r#"{"token":"test"}"#).unwrap();
        std::fs::write(profile.join("config.toml"), "model = \"o4-mini\"").unwrap();
        profile
    }

    #[test]
    fn test_stage_and_commit_creates_target() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");
        let profile_src = make_profile(&tmp);

        let mut txn = ProfileTransaction::new(&target).unwrap();
        txn.stage_profile(&profile_src, &["auth.json", "config.toml"])
            .unwrap();
        txn.commit().unwrap();

        assert!(target.exists(), "target should exist after commit");
        assert!(target.join("auth.json").exists());
        assert!(target.join("config.toml").exists());
    }

    #[test]
    fn test_commit_clears_stale_files() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");
        std::fs::create_dir_all(&target).unwrap();
        // Write a stale file that is NOT in the new profile.
        std::fs::write(target.join("stale.json"), "old").unwrap();

        let profile_src = make_profile(&tmp);

        let mut txn = ProfileTransaction::new(&target).unwrap();
        txn.stage_profile(&profile_src, &["auth.json"]).unwrap();
        txn.commit().unwrap();

        // The committed target should only contain what was staged.
        assert!(target.join("auth.json").exists());
        assert!(
            !target.join("stale.json").exists(),
            "stale file must be gone after atomic switch"
        );
    }

    #[test]
    fn test_rollback_before_commit_removes_staging() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");
        let profile_src = make_profile(&tmp);

        let mut txn = ProfileTransaction::new(&target).unwrap();
        let staging = txn.staging_dir().to_path_buf();
        txn.stage_profile(&profile_src, &["auth.json"]).unwrap();

        assert!(staging.exists(), "staging should exist before rollback");
        txn.rollback().unwrap();

        assert!(!staging.exists(), "staging should be gone after rollback");
        assert!(!target.exists(), "target should not have been created");
    }

    #[test]
    fn test_rollback_after_commit_restores_original() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");

        // Pre-existing state.
        std::fs::create_dir_all(&target).unwrap();
        std::fs::write(target.join("auth.json"), r#"{"token":"original"}"#).unwrap();

        let profile_src = make_profile(&tmp); // has auth.json with "test" token

        let mut txn = ProfileTransaction::new(&target).unwrap();
        txn.stage_profile(&profile_src, &["auth.json"]).unwrap();
        txn.commit().unwrap();

        // Confirm new profile is live.
        let committed = std::fs::read_to_string(target.join("auth.json")).unwrap();
        assert!(committed.contains("test"));

        // Rollback should restore original.
        txn.rollback().unwrap();

        let restored = std::fs::read_to_string(target.join("auth.json")).unwrap();
        assert!(
            restored.contains("original"),
            "original content should be restored after rollback"
        );
    }

    #[test]
    fn test_rollback_after_commit_no_original() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex"); // does not exist initially

        let profile_src = make_profile(&tmp);

        let mut txn = ProfileTransaction::new(&target).unwrap();
        txn.stage_profile(&profile_src, &["auth.json"]).unwrap();
        txn.commit().unwrap();

        assert!(target.exists());

        txn.rollback().unwrap();

        assert!(
            !target.exists(),
            "target should be removed when there was no original"
        );
    }

    #[test]
    fn test_cleanup_original_removes_backup() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");
        std::fs::create_dir_all(&target).unwrap();
        std::fs::write(target.join("auth.json"), "old").unwrap();

        let profile_src = make_profile(&tmp);

        let mut txn = ProfileTransaction::new(&target).unwrap();
        txn.stage_profile(&profile_src, &["auth.json"]).unwrap();
        txn.commit().unwrap();

        let orig_path = txn.original_backup_path().map(PathBuf::from);
        assert!(orig_path.is_some());
        let orig_path = orig_path.unwrap();
        assert!(
            orig_path.exists(),
            "original backup should exist after commit"
        );

        txn.cleanup_original().unwrap();
        assert!(
            !orig_path.exists(),
            "original backup should be gone after cleanup"
        );
    }

    #[test]
    fn test_drop_cleans_up_uncommitted_staging() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");
        let profile_src = make_profile(&tmp);

        let staging_path = {
            let mut txn = ProfileTransaction::new(&target).unwrap();
            txn.stage_profile(&profile_src, &["auth.json"]).unwrap();
            txn.staging_dir().to_path_buf()
            // txn dropped here without commit
        };

        assert!(
            !staging_path.exists(),
            "staging dir should be removed on drop"
        );
    }

    #[test]
    fn test_commit_without_stage_returns_error() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");

        let mut txn = ProfileTransaction::new(&target).unwrap();
        let result = txn.commit();
        assert!(result.is_err());
    }

    #[test]
    fn test_stage_dir_stages_entire_tree() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("codex");

        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("auth.json"), "a").unwrap();
        std::fs::create_dir_all(src.join("sessions")).unwrap();
        std::fs::write(src.join("sessions").join("s1.json"), "s").unwrap();

        let mut txn = ProfileTransaction::new(&target).unwrap();
        txn.stage_dir(&src).unwrap();
        txn.commit().unwrap();

        assert!(target.join("auth.json").exists());
        assert!(target.join("sessions").join("s1.json").exists());
    }
}