capo-agent 0.6.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! Session ID + cwd encoding.
//!
//! Matches pi's `migrations.ts`: cwd is converted to a single safe path
//! component by stripping the leading slash, replacing `/`, `\`, `:` with
//! `-`, and wrapping in `--…--`. Session IDs themselves are ULIDs
//! (lexicographically sortable; chosen over UUID because the workspace
//! already ships the `ulid` crate and sort order simplifies "most recent"
//! queries).

use std::path::Path;

/// 26-char Crockford-base32 ULID string. Newtype around `String` so the
/// `motosan_agent_loop::SessionStore` API (which takes `&str`) can't be
/// confused with arbitrary strings, and so we can add validation later
/// without churning consumers.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SessionId(String);

impl SessionId {
    /// Generate a fresh ULID-backed session id.
    pub fn new() -> Self {
        Self(ulid::Ulid::new().to_string())
    }

    /// Wrap an existing string. Validation is permissive here; the
    /// upstream `FileSessionStore::validate_session_id` rejects path
    /// traversal at append/load time.
    pub fn from_string(s: String) -> Self {
        Self(s)
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    pub fn into_string(self) -> String {
        self.0
    }
}

impl Default for SessionId {
    fn default() -> Self {
        Self::new()
    }
}

impl std::fmt::Display for SessionId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// Encode `cwd` into a single path-safe component for the per-cwd session bucket.
///
/// Matches pi `migrations.ts`:
/// ```text
/// let safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`
/// ```
pub fn encode_cwd(cwd: &Path) -> String {
    let s = cwd.to_string_lossy();
    let stripped = s.trim_start_matches(['/', '\\']);
    let replaced: String = stripped
        .chars()
        .map(|c| match c {
            '/' | '\\' | ':' => '-',
            other => other,
        })
        .collect();
    format!("--{replaced}--")
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn encode_cwd_matches_pi_examples() {
        assert_eq!(
            encode_cwd(&PathBuf::from("/Users/wade/proj/capo")),
            "--Users-wade-proj-capo--"
        );
    }

    #[test]
    fn encode_cwd_handles_windows_drive() {
        // pi behaviour: leading `C` survives; `:` → `-` AND `\` → `-`, so the
        // `C:\` prefix becomes `C--` (two adjacent dashes are intentional).
        assert_eq!(
            encode_cwd(&PathBuf::from("C:\\Users\\wade\\proj")),
            "--C--Users-wade-proj--"
        );
    }

    #[test]
    fn encode_cwd_handles_root() {
        assert_eq!(encode_cwd(&PathBuf::from("/")), "----");
    }

    #[test]
    fn session_id_new_is_26_chars_and_unique() {
        let a = SessionId::new();
        let b = SessionId::new();
        assert_eq!(a.as_str().len(), 26);
        assert_eq!(b.as_str().len(), 26);
        assert_ne!(a, b);
    }
}