Skip to main content

codex_sync/sync/
directory.rs

1//! Thin adapter for snapshots stored in any mounted or synchronized directory.
2
3use super::core::{self, DirectorySource, PullReport};
4use crate::config::Config;
5use anyhow::{Result, bail};
6use serde::Serialize;
7use std::{
8    fs,
9    path::{Path, PathBuf},
10};
11
12const ROOT: &str = "codex-sync";
13
14#[derive(Debug, Serialize)]
15pub struct SnapshotInfo {
16    pub node_id: String,
17    pub node_name: String,
18    pub files: usize,
19    pub path: PathBuf,
20}
21
22pub fn export(cfg: &Config, destination: &Path) -> Result<PathBuf> {
23    if !destination.is_dir() {
24        bail!("目标目录不存在:{}", destination.display());
25    }
26    let snapshot = destination.join(ROOT).join(cfg.node_id());
27    core::publish_snapshot(cfg, &snapshot)?;
28    Ok(snapshot)
29}
30
31pub async fn import(cfg: &Config, source: &Path, overwrite: bool) -> Result<PullReport> {
32    let snapshot = resolve_snapshot(source)?;
33    core::import_from(cfg, &DirectorySource::open(&snapshot)?, overwrite).await
34}
35
36pub fn list(source: &Path) -> Result<Vec<SnapshotInfo>> {
37    let root = if source.join(ROOT).is_dir() {
38        source.join(ROOT)
39    } else {
40        source.to_path_buf()
41    };
42    if !root.is_dir() {
43        bail!("未找到 codex-sync 快照目录");
44    }
45    let mut snapshots = Vec::new();
46    for entry in fs::read_dir(root)? {
47        let path = entry?.path();
48        if !path.join(core::MANIFEST_FILE).is_file() {
49            continue;
50        }
51        let manifest = core::read_manifest(&path)?;
52        snapshots.push(SnapshotInfo {
53            node_id: manifest.node_id,
54            node_name: manifest.node_name,
55            files: manifest.files.len(),
56            path,
57        });
58    }
59    snapshots.sort_by(|left, right| left.node_name.cmp(&right.node_name));
60    Ok(snapshots)
61}
62
63fn resolve_snapshot(source: &Path) -> Result<PathBuf> {
64    if source.join(core::MANIFEST_FILE).is_file() {
65        return Ok(source.to_path_buf());
66    }
67    match list(source)?.as_slice() {
68        [one] => Ok(one.path.clone()),
69        [] => bail!("没有找到可导入的快照"),
70        _ => bail!("发现多个快照,请指定其中一个快照目录"),
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn temp_dir(label: &str) -> PathBuf {
79        std::env::temp_dir().join(format!(
80            "codex-sync-{label}-{}-{}",
81            std::process::id(),
82            std::time::SystemTime::now()
83                .duration_since(std::time::UNIX_EPOCH)
84                .unwrap()
85                .as_nanos()
86        ))
87    }
88
89    #[test]
90    fn missing_usb_directory_is_rejected() {
91        assert!(
92            export(
93                &Config::default(),
94                Path::new("/definitely/not/a/real/usb/path")
95            )
96            .is_err()
97        );
98    }
99
100    #[tokio::test]
101    async fn exports_and_imports_a_verified_snapshot() -> Result<()> {
102        let root = temp_dir("roundtrip");
103        let source = root.join("computer-a");
104        let destination = root.join("computer-b");
105        let relay = root.join("relay");
106        fs::create_dir_all(source.join("sessions"))?;
107        fs::create_dir_all(&destination)?;
108        fs::create_dir_all(&relay)?;
109        fs::write(source.join("sessions/chat.jsonl"), b"hello codex")?;
110        fs::write(source.join("auth.json"), b"must not leave computer")?;
111
112        let a = Config {
113            node_name: "computer-a".into(),
114            shared_key: "same-key".into(),
115            sync_dir: source,
116            ..Config::default()
117        };
118        let b = Config {
119            node_name: "computer-b".into(),
120            sync_dir: destination.clone(),
121            ..a.clone()
122        };
123
124        export(&a, &relay)?;
125        let report = import(&b, &relay, false).await?;
126        assert_eq!(report.downloaded, 1);
127        assert_eq!(
128            fs::read(destination.join("sessions/chat.jsonl"))?,
129            b"hello codex"
130        );
131        assert!(!destination.join("auth.json").exists());
132        fs::remove_dir_all(root)?;
133        Ok(())
134    }
135}