protocol_droid/domain/acp/implementation.rs
1//! `Implementation` — the `clientInfo` / `agentInfo` block
2//! the editor and the agent exchange in `initialize`.
3//!
4//! The schema's source of truth is
5//! `crates/protocol-droid-schema/spec/acp/v1.25.0/schema.json`
6//! under `$defs.Implementation`. The shape:
7//!
8//! - `name`: programmatic identifier (required)
9//! - `title`: human-readable display name (optional)
10//! - `version`: semver string (required)
11//! - `_meta`: per-implementation metadata (optional,
12//! open shape, kept as a JSON `Value` for forward
13//! compatibility)
14//!
15//! # Why a `BTreeMap` for `_meta`
16//!
17//! The schema's `_meta` is an open object with
18//! `additionalProperties: true`. A typed
19//! `BTreeMap<String, serde_json::Value>` is the closest
20//! the Rust type system can get to the wire form
21//! without losing the JSON's "anything goes" semantics.
22//! The map keeps the wire form deterministic
23//! (alphabetical by key) so test fixtures stay readable.
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29/// The `clientInfo` / `agentInfo` block. The same shape
30/// on both sides of the wire, mirrored by the schema.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Implementation {
34 /// Programmatic identifier. Required, non-empty.
35 /// Convention is reverse-DNS, e.g. `com.example.client`.
36 pub name: String,
37 /// Human-readable display name. Optional; UI surfaces
38 /// fall back to `name` when `title` is absent.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub title: Option<String>,
41 /// Semver-form version string. Required, non-empty.
42 /// The schema does not validate the semver shape; the
43 /// build's own `protocol_droid::VERSION` is the
44 /// canonical source for protocol-droid itself.
45 pub version: String,
46 /// Per-implementation metadata. The schema's `_meta`
47 /// is open (`additionalProperties: true`), so the
48 /// typed form is a JSON `Value` map. The map keeps
49 /// the wire form deterministic.
50 #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
51 pub meta: BTreeMap<String, serde_json::Value>,
52}
53
54impl Implementation {
55 /// A `protocol-droid`-flavored `Implementation` for the
56 /// build the consumer is running. The version string
57 /// is the crate's own `CARGO_PKG_VERSION`; the name
58 /// is `protocol-droid-client` / `protocol-droid-agent`
59 /// so a wire log line carries the implementation.
60 pub fn protocol_droid(name: &str, version: &str) -> Self {
61 Self {
62 name: name.to_owned(),
63 title: Some("protocol-droid".to_owned()),
64 version: version.to_owned(),
65 meta: BTreeMap::new(),
66 }
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 /// The test error type. The `serde_json` calls
75 /// below are infallible for a well-formed value
76 /// but the test function must return `Result` to
77 /// satisfy the project's no-`expect` rule.
78 #[derive(Debug)]
79 #[expect(
80 dead_code,
81 reason = "the test only constructs the error variant on failure; the field is read by the Debug derive"
82 )]
83 enum Failure {
84 Json(serde_json::Error),
85 }
86
87 impl From<serde_json::Error> for Failure {
88 fn from(error: serde_json::Error) -> Self {
89 Self::Json(error)
90 }
91 }
92
93 /// The wire form is `camelCase` for `clientInfo`
94 /// and `agentInfo` themselves but the inner
95 /// `name`/`title`/`version`/`_meta` keys are
96 /// the schema's own (no `camelCase` rewrite
97 /// because they are already lowercase). The
98 /// rename rule applies to the *struct*'s
99 /// field renames, not the inner field names.
100 #[test]
101 fn the_inner_field_names_are_already_lowercase() -> Result<(), Failure> {
102 let info = Implementation::protocol_droid("test-client", "0.1.0");
103 let value = serde_json::to_value(&info)?;
104 let name = value.get("name").and_then(serde_json::Value::as_str);
105 let title = value.get("title").and_then(serde_json::Value::as_str);
106 let version = value.get("version").and_then(serde_json::Value::as_str);
107 assert_eq!(name, Some("test-client"));
108 assert_eq!(title, Some("protocol-droid"));
109 assert_eq!(version, Some("0.1.0"));
110 assert!(
111 value.get("_meta").is_none(),
112 "empty _meta is omitted from the wire"
113 );
114 Ok(())
115 }
116
117 /// A round-trip through `Serialize` →
118 /// `Deserialize` produces an equal value. The
119 /// `_meta` field carries JSON `Value`s, so the
120 /// round-trip is by-value, not by-reference.
121 #[test]
122 fn serialize_then_deserialize_round_trips() -> Result<(), Failure> {
123 let mut info = Implementation::protocol_droid("test", "0.1.0");
124 info.meta
125 .insert("k".to_owned(), serde_json::json!({"nested": 42}));
126 let json = serde_json::to_string(&info)?;
127 let round_tripped: Implementation = serde_json::from_str(&json)?;
128 assert_eq!(round_tripped, info);
129 Ok(())
130 }
131}