codex_sync/sync/
directory.rs1use 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}