1use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(transparent)]
13pub struct CapabilityId(String);
14
15impl CapabilityId {
16 pub fn new(id: impl Into<String>) -> Self {
21 Self(id.into())
22 }
23
24 #[must_use]
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29}
30
31impl From<&str> for CapabilityId {
32 fn from(value: &str) -> Self {
33 Self::new(value)
34 }
35}
36
37impl From<String> for CapabilityId {
38 fn from(value: String) -> Self {
39 Self::new(value)
40 }
41}
42
43impl fmt::Display for CapabilityId {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 f.write_str(&self.0)
46 }
47}
48
49impl AsRef<str> for CapabilityId {
50 fn as_ref(&self) -> &str {
51 self.as_str()
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
57#[serde(transparent)]
58pub struct ProviderId(String);
59
60impl ProviderId {
61 pub fn new(id: impl Into<String>) -> Self {
66 Self(id.into())
67 }
68
69 #[must_use]
71 pub fn as_str(&self) -> &str {
72 &self.0
73 }
74}
75
76impl From<&str> for ProviderId {
77 fn from(value: &str) -> Self {
78 Self::new(value)
79 }
80}
81
82impl From<String> for ProviderId {
83 fn from(value: String) -> Self {
84 Self::new(value)
85 }
86}
87
88impl fmt::Display for ProviderId {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str(&self.0)
91 }
92}
93
94impl AsRef<str> for ProviderId {
95 fn as_ref(&self) -> &str {
96 self.as_str()
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum CapabilityVerb {
104 Ready,
106 Set,
108 Go,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum CapabilityState {
120 Ready,
122 Missing,
124 Incomplete,
126 Blocked,
128 Stale,
130 Optional,
132 NotNeeded,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum CapabilityRelevance {
140 Required,
142 Optional,
144 NotNeeded,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct CapabilityDescriptor {
151 pub id: CapabilityId,
153 pub title: String,
155 pub provider: ProviderId,
157 pub verbs: Vec<CapabilityVerb>,
159 pub default_relevance: CapabilityRelevance,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct NextAction {
166 pub command: String,
168 pub description: String,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct CapabilityReport {
175 pub id: CapabilityId,
177 pub title: String,
179 pub provider: ProviderId,
181 pub state: CapabilityState,
183 pub relevance: CapabilityRelevance,
185 pub summary: String,
187 pub next_action: Option<NextAction>,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "kebab-case")]
194pub enum RunStatus {
195 Ok,
197 Changed,
199 Noop,
201 Failed,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "kebab-case")]
208pub enum CapabilityActionKind {
209 Create,
211 Modify,
213 Delete,
215 Run,
217 Check,
219 Skip,
221 Error,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct CapabilityAction {
228 pub kind: CapabilityActionKind,
230 pub summary: String,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub path: Option<String>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct CapabilityRunReport {
240 pub id: CapabilityId,
242 #[serde(
244 serialize_with = "run_verb::serialize",
245 deserialize_with = "run_verb::deserialize"
246 )]
247 pub verb: CapabilityVerb,
248 pub status: RunStatus,
250 pub actions: Vec<CapabilityAction>,
252}
253
254mod run_verb {
255 use serde::{Deserialize, Deserializer, Serializer};
256
257 use super::CapabilityVerb;
258
259 #[allow(clippy::trivially_copy_pass_by_ref)] pub(super) fn serialize<S>(verb: &CapabilityVerb, serializer: S) -> Result<S::Ok, S::Error>
261 where
262 S: Serializer,
263 {
264 use serde::ser::Error as _;
265
266 match verb {
267 CapabilityVerb::Set => serializer.serialize_str("set"),
268 CapabilityVerb::Go => serializer.serialize_str("go"),
269 CapabilityVerb::Ready => Err(S::Error::custom("ready is not valid in a run report")),
270 }
271 }
272
273 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<CapabilityVerb, D::Error>
274 where
275 D: Deserializer<'de>,
276 {
277 use serde::de::Error as _;
278
279 let raw = String::deserialize(deserializer)?;
280 match raw.as_str() {
281 "set" => Ok(CapabilityVerb::Set),
282 "go" => Ok(CapabilityVerb::Go),
283 other => Err(D::Error::unknown_variant(other, &["set", "go"])),
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn descriptor_round_trips_json() {
294 let descriptor = CapabilityDescriptor {
295 id: "linting".into(),
296 title: "Linting".into(),
297 provider: "rust".into(),
298 verbs: vec![
299 CapabilityVerb::Ready,
300 CapabilityVerb::Set,
301 CapabilityVerb::Go,
302 ],
303 default_relevance: CapabilityRelevance::Required,
304 };
305
306 let json = serde_json::to_string(&descriptor).unwrap();
307 let back: CapabilityDescriptor = serde_json::from_str(&json).unwrap();
308 assert_eq!(descriptor, back);
309 assert!(json.contains("\"default_relevance\":\"required\""));
310 }
311
312 #[test]
313 fn report_round_trips_json_with_null_next_action() {
314 let report = CapabilityReport {
315 id: "deploy".into(),
316 title: "Deploy".into(),
317 provider: "deploy".into(),
318 state: CapabilityState::NotNeeded,
319 relevance: CapabilityRelevance::NotNeeded,
320 summary: "deployment is not needed".into(),
321 next_action: None,
322 };
323
324 let json = serde_json::to_string(&report).unwrap();
325 let back: CapabilityReport = serde_json::from_str(&json).unwrap();
326 assert_eq!(report, back);
327 assert!(json.contains("\"state\":\"not-needed\""));
328 assert!(json.contains("\"next_action\":null"));
329 }
330
331 #[test]
332 fn run_report_round_trips_json() {
333 let report = CapabilityRunReport {
334 id: "linting".into(),
335 verb: CapabilityVerb::Set,
336 status: RunStatus::Changed,
337 actions: vec![
338 CapabilityAction {
339 kind: CapabilityActionKind::Create,
340 summary: "created clippy config".into(),
341 path: Some("clippy.toml".into()),
342 },
343 CapabilityAction {
344 kind: CapabilityActionKind::Run,
345 summary: "ran clippy".into(),
346 path: None,
347 },
348 CapabilityAction {
349 kind: CapabilityActionKind::Error,
350 summary: "clippy failed".into(),
351 path: None,
352 },
353 ],
354 };
355
356 let json = serde_json::to_string(&report).unwrap();
357 let back: CapabilityRunReport = serde_json::from_str(&json).unwrap();
358 assert_eq!(report, back);
359 }
360
361 #[test]
362 fn run_report_rejects_ready_verb_on_wire() {
363 let report = CapabilityRunReport {
364 id: "linting".into(),
365 verb: CapabilityVerb::Ready,
366 status: RunStatus::Ok,
367 actions: Vec::new(),
368 };
369 assert!(serde_json::to_string(&report).is_err());
370
371 let err = serde_json::from_str::<CapabilityRunReport>(
372 r#"{"id":"linting","verb":"ready","status":"ok","actions":[]}"#,
373 )
374 .unwrap_err();
375 assert!(err.to_string().contains("unknown variant"));
376 }
377}