1use std::collections::HashMap;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ValueType {
6 Bool,
7 String,
8 Integer,
9 Float,
10 Count, }
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum FlagValue {
16 Bool(bool),
17 Str(String),
18 Int(i64),
19 Float(f64),
20 Count(u32),
21}
22
23impl FlagValue {
24 pub fn as_str_value(&self) -> String {
25 match self {
26 FlagValue::Bool(b) => b.to_string(),
27 FlagValue::Str(s) => s.clone(),
28 FlagValue::Int(n) => n.to_string(),
29 FlagValue::Float(f) => f.to_string(),
30 FlagValue::Count(n) => n.to_string(),
31 }
32 }
33
34 pub fn is_truthy(&self) -> bool {
35 match self {
36 FlagValue::Bool(b) => *b,
37 FlagValue::Str(s) => !s.is_empty(),
38 FlagValue::Int(n) => *n != 0,
39 FlagValue::Float(f) => *f != 0.0,
40 FlagValue::Count(n) => *n > 0,
41 }
42 }
43}
44
45impl std::fmt::Display for FlagValue {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(f, "{}", self.as_str_value())
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct FlagSchema {
54 pub long: String,
55 pub short: Option<char>,
56 pub description: String,
57 pub value_type: ValueType,
58 pub expects_value: bool,
59 pub default: Option<String>,
60 pub choices: Option<Vec<String>>,
61 pub required: bool,
62 pub hidden: bool,
63}
64
65impl FlagSchema {
66 pub fn new(long: &str) -> Self {
67 Self {
68 long: long.to_string(),
69 short: None,
70 description: String::new(),
71 value_type: ValueType::String,
72 expects_value: true,
73 default: None,
74 choices: None,
75 required: false,
76 hidden: false,
77 }
78 }
79
80 pub fn boolean(long: &str) -> Self {
81 Self {
82 long: long.to_string(),
83 short: None,
84 description: String::new(),
85 value_type: ValueType::Bool,
86 expects_value: false,
87 default: None,
88 choices: None,
89 required: false,
90 hidden: false,
91 }
92 }
93
94 pub fn with_short(mut self, short: char) -> Self {
95 self.short = Some(short);
96 self
97 }
98
99 pub fn with_description(mut self, desc: &str) -> Self {
100 self.description = desc.to_string();
101 self
102 }
103
104 pub fn with_default(mut self, default: &str) -> Self {
105 self.default = Some(default.to_string());
106 self
107 }
108
109 pub fn with_choices(mut self, choices: &[&str]) -> Self {
110 self.choices = Some(choices.iter().map(|s| s.to_string()).collect());
111 self
112 }
113
114 pub fn required(mut self) -> Self {
115 self.required = true;
116 self
117 }
118
119 pub fn hidden(mut self) -> Self {
120 self.hidden = true;
121 self
122 }
123}
124
125#[derive(Debug, Clone, Default)]
127pub struct CommandPath {
128 pub domain: String,
129 pub resource: Option<String>,
130 pub verb: Option<String>,
131}
132
133impl CommandPath {
134 pub fn is_complete(&self) -> bool {
135 self.resource.is_some() && self.verb.is_some()
136 }
137
138 pub fn canonical(&self) -> String {
139 let mut parts = vec![self.domain.clone()];
140 if let Some(ref r) = self.resource {
141 parts.push(r.clone());
142 }
143 if let Some(ref v) = self.verb {
144 parts.push(v.clone());
145 }
146 parts.join("/")
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct ParsedCommand {
153 pub path: CommandPath,
154 pub target: Option<String>,
155 pub positional_args: Vec<String>,
156 pub flags: HashMap<String, FlagValue>,
157 pub raw: Vec<String>,
158}
159
160impl ParsedCommand {
161 pub fn new() -> Self {
162 Self {
163 path: CommandPath::default(),
164 target: None,
165 positional_args: Vec::new(),
166 flags: HashMap::new(),
167 raw: Vec::new(),
168 }
169 }
170
171 pub fn get_flag(&self, name: &str) -> Option<&str> {
172 match self.flags.get(name)? {
173 FlagValue::Str(s) => Some(s.as_str()),
174 _ => None,
175 }
176 }
177
178 pub fn has_flag(&self, name: &str) -> bool {
179 self.flags.get(name).is_some_and(|v| v.is_truthy())
180 }
181}
182
183pub fn global_flags() -> Vec<FlagSchema> {
185 vec![
186 FlagSchema::boolean("json")
187 .with_short('j')
188 .with_description("Force JSON output"),
189 FlagSchema::boolean("help")
190 .with_short('h')
191 .with_description("Show help"),
192 FlagSchema::boolean("version").with_description("Show version"),
193 FlagSchema::new("output")
194 .with_short('o')
195 .with_description("Output format")
196 .with_choices(&["text", "json", "yaml"]),
197 FlagSchema::boolean("no-color").with_description("Disable colors"),
198 FlagSchema::boolean("verbose")
199 .with_short('v')
200 .with_description("Verbose output"),
201 ]
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_flag_schema_builder() {
210 let flag = FlagSchema::new("output")
211 .with_short('o')
212 .with_description("Output format")
213 .with_default("text")
214 .with_choices(&["text", "json"])
215 .required();
216
217 assert_eq!(flag.long, "output");
218 assert_eq!(flag.short, Some('o'));
219 assert_eq!(flag.description, "Output format");
220 assert_eq!(flag.default, Some("text".to_string()));
221 assert_eq!(
222 flag.choices,
223 Some(vec!["text".to_string(), "json".to_string()])
224 );
225 assert!(flag.required);
226 assert!(flag.expects_value);
227 assert_eq!(flag.value_type, ValueType::String);
228
229 let bool_flag = FlagSchema::boolean("verbose").hidden();
230 assert_eq!(bool_flag.value_type, ValueType::Bool);
231 assert!(!bool_flag.expects_value);
232 assert!(bool_flag.hidden);
233 }
234
235 #[test]
236 fn test_flag_value_as_str() {
237 assert_eq!(FlagValue::Bool(true).as_str_value(), "true");
238 assert_eq!(FlagValue::Bool(false).as_str_value(), "false");
239 assert_eq!(FlagValue::Str("hello".into()).as_str_value(), "hello");
240 assert_eq!(FlagValue::Int(42).as_str_value(), "42");
241 assert_eq!(FlagValue::Float(2.5).as_str_value(), "2.5");
242 assert_eq!(FlagValue::Count(3).as_str_value(), "3");
243 }
244
245 #[test]
246 fn test_flag_value_is_truthy() {
247 assert!(FlagValue::Bool(true).is_truthy());
248 assert!(!FlagValue::Bool(false).is_truthy());
249 assert!(FlagValue::Str("yes".into()).is_truthy());
250 assert!(!FlagValue::Str(String::new()).is_truthy());
251 assert!(FlagValue::Int(1).is_truthy());
252 assert!(!FlagValue::Int(0).is_truthy());
253 assert!(FlagValue::Float(0.1).is_truthy());
254 assert!(!FlagValue::Float(0.0).is_truthy());
255 assert!(FlagValue::Count(1).is_truthy());
256 assert!(!FlagValue::Count(0).is_truthy());
257 }
258
259 #[test]
260 fn test_command_path_canonical() {
261 let full = CommandPath {
262 domain: "server".into(),
263 resource: Some("grpc".into()),
264 verb: Some("start".into()),
265 };
266 assert_eq!(full.canonical(), "server/grpc/start");
267
268 let partial = CommandPath {
269 domain: "query".into(),
270 resource: Some("sql".into()),
271 verb: None,
272 };
273 assert_eq!(partial.canonical(), "query/sql");
274
275 let domain_only = CommandPath {
276 domain: "health".into(),
277 resource: None,
278 verb: None,
279 };
280 assert_eq!(domain_only.canonical(), "health");
281 }
282
283 #[test]
284 fn test_command_path_is_complete() {
285 let complete = CommandPath {
286 domain: "server".into(),
287 resource: Some("grpc".into()),
288 verb: Some("start".into()),
289 };
290 assert!(complete.is_complete());
291
292 let incomplete = CommandPath {
293 domain: "server".into(),
294 resource: Some("grpc".into()),
295 verb: None,
296 };
297 assert!(!incomplete.is_complete());
298
299 let minimal = CommandPath {
300 domain: "health".into(),
301 resource: None,
302 verb: None,
303 };
304 assert!(!minimal.is_complete());
305 }
306
307 #[test]
308 fn test_parsed_command_get_flag() {
309 let mut cmd = ParsedCommand::new();
310 cmd.flags
311 .insert("output".into(), FlagValue::Str("json".into()));
312 cmd.flags.insert("verbose".into(), FlagValue::Bool(true));
313
314 assert_eq!(cmd.get_flag("output"), Some("json"));
315 assert_eq!(cmd.get_flag("verbose"), None); assert_eq!(cmd.get_flag("missing"), None);
317 }
318
319 #[test]
320 fn test_parsed_command_has_flag() {
321 let mut cmd = ParsedCommand::new();
322 cmd.flags.insert("verbose".into(), FlagValue::Bool(true));
323 cmd.flags.insert("quiet".into(), FlagValue::Bool(false));
324 cmd.flags.insert("count".into(), FlagValue::Count(3));
325
326 assert!(cmd.has_flag("verbose"));
327 assert!(!cmd.has_flag("quiet"));
328 assert!(cmd.has_flag("count"));
329 assert!(!cmd.has_flag("missing"));
330 }
331
332 #[test]
333 fn test_global_flags_defined() {
334 let flags = global_flags();
335 assert!(flags.len() >= 6);
336
337 let names: Vec<&str> = flags.iter().map(|f| f.long.as_str()).collect();
338 assert!(names.contains(&"json"));
339 assert!(names.contains(&"help"));
340 assert!(names.contains(&"version"));
341 assert!(names.contains(&"output"));
342 assert!(names.contains(&"no-color"));
343 assert!(names.contains(&"verbose"));
344
345 let output = flags.iter().find(|f| f.long == "output").unwrap();
346 assert!(output.expects_value);
347 assert!(output.choices.is_some());
348
349 let help = flags.iter().find(|f| f.long == "help").unwrap();
350 assert_eq!(help.short, Some('h'));
351 assert!(!help.expects_value);
352 }
353}