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 { head: head.to_string() };
49    let json = serde_json::to_string(&ptr).map_err(io::Error::other)?;
50    std::fs::write(&tmp, json)?;
51    std::fs::rename(&tmp, &path)
52}
53
54pub fn delete_chain(name: &str) -> io::Result<()> {
55    validate_chain_name(name)?;
56    let path = chain_path(name);
57    std::fs::remove_file(path)
58}
59
60pub fn list_chains() -> io::Result<Vec<NamedChain>> {
61    let dir = chains_dir();
62    if !dir.exists() {
63        return Ok(Vec::new());
64    }
65    let mut out = Vec::new();
66    for entry in std::fs::read_dir(&dir)? {
67        let entry = entry?;
68        let path = entry.path();
69        if path.extension().is_some_and(|e| e == "json") {
70            let name = match path.file_stem().and_then(|s| s.to_str()) {
71                Some(n) => n.to_string(),
72                None => continue,
73            };
74            let content = match std::fs::read_to_string(&path) {
75                Ok(c) => c,
76                Err(_) => continue,
77            };
78            let ptr: ChainPointer = match serde_json::from_str(&content) {
79                Ok(p) => p,
80                Err(_) => continue,
81            };
82            out.push(NamedChain { name, head: ptr.head });
83        }
84    }
85    out.sort_by(|a, b| a.name.cmp(&b.name));
86    Ok(out)
87}
88
89pub fn find_chain_by_head(session_id: &str) -> io::Result<Option<NamedChain>> {
90    Ok(list_chains()?.into_iter().find(|c| c.head == session_id))
91}
92
93pub fn find_all_chains_by_head(session_id: &str) -> io::Result<Vec<NamedChain>> {
94    Ok(list_chains()?.into_iter().filter(|c| c.head == session_id).collect())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use serial_test::serial;
101
102    use std::sync::Mutex;
103    static ENV_LOCK: Mutex<()> = Mutex::new(());
104
105    fn with_tmp_home<F: FnOnce()>(f: F) {
106        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
107        let tmp = std::env::temp_dir().join(format!("synaps-chain-test-{}", uuid::Uuid::new_v4()));
108        std::fs::create_dir_all(&tmp).unwrap();
109        let prev_home = std::env::var_os("HOME");
110        unsafe { std::env::set_var("HOME", &tmp); }
111        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
112        match prev_home {
113            Some(v) => unsafe { std::env::set_var("HOME", v); },
114            None => unsafe { std::env::remove_var("HOME"); },
115        }
116        let _ = std::fs::remove_dir_all(&tmp);
117        if let Err(e) = result { std::panic::resume_unwind(e); }
118    }
119
120    #[test]
121    fn chain_pointer_roundtrip() {
122        let p = ChainPointer { head: "abc".into() };
123        let s = serde_json::to_string(&p).unwrap();
124        let back: ChainPointer = serde_json::from_str(&s).unwrap();
125        assert_eq!(back.head, "abc");
126    }
127
128    #[test]
129    fn invalid_names_rejected() {
130        assert!(load_chain("").is_err());
131        assert!(load_chain("UPPER").is_err());
132        assert!(load_chain("has space").is_err());
133        assert!(save_chain("bad name", "id").is_err());
134        assert!(delete_chain("").is_err());
135    }
136
137    #[test]
138    #[serial]
139    fn save_load_delete_list() {
140        with_tmp_home(|| {
141            assert!(list_chains().unwrap().is_empty());
142            save_chain("work", "session-1").unwrap();
143            save_chain("play", "session-2").unwrap();
144
145            let ptr = load_chain("work").unwrap();
146            assert_eq!(ptr.head, "session-1");
147
148            let all = list_chains().unwrap();
149            assert_eq!(all.len(), 2);
150            assert_eq!(all[0].name, "play"); // sorted
151            assert_eq!(all[1].name, "work");
152
153            let found = find_chain_by_head("session-2").unwrap();
154            assert_eq!(found.unwrap().name, "play");
155
156            save_chain("also-play", "session-2").unwrap();
157            let multi = find_all_chains_by_head("session-2").unwrap();
158            assert_eq!(multi.len(), 2);
159
160            delete_chain("play").unwrap();
161            assert!(load_chain("play").is_err());
162        });
163    }
164
165    #[test]
166    #[serial]
167    fn overwrite_updates_head() {
168        with_tmp_home(|| {
169            save_chain("c", "one").unwrap();
170            save_chain("c", "two").unwrap();
171            assert_eq!(load_chain("c").unwrap().head, "two");
172        });
173    }
174}