Skip to main content

packset_core/
identity.rs

1//! Workspace naming: same human, same repository, every client.
2//!
3//! The default workspace is the normalized git remote, so two harnesses in one
4//! checkout agree without either being told. Asking git for the remote is I/O
5//! and belongs to the caller; everything here is a pure function of the string
6//! it is handed.
7
8/// Schema name for an identity record.
9pub const SCHEMA: &str = "inside.identity/v1";
10
11/// How a workspace name is chosen.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Strategy {
14    /// The normalized git remote, falling back to the directory.
15    PerRepo,
16    /// The resolved working directory.
17    PerDirectory,
18    /// One named session.
19    PerSession,
20    /// One name for the seat.
21    Global,
22}
23
24impl Strategy {
25    /// Parse a strategy name from the wire.
26    ///
27    /// # Errors
28    ///
29    /// Returns the unrecognised name.
30    pub fn parse(name: &str) -> Result<Self, String> {
31        match name {
32            "per-repo" => Ok(Self::PerRepo),
33            "per-directory" => Ok(Self::PerDirectory),
34            "per-session" => Ok(Self::PerSession),
35            "global" => Ok(Self::Global),
36            other => Err(format!("unknown workspace_strategy: {other}")),
37        }
38    }
39
40    /// The wire name.
41    #[must_use]
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::PerRepo => "per-repo",
45            Self::PerDirectory => "per-directory",
46            Self::PerSession => "per-session",
47            Self::Global => "global",
48        }
49    }
50}
51
52/// A git remote as a workspace name.
53///
54/// `https://host/owner/repo.git`, `git@host:owner/repo.git` and
55/// `ssh://git@host/owner/repo` all land on `git:host/owner/repo`, which is what
56/// makes two clients in one checkout agree.
57///
58/// # Errors
59///
60/// Returns a message when the string is empty or is not a remote shape.
61pub fn normalize_remote(url: &str) -> Result<String, String> {
62    let raw = url.trim();
63    if raw.is_empty() {
64        return Err("empty git remote".into());
65    }
66    for scheme in ["http://", "https://", "ssh://"] {
67        if let Some(rest) = raw.strip_prefix(scheme) {
68            let rest = rest.split_once('@').map_or(rest, |(_, after)| after);
69            let (host, path) = rest.split_once('/').ok_or_else(|| bad(raw))?;
70            let host = host.split_once(':').map_or(host, |(h, _)| h);
71            return Ok(format!(
72                "git:{host}/{}",
73                path.trim_start_matches('/').trim_end_matches(".git")
74            ));
75        }
76    }
77    // scp-like: [user@]host:path
78    let rest = raw.split_once('@').map_or(raw, |(_, after)| after);
79    let (host, path) = rest
80        .split_once(':')
81        .or_else(|| rest.split_once('/'))
82        .ok_or_else(|| bad(raw))?;
83    if host.is_empty() || path.is_empty() {
84        return Err(bad(raw));
85    }
86    Ok(format!("git:{host}/{}", path.trim_end_matches(".git")))
87}
88
89fn bad(raw: &str) -> String {
90    format!("unrecognized git remote: {raw}")
91}
92
93/// A workspace name as a directory component.
94///
95/// Every run of anything but a letter, digit, dot, underscore or hyphen becomes
96/// one underscore, so `git:github.com/HaoZeke/vissue` names a directory without
97/// nesting one.
98#[must_use]
99pub fn workspace_slug(workspace: &str) -> String {
100    let mut out = String::with_capacity(workspace.len());
101    let mut pending_underscore = false;
102    for ch in workspace.chars() {
103        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
104            if pending_underscore && !out.is_empty() {
105                out.push('_');
106            }
107            pending_underscore = false;
108            out.push(ch);
109        } else {
110            pending_underscore = true;
111        }
112    }
113    let trimmed = out.trim_matches('_');
114    if trimmed.is_empty() {
115        "workspace".into()
116    } else {
117        trimmed.to_string()
118    }
119}
120
121/// The peer name for one harness, with a non-default profile appended.
122///
123/// # Errors
124///
125/// Returns a message when the harness name is empty.
126pub fn agent_peer(harness: &str, profile: Option<&str>) -> Result<String, String> {
127    let name = harness.trim();
128    if name.is_empty() {
129        return Err("harness is required".into());
130    }
131    match profile.map(str::trim) {
132        Some(p) if !p.is_empty() && p != "default" => Ok(format!("{name}.{p}")),
133        _ => Ok(name.to_string()),
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn https_and_ssh_land_on_the_same_workspace() {
143        let https = normalize_remote("https://github.com/HaoZeke/grok-inside.git").unwrap();
144        let ssh = normalize_remote("git@github.com:HaoZeke/grok-inside.git").unwrap();
145        assert_eq!(https, "git:github.com/HaoZeke/grok-inside");
146        assert_eq!(ssh, https);
147    }
148
149    #[test]
150    fn the_ssh_scheme_form_agrees_too() {
151        assert_eq!(
152            normalize_remote("ssh://git@github.com/HaoZeke/vissue.git").unwrap(),
153            "git:github.com/HaoZeke/vissue"
154        );
155    }
156
157    #[test]
158    fn a_port_is_not_part_of_the_host() {
159        assert_eq!(
160            normalize_remote("https://example.com:8443/team/repo.git").unwrap(),
161            "git:example.com/team/repo"
162        );
163    }
164
165    #[test]
166    fn an_empty_or_shapeless_remote_is_refused() {
167        assert!(normalize_remote("").is_err());
168        assert!(normalize_remote("   ").is_err());
169        assert!(normalize_remote("just-a-word").is_err());
170    }
171
172    #[test]
173    fn a_slug_is_one_directory_component() {
174        assert_eq!(
175            workspace_slug("git:github.com/HaoZeke/vissue"),
176            "git_github.com_HaoZeke_vissue"
177        );
178        assert_eq!(workspace_slug("global"), "global");
179        assert_eq!(workspace_slug("///"), "workspace");
180        assert_eq!(workspace_slug(""), "workspace");
181    }
182
183    #[test]
184    fn a_profile_shows_only_when_it_is_not_the_default() {
185        assert_eq!(agent_peer("hermes", None).unwrap(), "hermes");
186        assert_eq!(agent_peer("hermes", Some("default")).unwrap(), "hermes");
187        assert_eq!(agent_peer("hermes", Some("")).unwrap(), "hermes");
188        assert_eq!(agent_peer("hermes", Some("work")).unwrap(), "hermes.work");
189        assert!(agent_peer("  ", None).is_err());
190    }
191
192    #[test]
193    fn strategy_names_round_trip() {
194        for name in ["per-repo", "per-directory", "per-session", "global"] {
195            assert_eq!(Strategy::parse(name).unwrap().as_str(), name);
196        }
197        assert!(Strategy::parse("per-mood").is_err());
198    }
199}