1#[cfg(feature = "xtask-clap")]
2use std::ffi::OsString;
3use std::fmt;
4use std::io::{self, Write};
5
6#[cfg(feature = "xtask-clap")]
7use clap::{Arg, ArgAction, Command, CommandFactory};
8use serde::{Deserialize, Serialize};
9
10pub const INFO_COMMAND: &str = "nextdeck-info";
11pub const SCHEMA_VERSION: u32 = 1;
12
13#[derive(Debug)]
14pub enum Error {
15 Io(io::Error),
16 Json(serde_json::Error),
17 UnsupportedFormat(String),
18 MissingFormatValue,
19}
20
21impl fmt::Display for Error {
22 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 Self::Io(error) => write!(formatter, "{error}"),
25 Self::Json(error) => write!(formatter, "{error}"),
26 Self::UnsupportedFormat(format) => {
27 write!(formatter, "unsupported Nextdeck info format: {format}")
28 }
29 Self::MissingFormatValue => write!(formatter, "--format requires a value"),
30 }
31 }
32}
33
34impl std::error::Error for Error {}
35
36impl From<io::Error> for Error {
37 fn from(error: io::Error) -> Self {
38 Self::Io(error)
39 }
40}
41
42impl From<serde_json::Error> for Error {
43 fn from(error: serde_json::Error) -> Self {
44 Self::Json(error)
45 }
46}
47
48pub type Result<T> = std::result::Result<T, Error>;
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub struct XtaskManifest {
52 pub schema_version: u32,
53 pub commands: Vec<XtaskCommand>,
54}
55
56#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
57pub struct XtaskCommand {
58 pub name: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub about: Option<String>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub args: Vec<XtaskArg>,
63}
64
65#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
66pub struct XtaskArg {
67 pub name: String,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub long: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub short: Option<char>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub help: Option<String>,
74 #[serde(default, skip_serializing_if = "is_false")]
75 pub required: bool,
76 pub value: XtaskValue,
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(tag = "type", rename_all = "kebab-case")]
81pub enum XtaskValue {
82 Bool {
83 #[serde(default)]
84 default: bool,
85 },
86 Number {
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 default: Option<i64>,
89 },
90 String {
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 default: Option<String>,
93 },
94 Enum {
95 values: Vec<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 default: Option<String>,
98 },
99}
100
101#[cfg(feature = "xtask-clap")]
102pub fn handle_nextdeck_info<T>() -> Result<bool>
103where
104 T: CommandFactory,
105{
106 handle_nextdeck_info_from::<T, _, _, _>(std::env::args_os(), io::stdout())
107}
108
109#[cfg(feature = "xtask-clap")]
110pub fn handle_nextdeck_info_from<T, I, S, W>(args: I, writer: W) -> Result<bool>
111where
112 T: CommandFactory,
113 I: IntoIterator<Item = S>,
114 S: Into<OsString>,
115 W: Write,
116{
117 let mut args = args.into_iter().map(Into::into);
118 let _binary = args.next();
119 let Some(command) = args.next() else {
120 return Ok(false);
121 };
122 if command != INFO_COMMAND {
123 return Ok(false);
124 }
125
126 let mut format = "json".to_owned();
127 while let Some(arg) = args.next() {
128 if arg == "--format" {
129 let Some(value) = args.next() else {
130 return Err(Error::MissingFormatValue);
131 };
132 format = value.to_string_lossy().into_owned();
133 } else if let Some(value) = arg
134 .to_string_lossy()
135 .strip_prefix("--format=")
136 .map(ToOwned::to_owned)
137 {
138 format = value;
139 } else {
140 return Err(Error::UnsupportedFormat(arg.to_string_lossy().into_owned()));
141 }
142 }
143
144 if format != "json" {
145 return Err(Error::UnsupportedFormat(format));
146 }
147
148 write_manifest_for::<T, W>(writer)?;
149 Ok(true)
150}
151
152#[cfg(feature = "xtask-clap")]
153pub fn write_manifest_for<T, W>(writer: W) -> Result<()>
154where
155 T: CommandFactory,
156 W: Write,
157{
158 write_manifest(&command_manifest(T::command()), writer)
159}
160
161pub fn write_manifest<W>(manifest: &XtaskManifest, mut writer: W) -> Result<()>
162where
163 W: Write,
164{
165 serde_json::to_writer_pretty(&mut writer, manifest)?;
166 writeln!(writer)?;
167 Ok(())
168}
169
170#[cfg(feature = "xtask-clap")]
171pub fn command_manifest(mut command: Command) -> XtaskManifest {
172 command.build();
173 XtaskManifest {
174 schema_version: SCHEMA_VERSION,
175 commands: command
176 .get_subcommands()
177 .filter(|command| !command.is_hide_set())
178 .filter(|command| command.get_name() != INFO_COMMAND)
179 .filter(|command| command.get_name() != "help")
180 .map(command_spec)
181 .collect(),
182 }
183}
184
185#[cfg(feature = "xtask-clap")]
186fn command_spec(command: &Command) -> XtaskCommand {
187 XtaskCommand {
188 name: command.get_name().to_owned(),
189 about: command
190 .get_about()
191 .or_else(|| command.get_long_about())
192 .map(ToString::to_string),
193 args: command
194 .get_arguments()
195 .filter(|arg| !arg.is_hide_set())
196 .filter(|arg| !arg.is_positional())
197 .filter(|arg| arg.get_long().is_some() || arg.get_short().is_some())
198 .filter_map(arg_spec)
199 .collect(),
200 }
201}
202
203#[cfg(feature = "xtask-clap")]
204fn arg_spec(arg: &Arg) -> Option<XtaskArg> {
205 let value = arg_value(arg)?;
206 Some(XtaskArg {
207 name: arg.get_id().as_str().to_owned(),
208 long: arg.get_long().map(ToOwned::to_owned),
209 short: arg.get_short(),
210 help: arg.get_help().map(ToString::to_string),
211 required: arg.is_required_set(),
212 value,
213 })
214}
215
216#[cfg(feature = "xtask-clap")]
217fn arg_value(arg: &Arg) -> Option<XtaskValue> {
218 match arg.get_action() {
219 ArgAction::SetTrue => Some(XtaskValue::Bool { default: false }),
220 ArgAction::SetFalse => Some(XtaskValue::Bool { default: true }),
221 ArgAction::Set => {
222 let defaults = default_values(arg);
223 let possible_values = arg
224 .get_value_parser()
225 .possible_values()
226 .map(|values| {
227 values
228 .filter(|value| !value.is_hide_set())
229 .map(|value| value.get_name().to_owned())
230 .collect::<Vec<_>>()
231 })
232 .unwrap_or_default();
233 if !possible_values.is_empty() {
234 return Some(XtaskValue::Enum {
235 values: possible_values,
236 default: defaults.first().cloned(),
237 });
238 }
239 if let Some(default) = defaults.first().and_then(|value| value.parse().ok()) {
240 return Some(XtaskValue::Number {
241 default: Some(default),
242 });
243 }
244 Some(XtaskValue::String {
245 default: defaults.first().cloned(),
246 })
247 }
248 _ => None,
249 }
250}
251
252#[cfg(feature = "xtask-clap")]
253fn default_values(arg: &Arg) -> Vec<String> {
254 arg.get_default_values()
255 .iter()
256 .filter_map(|value| value.to_str().map(ToOwned::to_owned))
257 .collect()
258}
259
260#[allow(clippy::trivially_copy_pass_by_ref)]
261fn is_false(value: &bool) -> bool {
262 !*value
263}
264
265#[cfg(all(test, feature = "xtask-clap"))]
266mod tests {
267 use clap::{Parser, Subcommand, ValueEnum};
268
269 use super::*;
270
271 #[derive(Parser)]
272 struct ExampleCli {
273 #[command(subcommand)]
274 command: ExampleCommand,
275 }
276
277 #[derive(Subcommand)]
278 enum ExampleCommand {
279 #[command(about = "Run checks")]
280 Check {
281 #[arg(long, help = "Allow a dirty worktree")]
282 allow_dirty: bool,
283 #[arg(long, default_value_t = 2)]
284 retries: i64,
285 #[arg(long, value_enum, default_value_t = Profile::Dev)]
286 profile: Profile,
287 #[arg(long)]
288 name: Option<String>,
289 #[arg(long, action = clap::ArgAction::SetFalse)]
290 color: bool,
291 },
292 }
293
294 #[derive(Clone, Debug, ValueEnum)]
295 enum Profile {
296 Dev,
297 Release,
298 }
299
300 #[test]
301 fn generates_nextdeck_manifest_from_clap() {
302 let manifest = command_manifest(ExampleCli::command());
303
304 assert_eq!(manifest.schema_version, SCHEMA_VERSION);
305 assert_eq!(manifest.commands[0].name, "check");
306 assert_eq!(manifest.commands[0].args[0].name, "allow_dirty");
307 assert_eq!(
308 manifest.commands[0].args[0].long.as_deref(),
309 Some("allow-dirty")
310 );
311 assert!(matches!(
312 manifest.commands[0].args[0].value,
313 XtaskValue::Bool { default: false }
314 ));
315 assert!(matches!(
316 manifest.commands[0].args[1].value,
317 XtaskValue::Number { default: Some(2) }
318 ));
319 assert!(matches!(
320 &manifest.commands[0].args[2].value,
321 XtaskValue::Enum { values, default }
322 if values == &vec!["dev".to_owned(), "release".to_owned()]
323 && default.as_deref() == Some("dev")
324 ));
325 assert!(manifest.commands[0].args.iter().any(|arg| {
326 arg.name == "color" && matches!(arg.value, XtaskValue::Bool { default: true })
327 }));
328 }
329
330 #[test]
331 fn handles_info_request_before_clap_parse() {
332 let mut output = Vec::new();
333 let handled = handle_nextdeck_info_from::<ExampleCli, _, _, _>(
334 ["xtask", INFO_COMMAND, "--format", "json"],
335 &mut output,
336 )
337 .expect("info");
338
339 assert!(handled);
340 let json = String::from_utf8(output).expect("utf8");
341 assert!(json.contains("\"schema_version\": 1"));
342 assert!(json.contains("\"name\": \"check\""));
343 assert!(json.contains("\"long\": \"allow-dirty\""));
344 }
345}