1use 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"); 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}