Skip to main content

cli/commands/
mod.rs

1//! Command definitions and command loading.
2//!
3//! These models mirror the public command surface declared in upstream
4//! `nest-cli/commands/*.command.ts`.
5
6use core::fmt;
7use std::collections::{HashMap, HashSet};
8
9use crate::actions::ActionInvocation;
10
11pub mod abstract_command;
12pub mod add_command;
13pub mod build_command;
14pub mod command_input;
15pub mod command_loader;
16pub mod generate_command;
17pub mod info_command;
18pub mod new_command;
19pub mod start_command;
20
21pub use command_input::{Input, InputOptions, InputValue};
22
23/// Public command names supported by upstream Nest CLI.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum CommandName {
26    Add,
27    Build,
28    Generate,
29    Info,
30    New,
31    Start,
32}
33
34impl CommandName {
35    pub const fn as_str(self) -> &'static str {
36        match self {
37            Self::Add => "add",
38            Self::Build => "build",
39            Self::Generate => "generate",
40            Self::Info => "info",
41            Self::New => "new",
42            Self::Start => "start",
43        }
44    }
45}
46
47/// Error raised while resolving a command or mapping CLI tokens into action inputs.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum CommandParseError {
50    MissingCommand,
51    UnknownCommand(String),
52    UnknownOption {
53        command: CommandName,
54        option: String,
55    },
56    MissingRequiredArgument {
57        command: CommandName,
58        argument: &'static str,
59    },
60    MissingRequiredOptionValue {
61        command: CommandName,
62        option: &'static str,
63    },
64    TooManyArguments {
65        command: CommandName,
66        extra: Vec<String>,
67    },
68    InvalidBuilder(String),
69    InvalidLanguage(String),
70}
71
72impl fmt::Display for CommandParseError {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::MissingCommand => formatter.write_str("missing command"),
76            Self::UnknownCommand(command) => write!(formatter, "invalid command: {command}"),
77            Self::UnknownOption { command, option } => {
78                write!(formatter, "unknown option for {command}: {option}")
79            }
80            Self::MissingRequiredArgument { command, argument } => {
81                write!(
82                    formatter,
83                    "missing required argument {argument} for {command}"
84                )
85            }
86            Self::MissingRequiredOptionValue { command, option } => {
87                write!(
88                    formatter,
89                    "missing required value for {command} option --{option}"
90                )
91            }
92            Self::TooManyArguments { command, extra } => write!(
93                formatter,
94                "too many arguments for {command}: {}",
95                extra.join(", ")
96            ),
97            Self::InvalidBuilder(builder) => write!(
98                formatter,
99                "Invalid builder option: {builder}. Available builder: cargo"
100            ),
101            Self::InvalidLanguage(language) => write!(
102                formatter,
103                "Invalid language \"{language}\" selected. Available language is \"rust\""
104            ),
105        }
106    }
107}
108
109impl std::error::Error for CommandParseError {}
110
111impl fmt::Display for CommandName {
112    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113        formatter.write_str(self.as_str())
114    }
115}
116
117/// Command argument cardinality from commander signatures.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum ArgumentArity {
120    Required,
121    Optional,
122    OptionalVariadic,
123}
124
125/// A positional argument declared by an upstream command signature.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub struct CommandArgumentSpec {
128    pub name: &'static str,
129    pub arity: ArgumentArity,
130}
131
132/// Option value syntax used by upstream commander declarations.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum OptionValueKind {
135    Bool,
136    OptionalString,
137    RequiredString,
138    StringList,
139}
140
141/// Default value declared by upstream commander options.
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub enum OptionDefault {
144    None,
145    Bool(bool),
146    String(&'static str),
147    StringListEmpty,
148}
149
150/// Data-only descriptor for a command option and its mapped action input name.
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
152pub struct CommandOptionSpec {
153    pub short: Option<char>,
154    pub long: &'static str,
155    pub input_name: &'static str,
156    pub value_kind: OptionValueKind,
157    pub default: OptionDefault,
158    pub negates: Option<&'static str>,
159}
160
161/// Data-only descriptor for an upstream CLI command.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub struct CommandSpec {
164    pub name: CommandName,
165    pub signature: &'static str,
166    pub aliases: &'static [&'static str],
167    pub description: &'static str,
168    pub usage: Option<&'static str>,
169    pub allow_unknown_options: bool,
170    pub args: &'static [CommandArgumentSpec],
171    pub options: &'static [CommandOptionSpec],
172}
173
174impl CommandSpec {
175    pub fn option(self, long: &str) -> Option<&'static CommandOptionSpec> {
176        self.options.iter().find(|option| option.long == long)
177    }
178}
179
180const ADD_ARGS: &[CommandArgumentSpec] = &[CommandArgumentSpec {
181    name: "library",
182    arity: ArgumentArity::Required,
183}];
184
185const ADD_OPTIONS: &[CommandOptionSpec] = &[
186    CommandOptionSpec {
187        short: Some('d'),
188        long: "dry-run",
189        input_name: "dry-run",
190        value_kind: OptionValueKind::Bool,
191        default: OptionDefault::None,
192        negates: None,
193    },
194    CommandOptionSpec {
195        short: Some('s'),
196        long: "skip-install",
197        input_name: "skip-install",
198        value_kind: OptionValueKind::Bool,
199        default: OptionDefault::Bool(false),
200        negates: None,
201    },
202    CommandOptionSpec {
203        short: Some('p'),
204        long: "project",
205        input_name: "project",
206        value_kind: OptionValueKind::OptionalString,
207        default: OptionDefault::None,
208        negates: None,
209    },
210];
211
212const BUILD_ARGS: &[CommandArgumentSpec] = &[CommandArgumentSpec {
213    name: "apps",
214    arity: ArgumentArity::OptionalVariadic,
215}];
216
217const BUILD_OPTIONS: &[CommandOptionSpec] = &[
218    option('c', "config", "config", OptionValueKind::OptionalString),
219    option('p', "path", "path", OptionValueKind::OptionalString),
220    option('w', "watch", "watch", OptionValueKind::Bool),
221    option('b', "builder", "builder", OptionValueKind::OptionalString),
222    option_no_short("watchAssets", "watchAssets", OptionValueKind::Bool),
223    option_no_short("webpack", "webpack", OptionValueKind::Bool),
224    option_no_short("type-check", "typeCheck", OptionValueKind::Bool),
225    option_no_short(
226        "webpackPath",
227        "webpackPath",
228        OptionValueKind::OptionalString,
229    ),
230    option_no_short("tsc", "tsc", OptionValueKind::Bool),
231    option_no_short(
232        "preserveWatchOutput",
233        "preserveWatchOutput",
234        OptionValueKind::Bool,
235    ),
236    option_no_short("all", "all", OptionValueKind::Bool),
237];
238
239const GENERATE_ARGS: &[CommandArgumentSpec] = &[
240    CommandArgumentSpec {
241        name: "schematic",
242        arity: ArgumentArity::Required,
243    },
244    CommandArgumentSpec {
245        name: "name",
246        arity: ArgumentArity::Optional,
247    },
248    CommandArgumentSpec {
249        name: "path",
250        arity: ArgumentArity::Optional,
251    },
252];
253
254const GENERATE_OPTIONS: &[CommandOptionSpec] = &[
255    option('d', "dry-run", "dry-run", OptionValueKind::Bool),
256    option('p', "project", "project", OptionValueKind::OptionalString),
257    CommandOptionSpec {
258        short: None,
259        long: "flat",
260        input_name: "flat",
261        value_kind: OptionValueKind::Bool,
262        default: OptionDefault::None,
263        negates: None,
264    },
265    CommandOptionSpec {
266        short: None,
267        long: "no-flat",
268        input_name: "flat",
269        value_kind: OptionValueKind::Bool,
270        default: OptionDefault::None,
271        negates: Some("flat"),
272    },
273    CommandOptionSpec {
274        short: None,
275        long: "spec",
276        input_name: "spec",
277        value_kind: OptionValueKind::Bool,
278        default: OptionDefault::Bool(true),
279        negates: None,
280    },
281    option_no_short(
282        "spec-file-suffix",
283        "specFileSuffix",
284        OptionValueKind::OptionalString,
285    ),
286    CommandOptionSpec {
287        short: None,
288        long: "skip-import",
289        input_name: "skipImport",
290        value_kind: OptionValueKind::Bool,
291        default: OptionDefault::Bool(false),
292        negates: None,
293    },
294    CommandOptionSpec {
295        short: None,
296        long: "no-spec",
297        input_name: "spec",
298        value_kind: OptionValueKind::Bool,
299        default: OptionDefault::None,
300        negates: Some("spec"),
301    },
302    option(
303        'c',
304        "collection",
305        "collection",
306        OptionValueKind::OptionalString,
307    ),
308    option_no_short("type", "type", OptionValueKind::RequiredString),
309    option_no_short("crud", "crud", OptionValueKind::OptionalString),
310];
311
312const INFO_ARGS: &[CommandArgumentSpec] = &[];
313const INFO_OPTIONS: &[CommandOptionSpec] = &[];
314
315const NEW_ARGS: &[CommandArgumentSpec] = &[CommandArgumentSpec {
316    name: "name",
317    arity: ArgumentArity::Optional,
318}];
319
320const NEW_OPTIONS: &[CommandOptionSpec] = &[
321    option_no_short("directory", "directory", OptionValueKind::OptionalString),
322    CommandOptionSpec {
323        short: Some('d'),
324        long: "dry-run",
325        input_name: "dry-run",
326        value_kind: OptionValueKind::Bool,
327        default: OptionDefault::Bool(false),
328        negates: None,
329    },
330    CommandOptionSpec {
331        short: Some('g'),
332        long: "skip-git",
333        input_name: "skip-git",
334        value_kind: OptionValueKind::Bool,
335        default: OptionDefault::Bool(false),
336        negates: None,
337    },
338    CommandOptionSpec {
339        short: Some('s'),
340        long: "skip-install",
341        input_name: "skip-install",
342        value_kind: OptionValueKind::Bool,
343        default: OptionDefault::Bool(false),
344        negates: None,
345    },
346    option(
347        'p',
348        "package-manager",
349        "packageManager",
350        OptionValueKind::OptionalString,
351    ),
352    CommandOptionSpec {
353        short: Some('l'),
354        long: "language",
355        input_name: "language",
356        value_kind: OptionValueKind::OptionalString,
357        default: OptionDefault::String("Rust"),
358        negates: None,
359    },
360    CommandOptionSpec {
361        short: Some('c'),
362        long: "collection",
363        input_name: "collection",
364        value_kind: OptionValueKind::OptionalString,
365        default: OptionDefault::String("@nestrs/schematics"),
366        negates: None,
367    },
368    CommandOptionSpec {
369        short: None,
370        long: "strict",
371        input_name: "strict",
372        value_kind: OptionValueKind::Bool,
373        default: OptionDefault::Bool(false),
374        negates: None,
375    },
376];
377
378const START_ARGS: &[CommandArgumentSpec] = &[CommandArgumentSpec {
379    name: "app",
380    arity: ArgumentArity::Optional,
381}];
382
383const START_OPTIONS: &[CommandOptionSpec] = &[
384    option('c', "config", "config", OptionValueKind::OptionalString),
385    option('p', "path", "path", OptionValueKind::OptionalString),
386    option('w', "watch", "watch", OptionValueKind::Bool),
387    option('b', "builder", "builder", OptionValueKind::OptionalString),
388    option_no_short("watchAssets", "watchAssets", OptionValueKind::Bool),
389    option('d', "debug", "debug", OptionValueKind::OptionalString),
390    option_no_short("webpack", "webpack", OptionValueKind::Bool),
391    option_no_short(
392        "webpackPath",
393        "webpackPath",
394        OptionValueKind::OptionalString,
395    ),
396    option_no_short("type-check", "typeCheck", OptionValueKind::Bool),
397    option_no_short("tsc", "tsc", OptionValueKind::Bool),
398    option_no_short("sourceRoot", "sourceRoot", OptionValueKind::OptionalString),
399    option_no_short("entryFile", "entryFile", OptionValueKind::OptionalString),
400    option('e', "exec", "exec", OptionValueKind::OptionalString),
401    option_no_short(
402        "preserveWatchOutput",
403        "preserveWatchOutput",
404        OptionValueKind::Bool,
405    ),
406    CommandOptionSpec {
407        short: None,
408        long: "shell",
409        input_name: "shell",
410        value_kind: OptionValueKind::Bool,
411        default: OptionDefault::Bool(true),
412        negates: None,
413    },
414    CommandOptionSpec {
415        short: None,
416        long: "no-shell",
417        input_name: "shell",
418        value_kind: OptionValueKind::Bool,
419        default: OptionDefault::None,
420        negates: Some("shell"),
421    },
422    CommandOptionSpec {
423        short: None,
424        long: "env-file",
425        input_name: "envFile",
426        value_kind: OptionValueKind::StringList,
427        default: OptionDefault::StringListEmpty,
428        negates: None,
429    },
430];
431
432pub const COMMAND_SPECS: &[CommandSpec] = &[
433    CommandSpec {
434        name: CommandName::New,
435        signature: "new [name]",
436        aliases: &["n"],
437        description: "Generate Nest application.",
438        usage: None,
439        allow_unknown_options: false,
440        args: NEW_ARGS,
441        options: NEW_OPTIONS,
442    },
443    CommandSpec {
444        name: CommandName::Build,
445        signature: "build [apps...]",
446        aliases: &[],
447        description: "Build Nest application.",
448        usage: None,
449        allow_unknown_options: false,
450        args: BUILD_ARGS,
451        options: BUILD_OPTIONS,
452    },
453    CommandSpec {
454        name: CommandName::Start,
455        signature: "start [app]",
456        aliases: &[],
457        description: "Run Nest application.",
458        usage: None,
459        allow_unknown_options: true,
460        args: START_ARGS,
461        options: START_OPTIONS,
462    },
463    CommandSpec {
464        name: CommandName::Info,
465        signature: "info",
466        aliases: &["i"],
467        description: "Display Nest project details.",
468        usage: None,
469        allow_unknown_options: false,
470        args: INFO_ARGS,
471        options: INFO_OPTIONS,
472    },
473    CommandSpec {
474        name: CommandName::Add,
475        signature: "add <library>",
476        aliases: &[],
477        description: "Adds support for an external library to your project.",
478        usage: Some("<library> [options] [library-specific-options]"),
479        allow_unknown_options: true,
480        args: ADD_ARGS,
481        options: ADD_OPTIONS,
482    },
483    CommandSpec {
484        name: CommandName::Generate,
485        signature: "generate <schematic> [name] [path]",
486        aliases: &["g"],
487        description: "Generate a Nest element.",
488        usage: None,
489        allow_unknown_options: false,
490        args: GENERATE_ARGS,
491        options: GENERATE_OPTIONS,
492    },
493];
494
495pub fn command_specs() -> &'static [CommandSpec] {
496    COMMAND_SPECS
497}
498
499pub fn command_spec(name: CommandName) -> Option<&'static CommandSpec> {
500    COMMAND_SPECS.iter().find(|command| command.name == name)
501}
502
503pub fn resolve_command_name(name_or_alias: &str) -> Option<CommandName> {
504    COMMAND_SPECS
505        .iter()
506        .find(|command| {
507            command.name.as_str() == name_or_alias || command.aliases.contains(&name_or_alias)
508        })
509        .map(|command| command.name)
510}
511
512pub fn resolve_command_spec(name_or_alias: &str) -> Option<&'static CommandSpec> {
513    resolve_command_name(name_or_alias).and_then(command_spec)
514}
515
516/// Pure equivalent of upstream `CommandLoader.load`: resolve one command line
517/// into the action invocation that would be handed to an action, without
518/// executing the action or touching process state.
519pub fn load_command_invocation<I, S>(args: I) -> Result<ActionInvocation, CommandParseError>
520where
521    I: IntoIterator<Item = S>,
522    S: AsRef<str>,
523{
524    let args: Vec<String> = args
525        .into_iter()
526        .map(|argument| argument.as_ref().to_owned())
527        .collect();
528    let (command_name, rest) = args
529        .split_first()
530        .ok_or(CommandParseError::MissingCommand)?;
531    let spec = resolve_command_spec(command_name)
532        .ok_or_else(|| CommandParseError::UnknownCommand(command_name.clone()))?;
533    let parsed = parse_tokens(spec, rest)?;
534
535    match spec.name {
536        CommandName::Add => build_add_invocation(parsed),
537        CommandName::Build => build_build_invocation(parsed),
538        CommandName::Generate => build_generate_invocation(parsed),
539        CommandName::Info => build_info_invocation(parsed),
540        CommandName::New => build_new_invocation(parsed),
541        CommandName::Start => build_start_invocation(parsed),
542    }
543}
544
545#[derive(Clone, Debug)]
546struct ParsedCommand {
547    positionals: Vec<String>,
548    options: HashMap<&'static str, InputValue>,
549    explicit_options: HashSet<&'static str>,
550    extra_flags: Vec<String>,
551}
552
553fn parse_tokens(
554    spec: &'static CommandSpec,
555    tokens: &[String],
556) -> Result<ParsedCommand, CommandParseError> {
557    let mut parsed = ParsedCommand {
558        positionals: Vec::new(),
559        options: HashMap::new(),
560        explicit_options: HashSet::new(),
561        extra_flags: Vec::new(),
562    };
563    let mut index = 0;
564
565    while index < tokens.len() {
566        let token = &tokens[index];
567        if token == "--" {
568            if spec.allow_unknown_options {
569                parsed
570                    .extra_flags
571                    .extend(tokens[index + 1..].iter().cloned());
572            } else {
573                parsed
574                    .positionals
575                    .extend(tokens[index + 1..].iter().cloned());
576            }
577            break;
578        }
579
580        if let Some(raw) = token.strip_prefix("--") {
581            let (long, inline_value) = split_long_option(raw);
582            if let Some(option) = spec.option(long) {
583                index = parse_known_option(spec, option, inline_value, tokens, index, &mut parsed)?;
584            } else if spec.allow_unknown_options {
585                parsed.extra_flags.push(token.clone());
586                if inline_value.is_none()
587                    && index + 1 < tokens.len()
588                    && !tokens[index + 1].starts_with('-')
589                {
590                    index += 1;
591                    parsed.extra_flags.push(tokens[index].clone());
592                }
593            } else {
594                return Err(CommandParseError::UnknownOption {
595                    command: spec.name,
596                    option: token.clone(),
597                });
598            }
599        } else if token.starts_with('-') && token.len() > 1 {
600            let (short, inline_value) = split_short_option(token);
601            if let Some(option) = spec
602                .options
603                .iter()
604                .find(|option| option.short == Some(short))
605            {
606                index = parse_known_option(spec, option, inline_value, tokens, index, &mut parsed)?;
607            } else if spec.allow_unknown_options {
608                parsed.extra_flags.push(token.clone());
609                if inline_value.is_none()
610                    && index + 1 < tokens.len()
611                    && !tokens[index + 1].starts_with('-')
612                {
613                    index += 1;
614                    parsed.extra_flags.push(tokens[index].clone());
615                }
616            } else {
617                return Err(CommandParseError::UnknownOption {
618                    command: spec.name,
619                    option: token.clone(),
620                });
621            }
622        } else {
623            parsed.positionals.push(token.clone());
624        }
625
626        index += 1;
627    }
628
629    validate_positionals(spec, &parsed.positionals)?;
630    Ok(parsed)
631}
632
633fn parse_known_option(
634    spec: &CommandSpec,
635    option: &'static CommandOptionSpec,
636    inline_value: Option<String>,
637    tokens: &[String],
638    index: usize,
639    parsed: &mut ParsedCommand,
640) -> Result<usize, CommandParseError> {
641    let mut next_index = index;
642    let value = match option.value_kind {
643        OptionValueKind::Bool => InputValue::Bool(option.negates.is_none()),
644        OptionValueKind::OptionalString => {
645            if let Some(value) = inline_value {
646                InputValue::String(value)
647            } else if index + 1 < tokens.len() && !tokens[index + 1].starts_with('-') {
648                next_index += 1;
649                InputValue::String(tokens[next_index].clone())
650            } else {
651                InputValue::Bool(true)
652            }
653        }
654        OptionValueKind::RequiredString => {
655            if let Some(value) = inline_value {
656                InputValue::String(value)
657            } else if index + 1 < tokens.len() && !tokens[index + 1].starts_with('-') {
658                next_index += 1;
659                InputValue::String(tokens[next_index].clone())
660            } else {
661                return Err(CommandParseError::MissingRequiredOptionValue {
662                    command: spec.name,
663                    option: option.long,
664                });
665            }
666        }
667        OptionValueKind::StringList => {
668            let value = if let Some(value) = inline_value {
669                value
670            } else if index + 1 < tokens.len() && !tokens[index + 1].starts_with('-') {
671                next_index += 1;
672                tokens[next_index].clone()
673            } else {
674                String::from("true")
675            };
676            let mut values = match parsed.options.remove(option.input_name) {
677                Some(InputValue::StringList(values)) => values,
678                _ => Vec::new(),
679            };
680            values.push(value);
681            InputValue::StringList(values)
682        }
683    };
684
685    parsed.options.insert(option.input_name, value);
686    parsed.explicit_options.insert(option.input_name);
687    Ok(next_index)
688}
689
690fn split_long_option(raw: &str) -> (&str, Option<String>) {
691    raw.split_once('=')
692        .map(|(name, value)| (name, Some(value.to_owned())))
693        .unwrap_or((raw, None))
694}
695
696fn split_short_option(raw: &str) -> (char, Option<String>) {
697    let short = raw.chars().nth(1).expect("short option marker");
698    let rest = &raw[2..];
699    let value = if rest.is_empty() {
700        None
701    } else {
702        Some(rest.trim_start_matches('=').to_owned())
703    };
704    (short, value)
705}
706
707fn validate_positionals(
708    spec: &CommandSpec,
709    positionals: &[String],
710) -> Result<(), CommandParseError> {
711    let required_count = spec
712        .args
713        .iter()
714        .filter(|argument| argument.arity == ArgumentArity::Required)
715        .count();
716    if positionals.len() < required_count {
717        let argument = spec.args[positionals.len()].name;
718        return Err(CommandParseError::MissingRequiredArgument {
719            command: spec.name,
720            argument,
721        });
722    }
723
724    let has_variadic = spec
725        .args
726        .iter()
727        .any(|argument| argument.arity == ArgumentArity::OptionalVariadic);
728    if !has_variadic && positionals.len() > spec.args.len() {
729        return Err(CommandParseError::TooManyArguments {
730            command: spec.name,
731            extra: positionals[spec.args.len()..].to_vec(),
732        });
733    }
734
735    Ok(())
736}
737
738fn build_add_invocation(parsed: ParsedCommand) -> Result<ActionInvocation, CommandParseError> {
739    let mut invocation = ActionInvocation::for_command(CommandName::Add);
740    invocation.inputs.push(Input::new(
741        "library",
742        string_positional(&parsed, 0).map(InputValue::String),
743    ));
744    invocation.options.push(Input::new(
745        "dry-run",
746        Some(InputValue::Bool(bool_option(&parsed, "dry-run"))),
747    ));
748    invocation.options.push(Input::new(
749        "skip-install",
750        Some(InputValue::Bool(bool_option(&parsed, "skip-install"))),
751    ));
752    invocation
753        .options
754        .push(Input::new("project", option_value(&parsed, "project")));
755    invocation.extra_flags = parsed.extra_flags;
756    Ok(invocation)
757}
758
759fn build_build_invocation(parsed: ParsedCommand) -> Result<ActionInvocation, CommandParseError> {
760    let mut invocation = ActionInvocation::for_command(CommandName::Build);
761    if parsed.positionals.is_empty() {
762        invocation.inputs.push(Input::new("app", None));
763    } else {
764        invocation.inputs.extend(
765            parsed
766                .positionals
767                .iter()
768                .cloned()
769                .map(|app| Input::new("app", Some(InputValue::String(app)))),
770        );
771    }
772
773    let is_webpack_enabled = !bool_option(&parsed, "tsc") && bool_option(&parsed, "webpack");
774    invocation
775        .options
776        .push(Input::new("config", option_value(&parsed, "config")));
777    invocation.options.push(Input::new(
778        "webpack",
779        Some(InputValue::Bool(is_webpack_enabled)),
780    ));
781    invocation.options.push(Input::new(
782        "watch",
783        Some(InputValue::Bool(bool_option(&parsed, "watch"))),
784    ));
785    invocation.options.push(Input::new(
786        "watchAssets",
787        Some(InputValue::Bool(bool_option(&parsed, "watchAssets"))),
788    ));
789    invocation
790        .options
791        .push(Input::new("path", option_value(&parsed, "path")));
792    invocation.options.push(Input::new(
793        "webpackPath",
794        option_value(&parsed, "webpackPath"),
795    ));
796    push_validated_builder(&mut invocation, &parsed)?;
797    invocation
798        .options
799        .push(Input::new("typeCheck", option_value(&parsed, "typeCheck")));
800    invocation.options.push(Input::new(
801        "preserveWatchOutput",
802        Some(InputValue::Bool(
803            bool_option(&parsed, "preserveWatchOutput")
804                && bool_option(&parsed, "watch")
805                && !is_webpack_enabled,
806        )),
807    ));
808    invocation.options.push(Input::new(
809        "all",
810        Some(InputValue::Bool(bool_option(&parsed, "all"))),
811    ));
812    Ok(invocation)
813}
814
815fn build_generate_invocation(parsed: ParsedCommand) -> Result<ActionInvocation, CommandParseError> {
816    let mut invocation = ActionInvocation::for_command(CommandName::Generate);
817    invocation.inputs.push(Input::new(
818        "schematic",
819        string_positional(&parsed, 0).map(InputValue::String),
820    ));
821    invocation.inputs.push(Input::new(
822        "name",
823        string_positional(&parsed, 1).map(InputValue::String),
824    ));
825    invocation.inputs.push(Input::new(
826        "path",
827        string_positional(&parsed, 2).map(InputValue::String),
828    ));
829
830    invocation.options.push(Input::new(
831        "dry-run",
832        Some(InputValue::Bool(bool_option(&parsed, "dry-run"))),
833    ));
834    if parsed.explicit_options.contains("flat") {
835        invocation
836            .options
837            .push(Input::new("flat", option_value(&parsed, "flat")));
838    }
839    invocation.options.push(Input::with_options(
840        "spec",
841        Some(InputValue::Bool(bool_option_default(&parsed, "spec", true))),
842        InputOptions {
843            passed_as_input: parsed.explicit_options.contains("spec"),
844        },
845    ));
846    invocation.options.push(Input::new(
847        "specFileSuffix",
848        option_value(&parsed, "specFileSuffix"),
849    ));
850    invocation.options.push(Input::new(
851        "collection",
852        option_value(&parsed, "collection"),
853    ));
854    invocation
855        .options
856        .push(Input::new("project", option_value(&parsed, "project")));
857    invocation.options.push(Input::new(
858        "skipImport",
859        Some(InputValue::Bool(bool_option(&parsed, "skipImport"))),
860    ));
861    invocation
862        .options
863        .push(Input::new("type", option_value(&parsed, "type")));
864    if let Some(value) = option_value(&parsed, "crud") {
865        let crud_enabled = match &value {
866            InputValue::Bool(value) => *value,
867            InputValue::String(value) => value == "true",
868            InputValue::StringList(values) => !values.is_empty(),
869        };
870        invocation
871            .options
872            .push(Input::new("crud", Some(InputValue::Bool(crud_enabled))));
873    }
874    Ok(invocation)
875}
876
877fn build_info_invocation(parsed: ParsedCommand) -> Result<ActionInvocation, CommandParseError> {
878    let mut invocation = ActionInvocation::for_command(CommandName::Info);
879    invocation.extra_flags = parsed.extra_flags;
880    Ok(invocation)
881}
882
883fn build_new_invocation(parsed: ParsedCommand) -> Result<ActionInvocation, CommandParseError> {
884    let mut invocation = ActionInvocation::for_command(CommandName::New);
885    invocation.inputs.push(Input::new(
886        "name",
887        string_positional(&parsed, 0).map(InputValue::String),
888    ));
889    invocation
890        .options
891        .push(Input::new("directory", option_value(&parsed, "directory")));
892    invocation.options.push(Input::new(
893        "dry-run",
894        Some(InputValue::Bool(bool_option(&parsed, "dry-run"))),
895    ));
896    invocation.options.push(Input::new(
897        "skip-git",
898        Some(InputValue::Bool(bool_option(&parsed, "skip-git"))),
899    ));
900    invocation.options.push(Input::new(
901        "skip-install",
902        Some(InputValue::Bool(bool_option(&parsed, "skip-install"))),
903    ));
904    invocation.options.push(Input::new(
905        "strict",
906        Some(InputValue::Bool(bool_option(&parsed, "strict"))),
907    ));
908    invocation.options.push(Input::new(
909        "packageManager",
910        option_value(&parsed, "packageManager"),
911    ));
912    invocation.options.push(Input::new(
913        "collection",
914        Some(
915            option_value(&parsed, "collection")
916                .unwrap_or_else(|| InputValue::String(String::from("@nestrs/schematics"))),
917        ),
918    ));
919    invocation.options.push(Input::new(
920        "language",
921        Some(InputValue::String(normalize_language(option_value(
922            &parsed, "language",
923        ))?)),
924    ));
925    Ok(invocation)
926}
927
928fn build_start_invocation(parsed: ParsedCommand) -> Result<ActionInvocation, CommandParseError> {
929    let mut invocation = ActionInvocation::for_command(CommandName::Start);
930    invocation.inputs.push(Input::new(
931        "app",
932        string_positional(&parsed, 0).map(InputValue::String),
933    ));
934
935    let is_webpack_enabled = !bool_option(&parsed, "tsc") && bool_option(&parsed, "webpack");
936    invocation
937        .options
938        .push(Input::new("config", option_value(&parsed, "config")));
939    invocation.options.push(Input::new(
940        "webpack",
941        Some(InputValue::Bool(is_webpack_enabled)),
942    ));
943    invocation
944        .options
945        .push(Input::new("debug", option_value(&parsed, "debug")));
946    invocation.options.push(Input::new(
947        "watch",
948        Some(InputValue::Bool(bool_option(&parsed, "watch"))),
949    ));
950    invocation.options.push(Input::new(
951        "watchAssets",
952        Some(InputValue::Bool(bool_option(&parsed, "watchAssets"))),
953    ));
954    invocation
955        .options
956        .push(Input::new("path", option_value(&parsed, "path")));
957    invocation.options.push(Input::new(
958        "webpackPath",
959        option_value(&parsed, "webpackPath"),
960    ));
961    invocation
962        .options
963        .push(Input::new("exec", option_value(&parsed, "exec")));
964    invocation.options.push(Input::new(
965        "sourceRoot",
966        option_value(&parsed, "sourceRoot"),
967    ));
968    invocation
969        .options
970        .push(Input::new("entryFile", option_value(&parsed, "entryFile")));
971    invocation.options.push(Input::new(
972        "preserveWatchOutput",
973        Some(InputValue::Bool(
974            bool_option(&parsed, "preserveWatchOutput")
975                && bool_option(&parsed, "watch")
976                && !is_webpack_enabled,
977        )),
978    ));
979    invocation.options.push(Input::new(
980        "shell",
981        Some(InputValue::Bool(bool_option_default(
982            &parsed, "shell", true,
983        ))),
984    ));
985    invocation.options.push(Input::new(
986        "envFile",
987        Some(
988            option_value(&parsed, "envFile").unwrap_or_else(|| InputValue::StringList(Vec::new())),
989        ),
990    ));
991    push_validated_builder(&mut invocation, &parsed)?;
992    invocation
993        .options
994        .push(Input::new("typeCheck", option_value(&parsed, "typeCheck")));
995    invocation.extra_flags = parsed.extra_flags;
996    Ok(invocation)
997}
998
999fn push_validated_builder(
1000    invocation: &mut ActionInvocation,
1001    parsed: &ParsedCommand,
1002) -> Result<(), CommandParseError> {
1003    if let Some(builder) = option_value(parsed, "builder") {
1004        match &builder {
1005            InputValue::String(value) if value == "cargo" => {
1006                invocation
1007                    .options
1008                    .push(Input::new("builder", Some(builder)));
1009                Ok(())
1010            }
1011            value => Err(CommandParseError::InvalidBuilder(value_to_string(value))),
1012        }
1013    } else {
1014        invocation.options.push(Input::new("builder", None));
1015        Ok(())
1016    }
1017}
1018
1019fn normalize_language(value: Option<InputValue>) -> Result<String, CommandParseError> {
1020    let language = match value {
1021        Some(InputValue::String(value)) => value,
1022        Some(value) => value_to_string(&value),
1023        None => String::from("Rust"),
1024    };
1025    match language.to_lowercase().as_str() {
1026        "rust" | "rs" => Ok(String::from("rs")),
1027        _ => Err(CommandParseError::InvalidLanguage(language)),
1028    }
1029}
1030
1031fn option_value(parsed: &ParsedCommand, name: &'static str) -> Option<InputValue> {
1032    parsed.options.get(name).cloned()
1033}
1034
1035fn bool_option(parsed: &ParsedCommand, name: &'static str) -> bool {
1036    bool_option_default(parsed, name, false)
1037}
1038
1039fn bool_option_default(parsed: &ParsedCommand, name: &'static str, default: bool) -> bool {
1040    match parsed.options.get(name) {
1041        Some(InputValue::Bool(value)) => *value,
1042        Some(InputValue::String(value)) => value == "true",
1043        Some(InputValue::StringList(values)) => !values.is_empty(),
1044        None => default,
1045    }
1046}
1047
1048fn string_positional(parsed: &ParsedCommand, index: usize) -> Option<String> {
1049    parsed.positionals.get(index).cloned()
1050}
1051
1052fn value_to_string(value: &InputValue) -> String {
1053    match value {
1054        InputValue::Bool(value) => value.to_string(),
1055        InputValue::String(value) => value.clone(),
1056        InputValue::StringList(values) => values.join(","),
1057    }
1058}
1059
1060const fn option(
1061    short: char,
1062    long: &'static str,
1063    input_name: &'static str,
1064    value_kind: OptionValueKind,
1065) -> CommandOptionSpec {
1066    CommandOptionSpec {
1067        short: Some(short),
1068        long,
1069        input_name,
1070        value_kind,
1071        default: OptionDefault::None,
1072        negates: None,
1073    }
1074}
1075
1076const fn option_no_short(
1077    long: &'static str,
1078    input_name: &'static str,
1079    value_kind: OptionValueKind,
1080) -> CommandOptionSpec {
1081    CommandOptionSpec {
1082        short: None,
1083        long,
1084        input_name,
1085        value_kind,
1086        default: OptionDefault::None,
1087        negates: None,
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094
1095    #[test]
1096    fn captures_upstream_command_names_in_loader_order() {
1097        let names: Vec<&str> = command_specs()
1098            .iter()
1099            .map(|command| command.name.as_str())
1100            .collect();
1101
1102        assert_eq!(names, ["new", "build", "start", "info", "add", "generate"]);
1103    }
1104
1105    #[test]
1106    fn captures_generate_negated_options() {
1107        let generate = command_spec(CommandName::Generate).expect("generate command");
1108
1109        assert_eq!(generate.aliases, ["g"]);
1110        assert_eq!(
1111            generate.option("no-spec").map(|option| option.negates),
1112            Some(Some("spec"))
1113        );
1114        assert_eq!(
1115            generate.option("no-flat").map(|option| option.negates),
1116            Some(Some("flat"))
1117        );
1118    }
1119
1120    #[test]
1121    fn captures_start_common_runtime_options() {
1122        let start = command_spec(CommandName::Start).expect("start command");
1123
1124        assert!(start.allow_unknown_options);
1125        assert_eq!(
1126            start.option("env-file").map(|option| option.value_kind),
1127            Some(OptionValueKind::StringList)
1128        );
1129        assert_eq!(
1130            start.option("shell").map(|option| option.default),
1131            Some(OptionDefault::Bool(true))
1132        );
1133    }
1134}