Skip to main content

nextdeck_helper/
xtask.rs

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}