1use anyhow::{Context, Result, anyhow};
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36pub fn sessions_root() -> Result<PathBuf> {
42 if let Ok(home) = std::env::var("WIRE_HOME") {
43 return Ok(PathBuf::from(home).join("sessions"));
44 }
45 let state = dirs::state_dir()
46 .ok_or_else(|| anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?;
47 Ok(state.join("wire").join("sessions"))
48}
49
50pub fn session_dir(name: &str) -> Result<PathBuf> {
54 Ok(sessions_root()?.join(sanitize_name(name)))
55}
56
57pub fn registry_path() -> Result<PathBuf> {
61 Ok(sessions_root()?.join("registry.json"))
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct SessionRegistry {
66 #[serde(default)]
69 pub by_cwd: HashMap<String, String>,
70}
71
72pub fn read_registry() -> Result<SessionRegistry> {
73 let path = registry_path()?;
74 if !path.exists() {
75 return Ok(SessionRegistry::default());
76 }
77 let bytes = std::fs::read(&path)
78 .with_context(|| format!("reading session registry {path:?}"))?;
79 serde_json::from_slice(&bytes)
80 .with_context(|| format!("parsing session registry {path:?}"))
81}
82
83pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
84 let path = registry_path()?;
85 if let Some(parent) = path.parent() {
86 std::fs::create_dir_all(parent)
87 .with_context(|| format!("creating {parent:?}"))?;
88 }
89 let body = serde_json::to_vec_pretty(reg)?;
90 std::fs::write(&path, body)
91 .with_context(|| format!("writing session registry {path:?}"))?;
92 Ok(())
93}
94
95pub fn sanitize_name(raw: &str) -> String {
99 let mut out = String::with_capacity(raw.len());
100 let mut prev_dash = false;
101 for c in raw.chars() {
102 let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
103 let ch = if ok { c.to_ascii_lowercase() } else { '-' };
104 if ch == '-' {
105 if !prev_dash && !out.is_empty() {
106 out.push('-');
107 }
108 prev_dash = true;
109 } else {
110 out.push(ch);
111 prev_dash = false;
112 }
113 }
114 let trimmed = out.trim_matches('-').to_string();
115 if trimmed.is_empty() {
116 return "wire-session".to_string();
117 }
118 if trimmed.len() > 32 {
119 return trimmed[..32].trim_end_matches('-').to_string();
120 }
121 trimmed
122}
123
124fn path_hash_suffix(cwd: &Path) -> String {
128 let bytes = cwd.as_os_str().to_string_lossy().into_owned();
129 let mut h = Sha256::new();
130 h.update(bytes.as_bytes());
131 let digest = h.finalize();
132 hex::encode(&digest[..2]) }
134
135pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
144 let cwd_key = cwd.to_string_lossy().into_owned();
145 if let Some(existing) = registry.by_cwd.get(&cwd_key) {
146 return existing.clone();
147 }
148 let base = cwd
149 .file_name()
150 .and_then(|s| s.to_str())
151 .map(sanitize_name)
152 .unwrap_or_else(|| "wire-session".to_string());
153 let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
154 if !occupied.contains(&base) {
155 return base;
156 }
157 let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
158 if !occupied.contains(&with_hash) {
159 return with_hash;
160 }
161 for n in 2..1000 {
164 let candidate = format!("{base}-{n}");
165 if !occupied.contains(&candidate) {
166 return candidate;
167 }
168 }
169 format!("{base}-{}-overflow", path_hash_suffix(cwd))
171}
172
173#[derive(Debug, Clone, Serialize)]
175pub struct SessionInfo {
176 pub name: String,
177 pub cwd: Option<String>,
181 pub home_dir: PathBuf,
182 pub did: Option<String>,
183 pub handle: Option<String>,
184 pub daemon_running: bool,
188}
189
190pub fn list_sessions() -> Result<Vec<SessionInfo>> {
193 let root = sessions_root()?;
194 if !root.exists() {
195 return Ok(Vec::new());
196 }
197 let registry = read_registry().unwrap_or_default();
198 let mut name_to_cwd: HashMap<String, String> = HashMap::new();
200 for (cwd, name) in ®istry.by_cwd {
201 name_to_cwd.insert(name.clone(), cwd.clone());
202 }
203
204 let mut out = Vec::new();
205 for entry in std::fs::read_dir(&root)?.flatten() {
206 let path = entry.path();
207 if !path.is_dir() {
208 continue;
209 }
210 let name = match path.file_name().and_then(|s| s.to_str()) {
211 Some(s) => s.to_string(),
212 None => continue,
213 };
214 if name == "registry.json" {
216 continue;
217 }
218 let card_path = path.join("config").join("wire").join("agent-card.json");
219 let (did, handle) = read_card_identity(&card_path);
220 let daemon_running = check_daemon_live(&path);
221 out.push(SessionInfo {
222 name: name.clone(),
223 cwd: name_to_cwd.get(&name).cloned(),
224 home_dir: path,
225 did,
226 handle,
227 daemon_running,
228 });
229 }
230 out.sort_by(|a, b| a.name.cmp(&b.name));
231 Ok(out)
232}
233
234fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
235 let bytes = match std::fs::read(card_path) {
236 Ok(b) => b,
237 Err(_) => return (None, None),
238 };
239 let v: serde_json::Value = match serde_json::from_slice(&bytes) {
240 Ok(v) => v,
241 Err(_) => return (None, None),
242 };
243 let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
244 let handle = v
245 .get("handle")
246 .and_then(|x| x.as_str())
247 .map(str::to_string)
248 .or_else(|| {
249 did.as_ref().map(|d| {
250 crate::agent_card::display_handle_from_did(d).to_string()
251 })
252 });
253 (did, handle)
254}
255
256fn check_daemon_live(session_home: &Path) -> bool {
257 let pidfile = session_home
262 .join("state")
263 .join("wire")
264 .join("daemon.pid");
265 let bytes = match std::fs::read(&pidfile) {
266 Ok(b) => b,
267 Err(_) => return false,
268 };
269 let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
271 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
272 } else {
273 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
275 };
276 let pid = match pid_opt {
277 Some(p) => p,
278 None => return false,
279 };
280 is_process_live(pid)
281}
282
283fn is_process_live(pid: u32) -> bool {
284 #[cfg(target_os = "linux")]
285 {
286 std::path::Path::new(&format!("/proc/{pid}")).exists()
287 }
288 #[cfg(not(target_os = "linux"))]
289 {
290 std::process::Command::new("kill")
291 .args(["-0", &pid.to_string()])
292 .output()
293 .map(|o| o.status.success())
294 .unwrap_or(false)
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn sanitize_handles_unicode_and_long_names() {
304 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
305 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
306 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
308 assert_eq!(sanitize_name("---"), "wire-session");
309 let long: String = "a".repeat(100);
310 assert_eq!(sanitize_name(&long).len(), 32);
311 }
312
313 #[test]
314 fn derive_name_returns_basename_when_no_collision() {
315 let reg = SessionRegistry::default();
316 assert_eq!(
317 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
318 "wire"
319 );
320 assert_eq!(
321 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
322 "slancha-mesh"
323 );
324 }
325
326 #[test]
327 fn derive_name_returns_stored_name_when_cwd_already_registered() {
328 let mut reg = SessionRegistry::default();
329 reg.by_cwd.insert(
330 "/Users/paul/Source/wire".to_string(),
331 "wire-special".to_string(),
332 );
333 assert_eq!(
334 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
335 "wire-special"
336 );
337 }
338
339 #[test]
340 fn derive_name_appends_path_hash_when_basename_collides() {
341 let mut reg = SessionRegistry::default();
342 reg.by_cwd.insert(
343 "/Users/paul/Source/wire".to_string(),
344 "wire".to_string(),
345 );
346 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
348 assert!(name.starts_with("wire-"));
349 assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
351 }
352}