1use 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}