1use serde::Serialize;
2
3#[derive(Debug, Serialize)]
4pub struct CliContract {
5 pub tool: &'static str,
6 pub contract_version: u32,
7 pub summary: &'static str,
8 pub global_flags: Vec<FlagContract>,
9 pub scope: Option<ScopeContract>,
10 pub commands: Vec<CommandContract>,
11 pub error_codes: Vec<&'static str>,
12}
13
14#[derive(Debug, Serialize)]
15pub struct CommandContract {
16 pub name: &'static str,
17 pub summary: &'static str,
18 pub daemon_consumed: bool,
19 pub positionals: Vec<PositionalContract>,
20 pub flags: Vec<FlagContract>,
21 pub json_output_keys: Vec<&'static str>,
22 #[serde(skip_serializing_if = "Vec::is_empty")]
23 pub hard_dependencies: Vec<&'static str>,
24 #[serde(skip_serializing_if = "Vec::is_empty")]
25 pub optional_dependencies: Vec<&'static str>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub multimodal: Option<&'static str>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub degradation: Option<DegradationContract>,
30}
31
32impl CommandContract {
33 pub fn new(name: &'static str, summary: &'static str) -> Self {
34 assert!(!name.is_empty(), "command contract name must not be empty");
35 assert!(
36 !summary.is_empty(),
37 "command contract summary must not be empty"
38 );
39 Self {
40 name,
41 summary,
42 daemon_consumed: false,
43 positionals: Vec::new(),
44 flags: Vec::new(),
45 json_output_keys: Vec::new(),
46 hard_dependencies: Vec::new(),
47 optional_dependencies: Vec::new(),
48 multimodal: None,
49 degradation: None,
50 }
51 }
52}
53
54#[derive(Debug, Serialize)]
55pub struct DegradationContract {
56 pub output_shape: &'static str,
57 pub metadata_keys: Vec<&'static str>,
58}
59
60#[derive(Debug, Serialize)]
61pub struct FlagContract {
62 pub name: &'static str,
63 pub takes_value: bool,
64 pub value_name: Option<&'static str>,
65 pub allowed_values: Vec<&'static str>,
66 pub required: bool,
67 pub repeatable: bool,
68}
69
70#[derive(Debug, Serialize)]
71pub struct PositionalContract {
72 pub name: &'static str,
73 pub required: bool,
74 pub repeatable: bool,
75}
76
77#[derive(Debug, Serialize)]
78pub struct ScopeContract {
79 pub flags: Vec<FlagContract>,
80 pub default: &'static str,
81 pub identity_keys: Vec<&'static str>,
82}
83
84impl FlagContract {
85 pub fn switch(name: &'static str) -> Self {
86 Self {
87 name,
88 takes_value: false,
89 value_name: None,
90 allowed_values: Vec::new(),
91 required: false,
92 repeatable: false,
93 }
94 }
95
96 pub fn value(name: &'static str, value_name: &'static str) -> Self {
97 Self {
98 name,
99 takes_value: true,
100 value_name: Some(value_name),
101 allowed_values: Vec::new(),
102 required: false,
103 repeatable: false,
104 }
105 }
106
107 pub fn repeatable_value(name: &'static str, value_name: &'static str) -> Self {
108 Self {
109 repeatable: true,
110 ..Self::value(name, value_name)
111 }
112 }
113
114 pub fn required(mut self) -> Self {
115 self.required = true;
116 self
117 }
118
119 pub fn allowed(mut self, values: Vec<&'static str>) -> Self {
120 self.allowed_values = values;
121 self
122 }
123}
124
125impl PositionalContract {
126 pub fn required(name: &'static str) -> Self {
127 Self {
128 name,
129 required: true,
130 repeatable: false,
131 }
132 }
133
134 pub fn repeatable(name: &'static str) -> Self {
135 Self {
136 name,
137 required: true,
138 repeatable: true,
139 }
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use serde_json::Value;
146
147 use super::*;
148
149 #[test]
150 fn command_contract_serializes_builder_shape_and_skips_empty_optional_fields() {
151 let mut contract = CommandContract::new("index", "Index source files");
152 contract.daemon_consumed = true;
153 contract.positionals = vec![PositionalContract::repeatable("path")];
154 contract.flags = vec![
155 FlagContract::repeatable_value("glob", "GLOB")
156 .required()
157 .allowed(vec!["*.rs", "*.md"]),
158 FlagContract::switch("dry-run"),
159 ];
160 contract.json_output_keys = vec!["indexed_files", "skipped_files"];
161
162 let serialized = serde_json::to_string(&contract).expect("serialize command contract");
163
164 assert_eq!(
165 serialized,
166 r#"{"name":"index","summary":"Index source files","daemon_consumed":true,"positionals":[{"name":"path","required":true,"repeatable":true}],"flags":[{"name":"glob","takes_value":true,"value_name":"GLOB","allowed_values":["*.rs","*.md"],"required":true,"repeatable":true},{"name":"dry-run","takes_value":false,"value_name":null,"allowed_values":[],"required":false,"repeatable":false}],"json_output_keys":["indexed_files","skipped_files"]}"#
167 );
168
169 let round_trip: Value =
170 serde_json::from_str(&serialized).expect("golden command contract is valid JSON");
171 assert!(round_trip.get("hard_dependencies").is_none());
172 assert!(round_trip.get("optional_dependencies").is_none());
173 assert!(round_trip.get("multimodal").is_none());
174 assert!(round_trip.get("degradation").is_none());
175 assert_eq!(round_trip["flags"][0]["value_name"].as_str(), Some("GLOB"));
176 assert_eq!(round_trip["flags"][0]["required"].as_bool(), Some(true));
177 assert_eq!(round_trip["flags"][0]["repeatable"].as_bool(), Some(true));
178 }
179}