1pub const SCHEMA: &str = "inside.identity/v1";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Strategy {
14 PerRepo,
16 PerDirectory,
18 PerSession,
20 Global,
22}
23
24impl Strategy {
25 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 #[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
52pub 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 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#[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
121pub 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}