Skip to main content

capo_agent/session/
id.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3//! Session ID + cwd encoding.
4//!
5//! Matches pi's `migrations.ts`: cwd is converted to a single safe path
6//! component by stripping the leading slash, replacing `/`, `\`, `:` with
7//! `-`, and wrapping in `--…--`. Session IDs themselves are ULIDs
8//! (lexicographically sortable; chosen over UUID because the workspace
9//! already ships the `ulid` crate and sort order simplifies "most recent"
10//! queries).
11
12use std::path::Path;
13
14/// 26-char Crockford-base32 ULID string. Newtype around `String` so the
15/// `motosan_agent_loop::SessionStore` API (which takes `&str`) can't be
16/// confused with arbitrary strings, and so we can add validation later
17/// without churning consumers.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct SessionId(String);
20
21impl SessionId {
22    /// Generate a fresh ULID-backed session id.
23    pub fn new() -> Self {
24        Self(ulid::Ulid::new().to_string())
25    }
26
27    /// Wrap an existing string. Validation is permissive here; the
28    /// upstream `FileSessionStore::validate_session_id` rejects path
29    /// traversal at append/load time.
30    pub fn from_string(s: String) -> Self {
31        Self(s)
32    }
33
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37
38    pub fn into_string(self) -> String {
39        self.0
40    }
41}
42
43impl Default for SessionId {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl std::fmt::Display for SessionId {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        write!(f, "{}", self.0)
52    }
53}
54
55/// Encode `cwd` into a single path-safe component for the per-cwd session bucket.
56///
57/// Matches pi `migrations.ts`:
58/// ```text
59/// let safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`
60/// ```
61pub fn encode_cwd(cwd: &Path) -> String {
62    let s = cwd.to_string_lossy();
63    let stripped = s.trim_start_matches(['/', '\\']);
64    let replaced: String = stripped
65        .chars()
66        .map(|c| match c {
67            '/' | '\\' | ':' => '-',
68            other => other,
69        })
70        .collect();
71    format!("--{replaced}--")
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use std::path::PathBuf;
78
79    #[test]
80    fn encode_cwd_matches_pi_examples() {
81        assert_eq!(
82            encode_cwd(&PathBuf::from("/Users/wade/proj/capo")),
83            "--Users-wade-proj-capo--"
84        );
85    }
86
87    #[test]
88    fn encode_cwd_handles_windows_drive() {
89        // pi behaviour: leading `C` survives; `:` → `-` AND `\` → `-`, so the
90        // `C:\` prefix becomes `C--` (two adjacent dashes are intentional).
91        assert_eq!(
92            encode_cwd(&PathBuf::from("C:\\Users\\wade\\proj")),
93            "--C--Users-wade-proj--"
94        );
95    }
96
97    #[test]
98    fn encode_cwd_handles_root() {
99        assert_eq!(encode_cwd(&PathBuf::from("/")), "----");
100    }
101
102    #[test]
103    fn session_id_new_is_26_chars_and_unique() {
104        let a = SessionId::new();
105        let b = SessionId::new();
106        assert_eq!(a.as_str().len(), 26);
107        assert_eq!(b.as_str().len(), 26);
108        assert_ne!(a, b);
109    }
110}