Skip to main content

wire/
session.rs

1//! Multi-session wire on one machine (v0.5.16).
2//!
3//! Problem: multiple Claude Code (or any agent harness) sessions on the
4//! same machine share a single `WIRE_HOME`, which means they share the
5//! same DID, same relay slot, same inbox JSONL, and same daemon. Peers
6//! have no way to address a specific session, and the operator can't
7//! tell which session sent what.
8//!
9//! Solution: a `wire session` subcommand that bootstraps **isolated**
10//! per-session `WIRE_HOME` trees. Each session gets its own identity,
11//! handle, relay slot, daemon, and inbox/outbox. Sessions pair with each
12//! other through the public relay (`wireup.net`) like any other peer —
13//! no protocol changes. The bilateral-pair gate from v0.5.14 still
14//! applies in both directions.
15//!
16//! Storage layout:
17//!
18//! ```text
19//! ~/.local/state/wire/sessions/
20//!   registry.json                — cwd → session_name map
21//!   <session-name>/               — full WIRE_HOME tree per session
22//!     config/wire/...
23//!     state/wire/...
24//! ```
25//!
26//! Naming: derived from `basename(cwd)` so re-opening the same project
27//! reuses the same session identity. Collisions across two different
28//! paths with the same basename get a 4-char SHA-256 path-hash suffix.
29
30use anyhow::{Context, Result, anyhow};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sha2::{Digest, Sha256};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
38
39/// Root directory under which all session WIRE_HOMEs live.
40///
41/// Honors `WIRE_HOME` for testing (sessions root becomes
42/// `$WIRE_HOME/sessions/`); otherwise:
43///   - Linux: `$XDG_STATE_HOME/wire/sessions/` (typically
44///     `~/.local/state/wire/sessions/`).
45///   - macOS / other Unix without XDG: falls back to
46///     `dirs::data_local_dir() / wire / sessions /`, which on macOS is
47///     `~/Library/Application Support/wire/sessions/`. This mirrors
48///     `config::state_dir`'s fallback so the two surfaces resolve to
49///     compatible roots on every platform.
50pub fn sessions_root() -> Result<PathBuf> {
51    if let Ok(home_str) = std::env::var("WIRE_HOME") {
52        let home = PathBuf::from(&home_str);
53        let direct = home.join("sessions");
54        if direct.exists() {
55            return Ok(direct);
56        }
57        // v0.6.4: inside-session fallback. When WIRE_HOME is set by the
58        // MCP auto-detect or `wire session env`, it points at one
59        // session's home (`<root>/sessions/<name>`) — *not* the root
60        // holding every session. Without this fallback, `wire mesh
61        // status` / `mesh role list` / `mesh broadcast` invoked from
62        // inside a session see zero sister sessions even though the
63        // operator can plainly see them with `wire session list`.
64        //
65        // The check is tight on purpose: only short-circuit when the
66        // immediate parent dir is named `sessions`. Anything else (a
67        // plain test WIRE_HOME, a custom location) keeps the v0.6.3
68        // behavior of returning `<WIRE_HOME>/sessions/` so the caller
69        // can populate it.
70        if let Some(parent) = home.parent()
71            && parent.file_name().and_then(|s| s.to_str()) == Some("sessions")
72        {
73            return Ok(parent.to_path_buf());
74        }
75        return Ok(direct);
76    }
77    let state = dirs::state_dir()
78        .or_else(dirs::data_local_dir)
79        .ok_or_else(|| {
80            anyhow!(
81                "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
82                 set WIRE_HOME or run on a platform with `dirs` support"
83            )
84        })?;
85    Ok(state.join("wire").join("sessions"))
86}
87
88/// Full filesystem path for the named session's WIRE_HOME root.
89/// Inside this dir the standard wire layout applies: `config/wire/...`
90/// and `state/wire/...`.
91pub fn session_dir(name: &str) -> Result<PathBuf> {
92    Ok(sessions_root()?.join(sanitize_name(name)))
93}
94
95/// Registry tracks `cwd → session_name` so repeated `wire session new`
96/// from the same project reuses the same identity instead of creating
97/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
98pub fn registry_path() -> Result<PathBuf> {
99    Ok(sessions_root()?.join("registry.json"))
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct SessionRegistry {
104    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
105    /// associated with a session yet.
106    #[serde(default)]
107    pub by_cwd: HashMap<String, String>,
108}
109
110pub fn read_registry() -> Result<SessionRegistry> {
111    let path = registry_path()?;
112    if !path.exists() {
113        return Ok(SessionRegistry::default());
114    }
115    let bytes =
116        std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
117    serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
118}
119
120pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
121    let path = registry_path()?;
122    if let Some(parent) = path.parent() {
123        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
124    }
125    let body = serde_json::to_vec_pretty(reg)?;
126    std::fs::write(&path, body).with_context(|| format!("writing session registry {path:?}"))?;
127    Ok(())
128}
129
130/// Sanitize an arbitrary string to a session-name-safe form: lowercase
131/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
132/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
133pub fn sanitize_name(raw: &str) -> String {
134    let mut out = String::with_capacity(raw.len());
135    let mut prev_dash = false;
136    for c in raw.chars() {
137        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
138        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
139        if ch == '-' {
140            if !prev_dash && !out.is_empty() {
141                out.push('-');
142            }
143            prev_dash = true;
144        } else {
145            out.push(ch);
146            prev_dash = false;
147        }
148    }
149    let trimmed = out.trim_matches('-').to_string();
150    if trimmed.is_empty() {
151        return "wire-session".to_string();
152    }
153    if trimmed.len() > 32 {
154        return trimmed[..32].trim_end_matches('-').to_string();
155    }
156    trimmed
157}
158
159/// Short hash suffix derived from the full absolute path of the cwd.
160/// Used to disambiguate two different projects whose basenames collide
161/// (e.g. `~/Source/wire` and `~/Archive/wire`).
162fn path_hash_suffix(cwd: &Path) -> String {
163    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
164    let mut h = Sha256::new();
165    h.update(bytes.as_bytes());
166    let digest = h.finalize();
167    hex::encode(&digest[..2]) // 4 hex chars
168}
169
170/// Derive a stable session name for the given cwd. Resolution order:
171///
172/// 1. If the registry already maps this cwd → name, return that name.
173/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
174///    already mapped to a DIFFERENT cwd in the registry, append a
175///    4-char path-hash suffix to avoid collision.
176/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
177///    until unique.
178pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
179    let cwd_key = cwd.to_string_lossy().into_owned();
180    if let Some(existing) = registry.by_cwd.get(&cwd_key) {
181        return existing.clone();
182    }
183    let base = cwd
184        .file_name()
185        .and_then(|s| s.to_str())
186        .map(sanitize_name)
187        .unwrap_or_else(|| "wire-session".to_string());
188    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
189    if !occupied.contains(&base) {
190        return base;
191    }
192    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
193    if !occupied.contains(&with_hash) {
194        return with_hash;
195    }
196    // Highly unlikely (would require a SHA-256 prefix collision plus an
197    // existing entry to claim it). Numeric tiebreaker as final fallback.
198    for n in 2..1000 {
199        let candidate = format!("{base}-{n}");
200        if !occupied.contains(&candidate) {
201            return candidate;
202        }
203    }
204    // Pathological fallback — every numbered slot is taken.
205    format!("{base}-{}-overflow", path_hash_suffix(cwd))
206}
207
208/// Summary of one on-disk session for `wire session list`.
209#[derive(Debug, Clone, Serialize)]
210pub struct SessionInfo {
211    pub name: String,
212    /// First cwd associated with this session in the registry. `None`
213    /// if the session was created without registry tracking (manual
214    /// `wire session new <name>`).
215    pub cwd: Option<String>,
216    pub home_dir: PathBuf,
217    pub did: Option<String>,
218    pub handle: Option<String>,
219    /// True if a `daemon.pid` file exists AND the recorded PID is
220    /// actually a live process (best-effort, not POSIX-portable but
221    /// matches the existing `wire status` / `wire doctor` checks).
222    pub daemon_running: bool,
223}
224
225/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
226/// references the registry so each entry's `cwd` is filled in when known.
227pub fn list_sessions() -> Result<Vec<SessionInfo>> {
228    let root = sessions_root()?;
229    if !root.exists() {
230        return Ok(Vec::new());
231    }
232    let registry = read_registry().unwrap_or_default();
233    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
234    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
235    for (cwd, name) in &registry.by_cwd {
236        name_to_cwd.insert(name.clone(), cwd.clone());
237    }
238
239    let mut out = Vec::new();
240    for entry in std::fs::read_dir(&root)?.flatten() {
241        let path = entry.path();
242        if !path.is_dir() {
243            continue;
244        }
245        let name = match path.file_name().and_then(|s| s.to_str()) {
246            Some(s) => s.to_string(),
247            None => continue,
248        };
249        // Skip the registry sidecar.
250        if name == "registry.json" {
251            continue;
252        }
253        let card_path = path.join("config").join("wire").join("agent-card.json");
254        let (did, handle) = read_card_identity(&card_path);
255        let daemon_running = check_daemon_live(&path);
256        out.push(SessionInfo {
257            name: name.clone(),
258            cwd: name_to_cwd.get(&name).cloned(),
259            home_dir: path,
260            did,
261            handle,
262            daemon_running,
263        });
264    }
265    out.sort_by(|a, b| a.name.cmp(&b.name));
266    Ok(out)
267}
268
269fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
270    let bytes = match std::fs::read(card_path) {
271        Ok(b) => b,
272        Err(_) => return (None, None),
273    };
274    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
275        Ok(v) => v,
276        Err(_) => return (None, None),
277    };
278    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
279    let handle = v
280        .get("handle")
281        .and_then(|x| x.as_str())
282        .map(str::to_string)
283        .or_else(|| {
284            did.as_ref()
285                .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
286        });
287    (did, handle)
288}
289
290fn check_daemon_live(session_home: &Path) -> bool {
291    // Pidfile lives at <session_home>/state/wire/daemon.pid. Use the
292    // existing ensure_up reader by temporarily pointing at the path; we
293    // can't change env mid-process race-free, so re-implement the pid
294    // extraction directly here from the JSON structure.
295    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
296    let bytes = match std::fs::read(&pidfile) {
297        Ok(b) => b,
298        Err(_) => return false,
299    };
300    // Try the structured form first.
301    let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
302        v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
303    } else {
304        // Legacy integer form.
305        String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
306    };
307    let pid = match pid_opt {
308        Some(p) => p,
309        None => return false,
310    };
311    is_process_live(pid)
312}
313
314fn is_process_live(pid: u32) -> bool {
315    #[cfg(target_os = "linux")]
316    {
317        std::path::Path::new(&format!("/proc/{pid}")).exists()
318    }
319    #[cfg(not(target_os = "linux"))]
320    {
321        std::process::Command::new("kill")
322            .args(["-0", &pid.to_string()])
323            .output()
324            .map(|o| o.status.success())
325            .unwrap_or(false)
326    }
327}
328
329/// Read a session's `relay.json` and return its `self.endpoints[]`
330/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
331/// is a best-effort discovery helper, not a verification tool. A pre-
332/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
333/// promotes those to a federation-only Endpoint, so the result is
334/// still meaningful for legacy sessions.
335///
336/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
337/// not the canonical filename (`config::relay_state_path` returns
338/// `relay.json`). The mis-named read silently no-op'd and
339/// `list-local` always returned an empty `local` map as a result.
340/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
341/// in the same release — that helper had the symmetric write-side
342/// bug, so the local endpoint never got persisted in the first place.
343pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
344    let path = session_home.join("config").join("wire").join("relay.json");
345    let bytes = match std::fs::read(&path) {
346        Ok(b) => b,
347        Err(_) => return Vec::new(),
348    };
349    let val: Value = match serde_json::from_slice(&bytes) {
350        Ok(v) => v,
351        Err(_) => return Vec::new(),
352    };
353    self_endpoints(&val)
354}
355
356/// Stripped view of a Local endpoint for tooling output. Drops
357/// `slot_token` because it is a bearer credential — exposing it
358/// through `wire session list-local --json` would risk accidental
359/// leak via logs, screenshots, or piped output. Routing code uses
360/// the full `Endpoint` from `relay.json` directly; this type
361/// is for human/JSON observation only.
362#[derive(Debug, Clone, Serialize)]
363pub struct LocalEndpointView {
364    pub relay_url: String,
365    pub slot_id: String,
366}
367
368/// One row of `wire session list-local` output: a session that has a
369/// Local-scope endpoint plus metadata to render it.
370#[derive(Debug, Clone, Serialize)]
371pub struct LocalSessionView {
372    pub name: String,
373    pub handle: Option<String>,
374    pub did: Option<String>,
375    pub cwd: Option<String>,
376    pub home_dir: PathBuf,
377    pub daemon_running: bool,
378    /// All Local-scope endpoints this session advertises (token redacted).
379    /// Most sessions have exactly one; multiple is permitted for multi-
380    /// relay setups.
381    pub local_endpoints: Vec<LocalEndpointView>,
382}
383
384/// Sessions with no Local endpoint — shown separately so the operator
385/// knows they exist but are federation-only.
386#[derive(Debug, Clone, Serialize)]
387pub struct FederationOnlySessionView {
388    pub name: String,
389    pub handle: Option<String>,
390    pub cwd: Option<String>,
391}
392
393/// Result shape for `wire session list-local`. `local` is grouped by
394/// the local-relay URL so output can render each cluster of mutually-
395/// reachable sister sessions together. `federation_only` lists the rest.
396#[derive(Debug, Clone, Serialize)]
397pub struct LocalSessionListing {
398    pub local: HashMap<String, Vec<LocalSessionView>>,
399    pub federation_only: Vec<FederationOnlySessionView>,
400}
401
402/// Build the listing for `wire session list-local` from current on-disk
403/// state. Read-only; no daemon contact, no relay probe.
404pub fn list_local_sessions() -> Result<LocalSessionListing> {
405    let sessions = list_sessions()?;
406    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
407    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
408
409    for s in sessions {
410        let endpoints = read_session_endpoints(&s.home_dir);
411        let local_eps: Vec<Endpoint> = endpoints
412            .into_iter()
413            .filter(|e| matches!(e.scope, EndpointScope::Local))
414            .collect();
415        if local_eps.is_empty() {
416            federation_only.push(FederationOnlySessionView {
417                name: s.name.clone(),
418                handle: s.handle.clone(),
419                cwd: s.cwd.clone(),
420            });
421            continue;
422        }
423        // Redacted view: drop slot_token before exposing through CLI.
424        let redacted: Vec<LocalEndpointView> = local_eps
425            .iter()
426            .map(|e| LocalEndpointView {
427                relay_url: e.relay_url.clone(),
428                slot_id: e.slot_id.clone(),
429            })
430            .collect();
431        // Group by relay_url. A session with two Local endpoints (rare —
432        // would mean two loopback relays) appears under each.
433        for ep in &local_eps {
434            local
435                .entry(ep.relay_url.clone())
436                .or_default()
437                .push(LocalSessionView {
438                    name: s.name.clone(),
439                    handle: s.handle.clone(),
440                    did: s.did.clone(),
441                    cwd: s.cwd.clone(),
442                    home_dir: s.home_dir.clone(),
443                    daemon_running: s.daemon_running,
444                    local_endpoints: redacted.clone(),
445                });
446        }
447    }
448    // Sort each group by session name so output is deterministic.
449    for group in local.values_mut() {
450        group.sort_by(|a, b| a.name.cmp(&b.name));
451    }
452    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
453    Ok(LocalSessionListing {
454        local,
455        federation_only,
456    })
457}
458
459/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
460///
461/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
462/// registry. If a session is registered for this cwd AND its home
463/// directory still exists, return that home dir; otherwise None.
464///
465/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
466/// so a `wire whoami` / `wire monitor` invocation from a project cwd
467/// adopts that project's session identity automatically, instead of
468/// silently falling back to the machine default. The CLI parity is
469/// load-bearing: without it, the user-visible identity diverges
470/// between MCP and the terminal, and monitors pull machine-wide
471/// inboxes when the operator expected a per-session view.
472pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
473    let registry = read_registry().ok()?;
474    let cwd_str = cwd.to_string_lossy().into_owned();
475    let session_name = registry.by_cwd.get(&cwd_str)?;
476    let session_home = session_dir(session_name).ok()?;
477    if !session_home.exists() {
478        return None;
479    }
480    Some(session_home)
481}
482
483/// v0.6.10: warn at MCP/CLI startup if another `wire mcp` process is
484/// already running with the same effective `WIRE_HOME`. Closes the
485/// "two Claudes in same cwd silently share an identity" failure mode
486/// that wasted hours of operator debugging time: today the collision
487/// is invisible (both Claudes resolve to the same wire session via
488/// v0.6.7 auto-detect, race the inbox cursor, "look identical" from
489/// the operator's view). This surfaces it explicitly with a clear
490/// remediation path.
491///
492/// Best-effort: any subprocess / env-read failure is silent (the
493/// collision check should never block startup). Cross-platform via
494/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
495/// returns empty (no collision detected).
496pub fn warn_on_identity_collision(self_pid: u32) {
497    let our_wire_home = match std::env::var("WIRE_HOME") {
498        Ok(h) => h,
499        Err(_) => return,
500    };
501
502    let pgrep_out = match std::process::Command::new("pgrep")
503        .args(["-f", "wire mcp"])
504        .output()
505    {
506        Ok(o) if o.status.success() => o,
507        _ => return,
508    };
509
510    let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
511        .split_whitespace()
512        .filter_map(|s| s.parse::<u32>().ok())
513        .filter(|&p| p != self_pid)
514        .collect();
515
516    let mut colliders: Vec<u32> = Vec::new();
517    for pid in &other_pids {
518        if let Some(their_home) = read_wire_home_from_pid(*pid)
519            && their_home == our_wire_home
520        {
521            colliders.push(*pid);
522        }
523    }
524
525    if colliders.is_empty() {
526        return;
527    }
528
529    eprintln!(
530        "wire mcp: WARNING — {} other wire mcp process(es) already using WIRE_HOME=`{}` (pid {})",
531        colliders.len(),
532        our_wire_home,
533        colliders
534            .iter()
535            .map(|p| p.to_string())
536            .collect::<Vec<_>>()
537            .join(", ")
538    );
539    eprintln!(
540        "  Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
541    );
542    eprintln!("  To use a separate identity:");
543    eprintln!("    1. Close the other agent(s), OR");
544    eprintln!("    2. `wire session new <name> --local-only` to create a fresh identity, then");
545    eprintln!(
546        "    3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
547    );
548}
549
550/// Best-effort cross-platform read of another process's `WIRE_HOME`.
551/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
552/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
553/// Windows / other: returns `None` (collision detection no-ops).
554fn read_wire_home_from_pid(pid: u32) -> Option<String> {
555    #[cfg(target_os = "linux")]
556    {
557        let path = format!("/proc/{pid}/environ");
558        let bytes = std::fs::read(&path).ok()?;
559        for entry in bytes.split(|&b| b == 0) {
560            let s = match std::str::from_utf8(entry) {
561                Ok(s) => s,
562                Err(_) => continue,
563            };
564            if let Some(val) = s.strip_prefix("WIRE_HOME=") {
565                return Some(val.to_string());
566            }
567        }
568        None
569    }
570
571    #[cfg(target_os = "macos")]
572    {
573        let output = std::process::Command::new("ps")
574            .args(["-E", "-p", &pid.to_string(), "-o", "command="])
575            .output()
576            .ok()?;
577        let s = String::from_utf8_lossy(&output.stdout);
578        for tok in s.split_whitespace() {
579            if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
580                return Some(val.to_string());
581            }
582        }
583        None
584    }
585
586    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
587    {
588        let _ = pid;
589        None
590    }
591}
592
593/// v0.6.7: apply `detect_session_wire_home` for the current process.
594///
595/// If `WIRE_HOME` is unset and the current cwd maps to an existing
596/// session, set `WIRE_HOME` for the rest of this process and emit a
597/// one-liner to stderr so the operator knows which identity is in
598/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
599///
600/// `label` distinguishes the caller in the stderr line (`mcp` vs
601/// `cli`). Set `WIRE_QUIET_AUTOSESSION=1` to suppress the stderr line
602/// while keeping the env-var application active.
603///
604/// MUST be called BEFORE any worker thread or async task spawns —
605/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
606/// guarantees, and our use is safe only at process entry.
607pub fn maybe_adopt_session_wire_home(label: &str) {
608    if std::env::var("WIRE_HOME").is_ok() {
609        return;
610    }
611    let cwd = match std::env::current_dir() {
612        Ok(c) => c,
613        Err(_) => return,
614    };
615    let home = match detect_session_wire_home(&cwd) {
616        Some(h) => h,
617        None => return,
618    };
619    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
620        eprintln!(
621            "wire {label}: auto-detected session for cwd `{}` → WIRE_HOME=`{}`",
622            cwd.display(),
623            home.display()
624        );
625    }
626    // SAFETY: caller contract is "before any thread spawn." All
627    // production sites (cli::run, mcp::run) call this as the first
628    // step in their respective entry points.
629    unsafe {
630        std::env::set_var("WIRE_HOME", &home);
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn sanitize_handles_unicode_and_long_names() {
640        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
641        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
642        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
643        assert_eq!(sanitize_name(""), "wire-session");
644        assert_eq!(sanitize_name("---"), "wire-session");
645        let long: String = "a".repeat(100);
646        assert_eq!(sanitize_name(&long).len(), 32);
647    }
648
649    #[test]
650    fn derive_name_returns_basename_when_no_collision() {
651        let reg = SessionRegistry::default();
652        assert_eq!(
653            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
654            "wire"
655        );
656        assert_eq!(
657            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
658            "slancha-mesh"
659        );
660    }
661
662    #[test]
663    fn derive_name_returns_stored_name_when_cwd_already_registered() {
664        let mut reg = SessionRegistry::default();
665        reg.by_cwd.insert(
666            "/Users/paul/Source/wire".to_string(),
667            "wire-special".to_string(),
668        );
669        assert_eq!(
670            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
671            "wire-special"
672        );
673    }
674
675    #[test]
676    fn read_session_endpoints_handles_missing_relay_state() {
677        let tmp = tempfile::tempdir().unwrap();
678        // No relay.json under <home>/config/wire/ — should yield empty.
679        let endpoints = read_session_endpoints(tmp.path());
680        assert!(endpoints.is_empty());
681    }
682
683    #[test]
684    fn read_session_endpoints_parses_dual_slot_form() {
685        let tmp = tempfile::tempdir().unwrap();
686        let cfg = tmp.path().join("config").join("wire");
687        std::fs::create_dir_all(&cfg).unwrap();
688        let body = serde_json::json!({
689            "self": {
690                "relay_url": "https://wireup.net",
691                "slot_id": "fed-slot",
692                "slot_token": "fed-tok",
693                "endpoints": [
694                    {
695                        "relay_url": "https://wireup.net",
696                        "slot_id": "fed-slot",
697                        "slot_token": "fed-tok",
698                        "scope": "federation"
699                    },
700                    {
701                        "relay_url": "http://127.0.0.1:8771",
702                        "slot_id": "loop-slot",
703                        "slot_token": "loop-tok",
704                        "scope": "local"
705                    }
706                ]
707            }
708        });
709        std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
710        let endpoints = read_session_endpoints(tmp.path());
711        assert_eq!(endpoints.len(), 2);
712        let local_count = endpoints
713            .iter()
714            .filter(|e| matches!(e.scope, EndpointScope::Local))
715            .count();
716        assert_eq!(local_count, 1);
717        let local = endpoints
718            .iter()
719            .find(|e| matches!(e.scope, EndpointScope::Local))
720            .unwrap();
721        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
722        assert_eq!(local.slot_id, "loop-slot");
723    }
724
725    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
726    // using a subprocess that sets WIRE_HOME per-process. We do not test
727    // it in-module because env mutation races other parallel unit tests
728    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
729    // grouping logic is straightforward enough that the integration
730    // test plus the read_session_endpoints unit tests above provide
731    // adequate coverage.
732
733    #[test]
734    fn derive_name_appends_path_hash_when_basename_collides() {
735        let mut reg = SessionRegistry::default();
736        reg.by_cwd
737            .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
738        // Different cwd, same basename → must get a hash suffix.
739        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
740        assert!(name.starts_with("wire-"));
741        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
742        assert_ne!(name, "wire");
743    }
744}