1pub struct BundledKind {
18 pub name: &'static str,
20 pub version: &'static str,
22 pub uri: &'static str,
24 pub schema: &'static str,
26}
27
28pub const BUNDLED_KINDS: &[BundledKind] = &[
33 BundledKind {
34 name: "agent-coding-session",
35 version: "v1.0.0",
36 uri: toolpath::v1::PATH_KIND_AGENT_CODING_SESSION_V1_0_0,
37 schema: include_str!("../kinds/agent-coding-session/v1.0.0/schema.json"),
38 },
39 BundledKind {
40 name: "agent-coding-session",
41 version: "v1.1.0",
42 uri: toolpath::v1::PATH_KIND_AGENT_CODING_SESSION,
43 schema: include_str!("../kinds/agent-coding-session/v1.1.0/schema.json"),
44 },
45];
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub struct Version {
50 pub major: u64,
51 pub minor: u64,
52 pub patch: u64,
53}
54
55fn parse_version(s: &str) -> Option<Version> {
56 let s = s.strip_prefix('v').unwrap_or(s);
57 let mut it = s.split('.');
58 let major = it.next()?.parse().ok()?;
59 let minor = it.next()?.parse().ok()?;
60 let patch = it.next()?.parse().ok()?;
61 if it.next().is_some() {
62 return None;
63 }
64 Some(Version {
65 major,
66 minor,
67 patch,
68 })
69}
70
71pub fn parse_kind_uri(uri: &str) -> Option<(String, Version)> {
75 let tail = uri.rsplit_once("/kinds/").map_or(uri, |(_, t)| t);
76 let tail = tail.trim_end_matches('/');
77 let (name, ver) = tail.rsplit_once('/')?;
78 let version = parse_version(ver)?;
79 Some((name.to_string(), version))
80}
81
82#[derive(Debug, Clone)]
84pub struct KindSelector {
85 name: String,
86 major: Option<u64>,
87 minor: Option<u64>,
88 patch: Option<u64>,
89 impossible: bool,
93}
94
95fn parse_seg(part: Option<&str>, impossible: &mut bool) -> Option<u64> {
98 match part {
99 None => None,
100 Some(s) => match s.parse() {
101 Ok(n) => Some(n),
102 Err(_) => {
103 *impossible = true;
104 None
105 }
106 },
107 }
108}
109
110pub fn parse_kind_selector(s: &str) -> KindSelector {
119 let tail = s.rsplit_once("/kinds/").map_or(s, |(_, t)| t);
120 let tail = tail.trim_end_matches('/');
121 match tail.split_once('/') {
122 None => KindSelector {
123 name: tail.to_string(),
124 major: None,
125 minor: None,
126 patch: None,
127 impossible: false,
128 },
129 Some((name, ver)) => {
130 let ver = ver.strip_prefix('v').unwrap_or(ver);
131 let mut parts = ver.split('.');
132 let mut impossible = false;
133 let major = parse_seg(parts.next(), &mut impossible);
134 let minor = parse_seg(parts.next(), &mut impossible);
135 let patch = parse_seg(parts.next(), &mut impossible);
136 if parts.next().is_some() {
137 impossible = true; }
139 KindSelector {
140 name: name.to_string(),
141 major,
142 minor,
143 patch,
144 impossible,
145 }
146 }
147 }
148}
149
150impl KindSelector {
151 pub fn matches(&self, name: &str, v: Version) -> bool {
153 !self.impossible
154 && self.name == name
155 && self.major.is_none_or(|m| m == v.major)
156 && self.minor.is_none_or(|m| m == v.minor)
157 && self.patch.is_none_or(|p| p == v.patch)
158 }
159
160 pub fn matches_uri(&self, uri: &str) -> bool {
163 parse_kind_uri(uri).is_some_and(|(name, v)| self.matches(&name, v))
164 }
165}
166
167pub fn resolve(selector: &str) -> Option<&'static BundledKind> {
169 let sel = parse_kind_selector(selector);
170 BUNDLED_KINDS
171 .iter()
172 .filter(|k| parse_kind_uri(k.uri).is_some_and(|(name, v)| sel.matches(&name, v)))
173 .max_by_key(|k| parse_kind_uri(k.uri).map(|(_, v)| v))
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn v(major: u64, minor: u64, patch: u64) -> Version {
181 Version {
182 major,
183 minor,
184 patch,
185 }
186 }
187
188 #[test]
189 fn bundled_kind_uris_parse() {
190 for k in BUNDLED_KINDS {
191 let (name, ver) = parse_kind_uri(k.uri).expect("bundled URI parses");
192 assert_eq!(name, k.name);
193 assert_eq!(
194 format!("v{}.{}.{}", ver.major, ver.minor, ver.patch),
195 k.version
196 );
197 }
198 }
199
200 #[test]
206 fn bundled_kinds_are_internally_consistent() {
207 for k in BUNDLED_KINDS {
208 let ctx = format!("{}/{}", k.name, k.version);
209 let schema: serde_json::Value =
210 serde_json::from_str(k.schema).expect("bundled schema is valid JSON");
211 assert_eq!(
212 schema["$id"],
213 serde_json::json!(format!("{}/schema.json", k.uri)),
214 "{ctx}: schema `$id` must be the entry URI plus `/schema.json`"
215 );
216 assert_eq!(
217 schema["properties"]["meta"]["properties"]["kind"]["const"],
218 serde_json::json!(k.uri),
219 "{ctx}: schema's `meta.kind` const must equal the entry URI"
220 );
221 let (name, ver) = parse_kind_uri(k.uri).expect("bundled URI parses");
222 assert_eq!(name, k.name, "{ctx}: URI name segment");
223 assert_eq!(
224 format!("v{}.{}.{}", ver.major, ver.minor, ver.patch),
225 k.version,
226 "{ctx}: URI version segment"
227 );
228 let on_disk = std::fs::read_to_string(format!(
232 "{}/kinds/{}/{}/schema.json",
233 env!("CARGO_MANIFEST_DIR"),
234 k.name,
235 k.version
236 ))
237 .expect("schema file exists under kinds/<name>/<version>/");
238 assert_eq!(
239 on_disk, k.schema,
240 "{ctx}: bundled schema must be included from its version directory"
241 );
242 }
243
244 let newest = BUNDLED_KINDS.last().expect("at least one bundled kind");
245 assert_eq!(newest.uri, toolpath::v1::PATH_KIND_AGENT_CODING_SESSION);
246 }
247
248 #[test]
249 fn parse_uri_extracts_name_and_version() {
250 let (name, ver) =
251 parse_kind_uri("https://toolpath.net/kinds/agent-coding-session/v1.1.0").unwrap();
252 assert_eq!(name, "agent-coding-session");
253 assert_eq!(ver, v(1, 1, 0));
254 }
255
256 #[test]
257 fn parse_uri_rejects_unversioned() {
258 assert!(parse_kind_uri("https://toolpath.net/kinds/agent-coding-session").is_none());
259 }
260
261 #[test]
262 fn bare_name_matches_any_version() {
263 let sel = parse_kind_selector("agent-coding-session");
264 assert!(sel.matches("agent-coding-session", v(1, 0, 0)));
265 assert!(sel.matches("agent-coding-session", v(2, 5, 9)));
266 assert!(!sel.matches("other-kind", v(1, 0, 0)));
267 }
268
269 #[test]
270 fn major_prefix_keeps_v1_and_v10_distinct() {
271 let sel = parse_kind_selector("agent-coding-session/v1");
272 assert!(sel.matches("agent-coding-session", v(1, 0, 0)));
273 assert!(sel.matches("agent-coding-session", v(1, 9, 0)));
274 assert!(!sel.matches("agent-coding-session", v(10, 0, 0)));
275 }
276
277 #[test]
278 fn minor_prefix_pins_major_minor() {
279 let sel = parse_kind_selector("agent-coding-session/v1.0");
280 assert!(sel.matches("agent-coding-session", v(1, 0, 0)));
281 assert!(sel.matches("agent-coding-session", v(1, 0, 9)));
282 assert!(!sel.matches("agent-coding-session", v(1, 1, 0)));
283 }
284
285 #[test]
286 fn full_triple_matches_exactly() {
287 let sel = parse_kind_selector("agent-coding-session/v1.0.0");
288 assert!(sel.matches("agent-coding-session", v(1, 0, 0)));
289 assert!(!sel.matches("agent-coding-session", v(1, 0, 1)));
290 }
291
292 #[test]
293 fn v_prefix_is_optional() {
294 let sel = parse_kind_selector("agent-coding-session/1.0");
295 assert!(sel.matches("agent-coding-session", v(1, 0, 0)));
296 }
297
298 #[test]
299 fn full_uri_selector_matches_exactly() {
300 let sel = parse_kind_selector("https://toolpath.net/kinds/agent-coding-session/v1.0.0");
301 assert!(sel.matches("agent-coding-session", v(1, 0, 0)));
302 assert!(!sel.matches("agent-coding-session", v(1, 1, 0)));
303 }
304
305 #[test]
306 fn resolve_picks_newest_for_bare_name() {
307 let k = resolve("agent-coding-session").expect("bundled");
308 assert_eq!(k.uri, toolpath::v1::PATH_KIND_AGENT_CODING_SESSION);
309 }
310
311 #[test]
312 fn resolve_pins_exact_version() {
313 let k = resolve("agent-coding-session/v1.0.0").expect("bundled");
314 assert_eq!(k.version, "v1.0.0");
315 }
316
317 #[test]
318 fn resolve_unknown_is_none() {
319 assert!(resolve("no-such-kind").is_none());
320 }
321
322 #[test]
323 fn unparseable_version_fails_closed_not_open() {
324 for bad in [
327 "agent-coding-session/vgarbage",
328 "agent-coding-session/v1.x",
329 "agent-coding-session/v1.2.3.4",
330 ] {
331 let sel = parse_kind_selector(bad);
332 assert!(
333 !sel.matches("agent-coding-session", v(1, 1, 0)),
334 "`{bad}` must not match"
335 );
336 assert!(
337 resolve(bad).is_none(),
338 "`{bad}` must not resolve to a schema"
339 );
340 }
341 }
342}