Skip to main content

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