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