Skip to main content

gobby_core/
cli_contract.rs

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}