Skip to main content

cli/lib/schematics/
mod.rs

1//! Schematic collection discovery and command construction abstractions.
2//!
3//! Rust-native schematic collection discovery and command construction.
4
5use std::collections::BTreeSet;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::utils::normalize_to_kebab_or_snake_case;
10use serde_json::Value;
11
12pub mod abstract_collection;
13pub mod collection;
14pub mod collection_factory;
15pub mod custom_collection;
16pub mod nest_collection;
17pub mod schematic_option;
18
19pub const NESTRS_COLLECTION_NAME: &str = "@nestrs/schematics";
20pub const NESTJS_COLLECTION_NAME: &str = NESTRS_COLLECTION_NAME;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Collection {
24    Nestjs,
25    Custom(String),
26}
27
28impl Collection {
29    pub fn from_name(name: impl Into<String>) -> Self {
30        let name = name.into();
31        if name == NESTRS_COLLECTION_NAME {
32            Self::Nestjs
33        } else {
34            Self::Custom(name)
35        }
36    }
37
38    pub fn as_str(&self) -> &str {
39        match self {
40            Self::Nestjs => NESTRS_COLLECTION_NAME,
41            Self::Custom(name) => name,
42        }
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum SchematicOptionValue {
48    Bool(bool),
49    String(String),
50}
51
52impl From<bool> for SchematicOptionValue {
53    fn from(value: bool) -> Self {
54        Self::Bool(value)
55    }
56}
57
58impl From<&str> for SchematicOptionValue {
59    fn from(value: &str) -> Self {
60        Self::String(value.to_string())
61    }
62}
63
64impl From<String> for SchematicOptionValue {
65    fn from(value: String) -> Self {
66        Self::String(value)
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct SchematicOption {
72    pub name: String,
73    pub value: SchematicOptionValue,
74}
75
76impl SchematicOption {
77    pub fn new(name: impl Into<String>, value: impl Into<SchematicOptionValue>) -> Self {
78        Self {
79            name: name.into(),
80            value: value.into(),
81        }
82    }
83
84    pub fn normalized_name(&self) -> String {
85        normalize_to_kebab_or_snake_case(&self.name)
86    }
87
88    pub fn to_command_string(&self) -> String {
89        let normalized_name = self.normalized_name();
90
91        match &self.value {
92            SchematicOptionValue::Bool(true) => format!("--{normalized_name}"),
93            SchematicOptionValue::Bool(false) => format!("--no-{normalized_name}"),
94            SchematicOptionValue::String(value) if self.name == "name" => {
95                format!("--{normalized_name}={}", format_name_value(value))
96            }
97            SchematicOptionValue::String(value)
98                if self.name == "version" || self.name == "path" =>
99            {
100                format!("--{normalized_name}={value}")
101            }
102            SchematicOptionValue::String(value) => format!("--{normalized_name}=\"{value}\""),
103        }
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Schematic {
109    pub name: String,
110    pub alias: String,
111    pub description: String,
112}
113
114impl Schematic {
115    pub fn new(
116        name: impl Into<String>,
117        alias: impl Into<String>,
118        description: impl Into<String>,
119    ) -> Self {
120        Self {
121            name: name.into(),
122            alias: alias.into(),
123            description: description.into(),
124        }
125    }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct CollectionSchematic {
130    pub schema: Option<String>,
131    pub description: String,
132    pub aliases: Vec<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct CollectionDescription {
137    pub path: Option<PathBuf>,
138    pub extends: Vec<String>,
139    pub schematics: Vec<(String, CollectionSchematic)>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct AbstractCollection {
144    collection: String,
145}
146
147impl AbstractCollection {
148    pub fn new(collection: impl Into<String>) -> Self {
149        Self {
150            collection: collection.into(),
151        }
152    }
153
154    pub fn collection(&self) -> &str {
155        &self.collection
156    }
157
158    pub fn build_command_line(&self, name: &str, options: &[SchematicOption]) -> String {
159        format!("{}:{name}{}", self.collection, Self::build_options(options))
160    }
161
162    pub fn build_command_line_with_extra_flags(
163        &self,
164        name: &str,
165        options: &[SchematicOption],
166        extra_flags: Option<&str>,
167    ) -> String {
168        let mut command = self.build_command_line(name, options);
169        if let Some(extra_flags) = extra_flags.filter(|flags| !flags.is_empty()) {
170            command.push(' ');
171            command.push_str(extra_flags);
172        }
173        command
174    }
175
176    fn build_options(options: &[SchematicOption]) -> String {
177        options.iter().fold(String::new(), |mut line, option| {
178            line.push(' ');
179            line.push_str(&option.to_command_string());
180            line
181        })
182    }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct NestCollection {
187    base: AbstractCollection,
188}
189
190impl NestCollection {
191    pub fn new() -> Self {
192        Self {
193            base: AbstractCollection::new(NESTJS_COLLECTION_NAME),
194        }
195    }
196
197    pub fn execute_command(
198        &self,
199        name: &str,
200        options: &[SchematicOption],
201    ) -> Result<String, String> {
202        let schematic = self.validate(name)?;
203        Ok(self.base.build_command_line(&schematic, options))
204    }
205
206    pub fn get_schematics(&self) -> Vec<Schematic> {
207        nest_schematics()
208            .into_iter()
209            .filter(|schematic| schematic.name != "angular-app")
210            .collect()
211    }
212
213    pub fn validate(&self, name: &str) -> Result<String, String> {
214        nest_schematics()
215            .into_iter()
216            .find(|schematic| schematic.name == name || schematic.alias == name)
217            .map(|schematic| schematic.name)
218            .ok_or_else(|| {
219                format!(
220                    "Invalid schematic \"{name}\". Please, ensure that \"{name}\" exists in this collection."
221                )
222            })
223    }
224}
225
226impl Default for NestCollection {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct CustomCollection {
234    base: AbstractCollection,
235    descriptions: Vec<CollectionDescription>,
236}
237
238impl CustomCollection {
239    pub fn new(collection: impl Into<String>, descriptions: Vec<CollectionDescription>) -> Self {
240        Self {
241            base: AbstractCollection::new(collection),
242            descriptions,
243        }
244    }
245
246    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
247        let path = path.as_ref();
248        let descriptions = CollectionDiscovery::discover_collection_descriptions(path)?;
249        Ok(Self::new(path.to_string_lossy().into_owned(), descriptions))
250    }
251
252    pub fn from_package(
253        package_name: &str,
254        module_paths: impl IntoIterator<Item = impl AsRef<Path>>,
255    ) -> Result<Self, String> {
256        let path =
257            CollectionDiscovery::resolve_package_collection_path(package_name, module_paths)?;
258        let descriptions = CollectionDiscovery::discover_collection_descriptions(&path)?;
259        Ok(Self::new(package_name, descriptions))
260    }
261
262    pub fn execute_command(&self, name: &str, options: &[SchematicOption]) -> String {
263        self.base.build_command_line(name, options)
264    }
265
266    pub fn execute_command_with_extra_flags(
267        &self,
268        name: &str,
269        options: &[SchematicOption],
270        extra_flags: Option<&str>,
271    ) -> String {
272        self.base
273            .build_command_line_with_extra_flags(name, options, extra_flags)
274    }
275
276    pub fn get_schematics(&self) -> Vec<Schematic> {
277        flatten_collection_descriptions(&self.descriptions)
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum CollectionInstance {
283    Nest(NestCollection),
284    Custom(CustomCollection),
285}
286
287impl CollectionInstance {
288    pub fn get_schematics(&self) -> Vec<Schematic> {
289        match self {
290            Self::Nest(collection) => collection.get_schematics(),
291            Self::Custom(collection) => collection.get_schematics(),
292        }
293    }
294
295    pub fn execute_command(
296        &self,
297        name: &str,
298        options: &[SchematicOption],
299    ) -> Result<String, String> {
300        match self {
301            Self::Nest(collection) => collection.execute_command(name, options),
302            Self::Custom(collection) => Ok(collection.execute_command(name, options)),
303        }
304    }
305}
306
307pub struct CollectionFactory;
308
309impl CollectionFactory {
310    pub fn create(collection: impl Into<String>) -> CollectionInstance {
311        match Collection::from_name(collection) {
312            Collection::Nestjs => CollectionInstance::Nest(NestCollection::new()),
313            Collection::Custom(name) => CollectionInstance::Custom(
314                CustomCollection::from_name_or_empty(&name, default_module_paths())
315                    .unwrap_or_else(|_| CustomCollection::new(name, Vec::new())),
316            ),
317        }
318    }
319}
320
321pub struct CollectionDiscovery;
322
323impl CollectionDiscovery {
324    pub fn discover_collection_descriptions(
325        path: impl AsRef<Path>,
326    ) -> Result<Vec<CollectionDescription>, String> {
327        let mut visited = BTreeSet::new();
328        discover_collection_descriptions(path.as_ref(), &mut visited)
329    }
330
331    pub fn resolve_package_collection_path(
332        package_name: &str,
333        module_paths: impl IntoIterator<Item = impl AsRef<Path>>,
334    ) -> Result<PathBuf, String> {
335        for module_path in module_paths {
336            let package_path = module_path.as_ref().join(package_name);
337            let package_json_path = package_path.join("package.json");
338            if !package_json_path.is_file() {
339                continue;
340            }
341
342            let content = fs::read_to_string(&package_json_path).map_err(|error| {
343                format!("Failed to read {}: {error}", package_json_path.display())
344            })?;
345            let schematics = extract_string_field(&content, "schematics")
346                .ok_or_else(|| format!("Package \"{package_name}\" does not declare schematics"))?;
347            return Ok(package_path.join(schematics));
348        }
349
350        Err(format!("Package \"{package_name}\" could not be resolved"))
351    }
352}
353
354fn discover_collection_descriptions(
355    path: &Path,
356    visited: &mut BTreeSet<PathBuf>,
357) -> Result<Vec<CollectionDescription>, String> {
358    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
359    if !visited.insert(canonical) {
360        return Ok(Vec::new());
361    }
362
363    let content = fs::read_to_string(path)
364        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
365    let mut description = parse_collection_description(&content)?;
366    description.path = Some(path.to_path_buf());
367
368    let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
369    let mut descriptions = vec![description.clone()];
370    for base in &description.extends {
371        let base_path = base_dir.join(base);
372        descriptions.extend(discover_collection_descriptions(&base_path, visited)?);
373    }
374    Ok(descriptions)
375}
376
377fn parse_collection_description(content: &str) -> Result<CollectionDescription, String> {
378    let value = serde_json::from_str::<Value>(content)
379        .map_err(|error| format!("Invalid collection JSON: {error}"))?;
380    let extends = string_array_value(value.get("extends"));
381    let schematics_value = value
382        .get("schematics")
383        .and_then(Value::as_object)
384        .ok_or_else(|| "Collection does not declare schematics".to_string())?;
385    let mut schematics = Vec::new();
386
387    for (name, body) in schematics_value {
388        let description = body
389            .get("description")
390            .and_then(Value::as_str)
391            .unwrap_or_default()
392            .to_string();
393        let aliases = string_array_value(body.get("aliases"));
394        let schema = body
395            .get("schema")
396            .and_then(Value::as_str)
397            .map(ToString::to_string);
398        schematics.push((
399            name.clone(),
400            CollectionSchematic {
401                schema,
402                description,
403                aliases,
404            },
405        ));
406    }
407
408    Ok(CollectionDescription {
409        path: None,
410        extends,
411        schematics,
412    })
413}
414
415impl CustomCollection {
416    pub fn from_name_or_empty(
417        name: &str,
418        module_paths: impl IntoIterator<Item = impl AsRef<Path>>,
419    ) -> Result<Self, String> {
420        let path = Path::new(name);
421        if path.is_file() {
422            return Self::from_path(path);
423        }
424        Self::from_package(name, module_paths)
425    }
426}
427
428fn default_module_paths() -> Vec<PathBuf> {
429    let mut paths = Vec::new();
430    if let Ok(cwd) = std::env::current_dir() {
431        for directory in cwd.ancestors() {
432            paths.push(directory.join("node_modules"));
433        }
434    }
435    paths
436}
437
438fn string_array_value(value: Option<&Value>) -> Vec<String> {
439    match value {
440        Some(Value::String(value)) => vec![value.clone()],
441        Some(Value::Array(values)) => values
442            .iter()
443            .filter_map(Value::as_str)
444            .map(ToString::to_string)
445            .collect(),
446        _ => Vec::new(),
447    }
448}
449
450fn flatten_collection_descriptions(descriptions: &[CollectionDescription]) -> Vec<Schematic> {
451    let mut used_names = BTreeSet::new();
452    let mut schematics = Vec::new();
453
454    for description in descriptions {
455        for (name, collection_schematic) in &description.schematics {
456            if used_names.contains(name) {
457                continue;
458            }
459            used_names.insert(name.clone());
460            let alias = collection_schematic
461                .aliases
462                .iter()
463                .find(|alias| !used_names.contains(*alias))
464                .cloned()
465                .unwrap_or_else(|| name.clone());
466            for alias in &collection_schematic.aliases {
467                used_names.insert(alias.clone());
468            }
469            schematics.push(Schematic::new(
470                name,
471                alias,
472                &collection_schematic.description,
473            ));
474        }
475    }
476
477    schematics.sort_by(|left, right| left.name.cmp(&right.name));
478    schematics
479}
480
481fn nest_schematics() -> Vec<Schematic> {
482    [
483        (
484            "application",
485            "application",
486            "Generate a new application workspace",
487        ),
488        ("angular-app", "ng-app", ""),
489        ("class", "cl", "Generate a new class"),
490        (
491            "configuration",
492            "config",
493            "Generate a CLI configuration file",
494        ),
495        ("controller", "co", "Generate a controller declaration"),
496        ("decorator", "d", "Generate a custom decorator"),
497        ("filter", "f", "Generate a filter declaration"),
498        ("gateway", "ga", "Generate a gateway declaration"),
499        ("guard", "gu", "Generate a guard declaration"),
500        ("interceptor", "itc", "Generate an interceptor declaration"),
501        ("interface", "itf", "Generate an interface"),
502        ("library", "lib", "Generate a new library within a monorepo"),
503        ("middleware", "mi", "Generate a middleware declaration"),
504        ("module", "mo", "Generate a module declaration"),
505        ("pipe", "pi", "Generate a pipe declaration"),
506        ("provider", "pr", "Generate a provider declaration"),
507        ("resolver", "r", "Generate a GraphQL resolver declaration"),
508        ("resource", "res", "Generate a new CRUD resource"),
509        ("service", "s", "Generate a service declaration"),
510        (
511            "sub-app",
512            "app",
513            "Generate a new application within a monorepo",
514        ),
515    ]
516    .into_iter()
517    .map(|(name, alias, description)| Schematic::new(name, alias, description))
518    .collect()
519}
520
521fn format_name_value(value: &str) -> String {
522    normalize_to_kebab_or_snake_case(value)
523        .chars()
524        .fold(String::new(), |mut output, character| {
525            if matches!(character, '(' | ')' | '[' | ']') {
526                output.push('\\');
527            }
528            output.push(character);
529            output
530        })
531}
532
533fn extract_string_field(content: &str, key: &str) -> Option<String> {
534    let index = find_json_key(content, key)?;
535    let after_colon = content[index..].split_once(':')?.1.trim_start();
536    parse_json_string(after_colon).map(|(value, _)| value)
537}
538
539fn find_json_key(content: &str, key: &str) -> Option<usize> {
540    content.find(&format!("\"{key}\""))
541}
542
543fn parse_json_string(content: &str) -> Option<(String, usize)> {
544    let mut chars = content.char_indices();
545    if chars.next()?.1 != '"' {
546        return None;
547    }
548
549    let mut escaped = false;
550    let mut value = String::new();
551    for (index, character) in chars {
552        if escaped {
553            value.push(character);
554            escaped = false;
555            continue;
556        }
557        if character == '\\' {
558            escaped = true;
559            continue;
560        }
561        if character == '"' {
562            return Some((value, index + character.len_utf8()));
563        }
564        value.push(character);
565    }
566    None
567}