Skip to main content

kaptein_viewmodel/
versioned.rs

1//! Contract-version enforcement (see `docs/versioning.md`).
2//!
3//! Kaptein has three independently-versioned contracts — the **MCP tool schema**, the
4//! **lens** (view-definition) schema, and the **WIT** worlds. Each carries its own
5//! `api_version`/schema version, bumped independently on a breaking change to *that*
6//! contract. A release must **refuse to load a plugin, lens, or MCP client whose
7//! version it does not support**, with a clear migration error — never silently break.
8//!
9//! This module is wasm-pure (no `kube`/`tokio`), so the browser UI and the headless
10//! agent share the exact same compatibility rule as the native frontends.
11
12use std::fmt;
13
14use serde::{Deserialize, Serialize};
15
16/// A contract's `api_version`/schema version, e.g. `v1` or `2`.
17///
18/// Parsed from strings like `"1"`, `"v1"`, `"1.2"`, or `"v1.2"`. Semantics follow
19/// `docs/versioning.md`: the **major** identifies a breaking contract generation;
20/// additive changes bump the **minor** without breaking compatibility.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub struct ApiVersion {
23    pub major: u32,
24    pub minor: u32,
25}
26
27impl ApiVersion {
28    pub const fn new(major: u32, minor: u32) -> Self {
29        Self { major, minor }
30    }
31}
32
33impl fmt::Display for ApiVersion {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        if self.minor == 0 {
36            write!(f, "v{}", self.major)
37        } else {
38            write!(f, "v{}.{}", self.major, self.minor)
39        }
40    }
41}
42
43/// Parse an `api_version` string such as `"1"`, `"v1"`, `"1.2"`, or `"v1.2"`.
44///
45/// Returns `None` for a malformed or empty version.
46pub fn parse_api_version(s: &str) -> Option<ApiVersion> {
47    let s = s.trim().strip_prefix('v').unwrap_or(s.trim());
48    if s.is_empty() {
49        return None;
50    }
51    let mut parts = s.split('.');
52    let major: u32 = parts.next()?.parse().ok()?;
53    let minor: u32 = parts.next().map(|p| p.parse().unwrap_or(0)).unwrap_or(0);
54    if parts.next().is_some() {
55        return None; // too many components
56    }
57    Some(ApiVersion { major, minor })
58}
59
60/// Whether a client/plugin/lens requesting `requested` is supported by a release
61/// implementing `supported`.
62///
63/// Compatibility is **same major**: additive (minor) changes are compatible, a major
64/// bump is a breaking contract change and is refused with a migration error.
65pub fn is_compatible(supported: ApiVersion, requested: ApiVersion) -> bool {
66    requested.major == supported.major
67}
68
69/// The Kaptein MCP tool-schema contract version. Bump the **major** on an incompatible
70/// tool change (a renamed/removed tool or a changed required argument), per
71/// `docs/versioning.md`; bump the **minor** for an additive tool or optional argument.
72pub const MCP_API_VERSION: ApiVersion = ApiVersion::new(1, 0);
73
74/// The `_meta` key under which the MCP client declares the contract version it speaks.
75pub const MCP_VERSION_META_KEY: &str = "io.kaptein/apiVersion";
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn parse_accepts_forms() {
83        assert_eq!(parse_api_version("1"), Some(ApiVersion::new(1, 0)));
84        assert_eq!(parse_api_version("v1"), Some(ApiVersion::new(1, 0)));
85        assert_eq!(parse_api_version("1.2"), Some(ApiVersion::new(1, 2)));
86        assert_eq!(parse_api_version("v1.2"), Some(ApiVersion::new(1, 2)));
87        assert_eq!(parse_api_version("  v2  "), Some(ApiVersion::new(2, 0)));
88    }
89
90    #[test]
91    fn parse_rejects_malformed() {
92        assert_eq!(parse_api_version(""), None);
93        assert_eq!(parse_api_version("v"), None);
94        assert_eq!(parse_api_version("abc"), None);
95        assert_eq!(parse_api_version("1.2.3"), None);
96        assert_eq!(parse_api_version("v1.2.3"), None);
97    }
98
99    #[test]
100    fn same_major_is_compatible() {
101        assert!(is_compatible(ApiVersion::new(1, 0), ApiVersion::new(1, 9)));
102        assert!(is_compatible(ApiVersion::new(1, 9), ApiVersion::new(1, 0)));
103    }
104
105    #[test]
106    fn different_major_is_incompatible() {
107        assert!(!is_compatible(ApiVersion::new(1, 0), ApiVersion::new(2, 0)));
108        assert!(!is_compatible(ApiVersion::new(2, 0), ApiVersion::new(1, 0)));
109    }
110
111    #[test]
112    fn display_omits_zero_minor() {
113        assert_eq!(ApiVersion::new(1, 0).to_string(), "v1");
114        assert_eq!(ApiVersion::new(1, 2).to_string(), "v1.2");
115    }
116}