Skip to main content

path_cli/
kinds.rs

1//! Bundled document-kind specs and semver-prefix matching.
2//!
3//! The binary ships a copy of every kind spec it knows about so that
4//! `path kind` and `path query --kind` work offline. [`BUNDLED_KINDS`] is the
5//! single source of truth for which `(name, version)` specs are baked in;
6//! the `schema` module (kind-aware validation) and the query layer both read
7//! it.
8//!
9//! A `meta.kind` value is a semver-versioned URI of the form
10//! `…/kinds/<name>/v<major>.<minor>.<patch>`. A [`KindSelector`] matches a
11//! *prefix* of `(name, major, minor, patch)`: a bare name matches any version,
12//! `v1` matches `v1.*.*`, `v1.0` matches `v1.0.*`, and a full triple matches
13//! exactly. Matching compares parsed integer tuples, so `v1` matches `v1.9.0`
14//! but keeps `v10.0.0` separate.
15
16/// A kind spec compiled into the binary.
17pub struct BundledKind {
18    /// Kind name, e.g. `agent-coding-session`.
19    pub name: &'static str,
20    /// Version segment, e.g. `v1.1.0`.
21    pub version: &'static str,
22    /// Full `meta.kind` URI this spec answers to.
23    pub uri: &'static str,
24    /// The bundled `schema.json` source.
25    pub schema: &'static str,
26}
27
28/// Every kind spec baked into the binary, oldest version first.
29///
30/// The `schema.json` files live at `crates/path-cli/kinds/<name>/<version>/`
31/// and publish under `https://toolpath.net/kinds/`.
32pub 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/// A parsed `(major, minor, patch)` version triple.
48#[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
71/// Parse a full `meta.kind` URI into its `(name, version)`.
72///
73/// Returns `None` if it doesn't look like `…/<name>/v<major>.<minor>.<patch>`.
74pub 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/// A `--kind` / `path kind` selector: a name plus an optional version prefix.
83#[derive(Debug, Clone)]
84pub struct KindSelector {
85    name: String,
86    major: Option<u64>,
87    minor: Option<u64>,
88    patch: Option<u64>,
89    /// A version segment was present but didn't parse as an integer (e.g. a
90    /// typo like `/vgarbage` or `/v1.x`). Such a selector matches nothing —
91    /// failing *closed* rather than silently widening to "any version".
92    impossible: bool,
93}
94
95/// Parse a version segment: absent → `None` (wildcard); present and numeric →
96/// `Some(n)`; present but non-numeric → `None` and flag the selector impossible.
97fn 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
110/// Parse a selector. Accepts a bare name (`agent-coding-session`), a
111/// name + version prefix (`agent-coding-session/v1`, `.../v1.0`,
112/// `.../v1.0.0`, with the `v` optional), or a full `meta.kind` URI.
113///
114/// A version part that's present but unparseable does **not** fail open: the
115/// selector is marked impossible and matches nothing, so a typo'd pin fails
116/// closed (and `path kind` reports "no bundled spec") rather than silently
117/// widening to every version.
118pub 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; // more than major.minor.patch
138            }
139            KindSelector {
140                name: name.to_string(),
141                major,
142                minor,
143                patch,
144                impossible,
145            }
146        }
147    }
148}
149
150impl KindSelector {
151    /// Whether this selector matches a parsed `(name, version)`.
152    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    /// Whether this selector matches a `meta.kind` URI. A URI that doesn't
161    /// parse as a versioned kind never matches.
162    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
167/// Resolve a selector to the newest bundled kind spec it matches.
168pub 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    /// Registration glue and schema internals must move together: each
201    /// entry's URI, its schema's `$id` and `meta.kind` const, and the
202    /// `kinds/<name>/<version>/` directory the schema is included from must
203    /// all name the same version, and the newest bundled URI must be the one
204    /// production stamps on derived paths.
205    #[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            // `schema` is a compile-time include; require it byte-identical
229            // to the file in the directory named by `version`, so the include
230            // path can't point at a different version than the entry claims.
231            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        // A typo'd version pin must match nothing — never silently widen to
325        // "any version". (Regression for the fail-open selector.)
326        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}