Skip to main content

agent_core/core/
chain.rs

1//! Chain pointer management — named bookmarks that auto-advance on compaction.
2//!
3//! A chain is just a file containing a session ID. When a session is compacted,
4//! any chain pointing at the old session head is advanced to the new session.
5//! All I/O is sync (std::fs) — chain files are tiny (<100 bytes).
6
7use serde::{Deserialize, Serialize};
8use std::io;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ChainPointer {
13    pub head: String,
14}
15
16#[derive(Debug, Clone)]
17pub struct NamedChain {
18    pub name: String,
19    pub head: String,
20}
21
22pub fn chains_dir() -> PathBuf {
23    crate::config::get_active_config_dir().join("chains")
24}
25
26pub fn chain_path(name: &str) -> PathBuf {
27    chains_dir().join(format!("{}.json", name))
28}
29
30fn validate_chain_name(name: &str) -> io::Result<()> {
31    crate::session::validate_name(name).map_err(io::Error::other)
32}
33
34pub fn load_chain(name: &str) -> io::Result<ChainPointer> {
35    validate_chain_name(name)?;
36    let path = chain_path(name);
37    let content = std::fs::read_to_string(&path)?;
38    serde_json::from_str(&content).map_err(io::Error::other)
39}
40
41pub fn save_chain(name: &str, head: &str) -> io::Result<()> {
42    validate_chain_name(name)?;
43
44    let dir = chains_dir();
45    std::fs::create_dir_all(&dir)?;
46    let path = chain_path(name);
47    let tmp = path.with_extension("tmp");
48    let ptr = ChainPointer {
49        head: head.to_string(),
50    };
51    let json = serde_json::to_string(&ptr).map_err(io::Error::other)?;
52    std::fs::write(&tmp, json)?;
53    std::fs::rename(&tmp, &path)
54}
55
56pub fn delete_chain(name: &str) -> io::Result<()> {
57    validate_chain_name(name)?;
58    let path = chain_path(name);
59    std::fs::remove_file(path)
60}
61
62pub fn list_chains() -> io::Result<Vec<NamedChain>> {
63    let dir = chains_dir();
64    if !dir.exists() {
65        return Ok(Vec::new());
66    }
67    let mut out = Vec::new();
68    for entry in std::fs::read_dir(&dir)? {
69        let entry = entry?;
70        let path = entry.path();
71        if path.extension().is_some_and(|e| e == "json") {
72            let name = match path.file_stem().and_then(|s| s.to_str()) {
73                Some(n) => n.to_string(),
74                None => continue,
75            };
76            let content = match std::fs::read_to_string(&path) {
77                Ok(c) => c,
78                Err(_) => continue,
79            };
80            let ptr: ChainPointer = match serde_json::from_str(&content) {
81                Ok(p) => p,
82                Err(_) => continue,
83            };
84            out.push(NamedChain {
85                name,
86                head: ptr.head,
87            });
88        }
89    }
90    out.sort_by(|a, b| a.name.cmp(&b.name));
91    Ok(out)
92}
93
94pub fn find_chain_by_head(session_id: &str) -> io::Result<Option<NamedChain>> {
95    Ok(list_chains()?.into_iter().find(|c| c.head == session_id))
96}
97
98pub fn find_all_chains_by_head(session_id: &str) -> io::Result<Vec<NamedChain>> {
99    Ok(list_chains()?
100        .into_iter()
101        .filter(|c| c.head == session_id)
102        .collect())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serial_test::serial;
109
110    use std::sync::Mutex;
111    static ENV_LOCK: Mutex<()> = Mutex::new(());
112
113    fn with_tmp_home<F: FnOnce()>(f: F) {
114        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
115        let tmp = std::env::temp_dir().join(format!("synaps-chain-test-{}", uuid::Uuid::new_v4()));
116        std::fs::create_dir_all(&tmp).unwrap();
117        let prev_home = std::env::var_os("HOME");
118        unsafe {
119            std::env::set_var("HOME", &tmp);
120        }
121        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
122        match prev_home {
123            Some(v) => unsafe {
124                std::env::set_var("HOME", v);
125            },
126            None => unsafe {
127                std::env::remove_var("HOME");
128            },
129        }
130        let _ = std::fs::remove_dir_all(&tmp);
131        if let Err(e) = result {
132            std::panic::resume_unwind(e);
133        }
134    }
135
136    #[test]
137    fn chain_pointer_roundtrip() {
138        let p = ChainPointer { head: "abc".into() };
139        let s = serde_json::to_string(&p).unwrap();
140        let back: ChainPointer = serde_json::from_str(&s).unwrap();
141        assert_eq!(back.head, "abc");
142    }
143
144    #[test]
145    fn invalid_names_rejected() {
146        assert!(load_chain("").is_err());
147        assert!(load_chain("UPPER").is_err());
148        assert!(load_chain("has space").is_err());
149        assert!(save_chain("bad name", "id").is_err());
150        assert!(delete_chain("").is_err());
151    }
152
153    #[test]
154    #[serial]
155    fn save_load_delete_list() {
156        with_tmp_home(|| {
157            assert!(list_chains().unwrap().is_empty());
158            save_chain("work", "session-1").unwrap();
159            save_chain("play", "session-2").unwrap();
160
161            let ptr = load_chain("work").unwrap();
162            assert_eq!(ptr.head, "session-1");
163
164            let all = list_chains().unwrap();
165            assert_eq!(all.len(), 2);
166            assert_eq!(all[0].name, "play"); // sorted
167            assert_eq!(all[1].name, "work");
168
169            let found = find_chain_by_head("session-2").unwrap();
170            assert_eq!(found.unwrap().name, "play");
171
172            save_chain("also-play", "session-2").unwrap();
173            let multi = find_all_chains_by_head("session-2").unwrap();
174            assert_eq!(multi.len(), 2);
175
176            delete_chain("play").unwrap();
177            assert!(load_chain("play").is_err());
178        });
179    }
180
181    #[test]
182    #[serial]
183    fn overwrite_updates_head() {
184        with_tmp_home(|| {
185            save_chain("c", "one").unwrap();
186            save_chain("c", "two").unwrap();
187            assert_eq!(load_chain("c").unwrap().head, "two");
188        });
189    }
190}