1use alloc::{boxed::Box, format, string::String, vec::Vec};
6use cargo_generate::{GenerateArgs, TemplatePath, Vcs, generate};
7use git2::build::RepoBuilder;
8use std::{
9 ffi::OsString,
10 fs, io,
11 path::{Path, PathBuf},
12};
13use thiserror::Error;
14
15pub mod cargo_toml;
16#[cfg(feature = "lint")]
17pub mod lint;
18pub mod manifest_edit;
19pub mod program;
20
21pub const DEFAULT_TEMPLATE_GIT: &str = "https://github.com/asimov-modules/asimov-template-module";
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum TemplateSource {
25 Git(String),
26 Path(PathBuf),
27}
28
29impl Default for TemplateSource {
30 fn default() -> Self {
31 Self::Git(DEFAULT_TEMPLATE_GIT.into())
32 }
33}
34
35#[derive(Clone, Debug)]
36pub struct NewModuleOptions {
37 pub target_dir: PathBuf,
38 pub name: String,
39 pub template: TemplateSource,
40 pub branch: Option<String>,
41 pub package_name: Option<String>,
42 pub package_description: Option<String>,
43 pub package_authors: Vec<String>,
44 pub module_label: Option<String>,
45 pub module_title: Option<String>,
46 pub module_summary: Option<String>,
47 pub program_name: Option<String>,
48 pub extra_programs: Vec<String>,
52 pub repository_url: Option<String>,
53 pub asimov_version: Option<String>,
54 pub publish: bool,
55 pub vcs: Option<String>,
56 pub create_program: bool,
60}
61
62impl NewModuleOptions {
63 pub fn new(target_dir: impl Into<PathBuf>, name: impl Into<String>) -> Self {
64 Self {
65 target_dir: target_dir.into(),
66 name: name.into(),
67 template: TemplateSource::default(),
68 branch: None,
69 package_name: None,
70 package_description: None,
71 package_authors: Vec::new(),
72 module_label: None,
73 module_title: None,
74 module_summary: None,
75 program_name: None,
76 extra_programs: Vec::new(),
77 repository_url: None,
78 asimov_version: Some(env!("CARGO_PKG_VERSION").into()),
79 publish: false,
80 vcs: Some("none".into()),
81 create_program: true,
82 }
83 }
84
85 pub fn template_path(mut self, path: impl Into<PathBuf>) -> Self {
86 self.template = TemplateSource::Path(path.into());
87 self
88 }
89
90 pub fn template_git(mut self, git: impl Into<String>) -> Self {
91 self.template = TemplateSource::Git(git.into());
92 self
93 }
94
95 pub fn without_program(mut self) -> Self {
97 self.create_program = false;
98 self
99 }
100
101 pub fn programs(mut self, names: impl IntoIterator<Item = String>) -> Self {
108 let names: Vec<String> = names.into_iter().collect();
109 if names.is_empty() {
110 return self;
111 }
112 self.create_program = false;
113 self.program_name = None;
114 self.extra_programs = names;
115 self
116 }
117}
118
119#[derive(Debug, Error)]
120pub enum NewModuleError {
121 #[error("module name must not be empty")]
122 EmptyName,
123
124 #[error("module name `{0}` is not supported; use lowercase letters, digits, and hyphens")]
125 InvalidName(String),
126
127 #[error("target directory has no parent: {0}")]
128 MissingTargetParent(PathBuf),
129
130 #[error("target directory has no final path component: {0}")]
131 MissingTargetName(PathBuf),
132
133 #[error("target directory already exists: {0}")]
134 TargetExists(PathBuf),
135
136 #[error("failed to create target parent directory `{0}`: {1}")]
137 CreateTargetParent(PathBuf, #[source] io::Error),
138
139 #[error("failed to run `cargo generate`: {0}")]
140 CargoGenerate(#[source] anyhow::Error),
141
142 #[error(transparent)]
143 InvalidProgramName(#[from] InvalidProgramName),
144
145 #[error("failed to add program `{0}`: {1}")]
146 AddProgram(String, #[source] Box<program::AddProgramError>),
147
148 #[error("failed to create a temporary directory: {0}")]
149 TempDir(#[source] io::Error),
150
151 #[error("failed to clone template `{0}`: {1}")]
152 CloneTemplate(String, #[source] git2::Error),
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, Error)]
162#[error(
163 "program name `{0}` is not supported; use the shape `asimov-<module>-<kind>` (lowercase letters, digits, and hyphens)"
164)]
165pub struct InvalidProgramName(pub String);
166
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct CreatedModule {
169 pub module_name: String,
170 pub crate_name: String,
171 pub program_names: Vec<String>,
174 pub target_dir: PathBuf,
175}
176
177pub fn new_module(options: NewModuleOptions) -> Result<CreatedModule, NewModuleError> {
178 tracing::info!(
179 module = %options.name,
180 target = ?options.target_dir,
181 template = ?options.template,
182 "generating new ASIMOV module"
183 );
184
185 validate_module_name(&options.name)?;
186 let expected_program_prefix = format!("asimov-{}-", options.name);
187 let mut programs_to_create: Vec<String> = Vec::new();
188 if let Some(program_name) = &options.program_name {
189 validate_program_name(program_name)?;
190 if !program_name.starts_with(&expected_program_prefix) {
191 return Err(NewModuleError::InvalidProgramName(InvalidProgramName(
192 program_name.clone(),
193 )));
194 }
195 if !programs_to_create.contains(program_name) {
196 programs_to_create.push(program_name.clone());
197 }
198 }
199 for extra_program in &options.extra_programs {
200 validate_program_name(extra_program)?;
201 if !extra_program.starts_with(&expected_program_prefix) {
202 return Err(NewModuleError::InvalidProgramName(InvalidProgramName(
203 extra_program.clone(),
204 )));
205 }
206 if !programs_to_create.contains(extra_program) {
207 programs_to_create.push(extra_program.clone());
208 }
209 }
210 if programs_to_create.is_empty() && options.create_program {
211 programs_to_create.push(format!("asimov-{}-emitter", options.name));
212 }
213
214 if options.target_dir.exists() {
215 tracing::error!(target = ?options.target_dir, "target directory already exists");
216 return Err(NewModuleError::TargetExists(options.target_dir));
217 }
218
219 let target_parent = options
220 .target_dir
221 .parent()
222 .ok_or_else(|| NewModuleError::MissingTargetParent(options.target_dir.clone()))?;
223 let target_name = target_dir_name(&options.target_dir)?;
224
225 fs::create_dir_all(target_parent)
226 .map_err(|err| NewModuleError::CreateTargetParent(target_parent.into(), err))?;
227
228 let crate_name = options
229 .package_name
230 .clone()
231 .unwrap_or_else(|| format!("asimov-{}-module", options.name));
232 let module_label = options
233 .module_label
234 .clone()
235 .unwrap_or_else(|| title_case_slug(&options.name));
236 let module_title = options
237 .module_title
238 .clone()
239 .unwrap_or_else(|| format!("ASIMOV {module_label} Module"));
240 let package_authors = if options.package_authors.is_empty() {
241 "ASIMOV Community".into()
242 } else {
243 options.package_authors.join(", ")
244 };
245 let repository_url = options
246 .repository_url
247 .clone()
248 .unwrap_or_else(|| format!("https://github.com/asimov-modules/{crate_name}"));
249 let asimov_version = options
250 .asimov_version
251 .clone()
252 .unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
253
254 let (template_dir, _template_clone) = resolve_template(&options)?;
260
261 let mut args = GenerateArgs {
262 template_path: TemplatePath {
263 path: Some(template_dir.to_string_lossy().into_owned()),
264 ..TemplatePath::default()
265 },
266 name: Some(target_name.to_string_lossy().into_owned()),
267 force: true,
268 silent: true,
269 destination: Some(target_parent.into()),
270 vcs: options.vcs.as_deref().map(parse_vcs).transpose()?,
271 no_workspace: true,
272 ..GenerateArgs::default()
273 };
274
275 args.define = [
276 Some(("package_name", crate_name.as_str())),
277 options
278 .package_description
279 .as_deref()
280 .map(|v| ("package_description", v)),
281 Some(("package_authors", package_authors.as_str())),
282 Some(("module_name", options.name.as_str())),
283 Some(("module_label", module_label.as_str())),
284 Some(("module_title", module_title.as_str())),
285 options
286 .module_summary
287 .as_deref()
288 .map(|v| ("module_summary", v)),
289 Some(("repository_url", repository_url.as_str())),
290 Some(("asimov_version", asimov_version.as_str())),
291 Some(("publish", if options.publish { "true" } else { "false" })),
292 ]
293 .into_iter()
294 .flatten()
295 .map(|(key, value)| format!("{key}={value}"))
296 .collect();
297
298 generate(args).map_err(|err| {
299 tracing::error!(error = %err, "cargo-generate failed");
300 NewModuleError::CargoGenerate(err)
301 })?;
302
303 let mut program_names = Vec::new();
308 for program_name in programs_to_create {
309 program::add_program(program::AddProgramOptions {
310 module_dir: options.target_dir.clone(),
311 program_name: program_name.clone(),
312 required_features: Vec::new(),
313 template_path: template_dir.clone(),
314 })
315 .map_err(|err| NewModuleError::AddProgram(program_name.clone(), Box::new(err)))?;
316 program_names.push(program_name);
317 }
318
319 tracing::info!(
320 module = %options.name,
321 crate_name = %crate_name,
322 target = ?options.target_dir,
323 "module generated"
324 );
325
326 Ok(CreatedModule {
327 module_name: options.name,
328 crate_name,
329 program_names,
330 target_dir: options.target_dir,
331 })
332}
333
334fn resolve_template(
343 options: &NewModuleOptions,
344) -> Result<(PathBuf, Option<tempfile::TempDir>), NewModuleError> {
345 match &options.template {
346 TemplateSource::Path(path) => Ok((path.clone(), None)),
347 TemplateSource::Git(url) => {
348 let scratch = tempfile::tempdir().map_err(NewModuleError::TempDir)?;
349 let mut builder = RepoBuilder::new();
350 if let Some(branch) = &options.branch {
351 builder.branch(branch);
352 }
353 builder
354 .clone(url, scratch.path())
355 .map_err(|err| NewModuleError::CloneTemplate(url.clone(), err))?;
356 let path = scratch.path().to_path_buf();
357 Ok((path, Some(scratch)))
358 },
359 }
360}
361
362fn parse_vcs(vcs: &str) -> Result<Vcs, NewModuleError> {
363 vcs.parse().map_err(NewModuleError::CargoGenerate)
364}
365
366fn validate_module_name(name: &str) -> Result<(), NewModuleError> {
367 if name.is_empty() {
368 return Err(NewModuleError::EmptyName);
369 }
370
371 if !name.as_bytes()[0].is_ascii_alphanumeric() {
372 return Err(NewModuleError::InvalidName(name.into()));
373 }
374
375 if name.ends_with('-') {
376 return Err(NewModuleError::InvalidName(name.into()));
377 }
378
379 let valid = name
380 .bytes()
381 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
382 if valid {
383 Ok(())
384 } else {
385 Err(NewModuleError::InvalidName(name.into()))
386 }
387}
388
389fn validate_program_name(name: &str) -> Result<(), InvalidProgramName> {
390 let Some(rest) = name.strip_prefix("asimov-") else {
391 return Err(InvalidProgramName(name.into()));
392 };
393
394 if rest.is_empty() || rest.starts_with('-') || rest.ends_with('-') || !rest.contains('-') {
395 return Err(InvalidProgramName(name.into()));
396 }
397
398 let valid = rest
399 .bytes()
400 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
401 if valid {
402 Ok(())
403 } else {
404 Err(InvalidProgramName(name.into()))
405 }
406}
407
408pub(crate) fn program_kind_of(program_name: &str) -> &str {
411 program_name.rsplit('-').next().unwrap_or(program_name)
412}
413
414fn target_dir_name(target_dir: &Path) -> Result<OsString, NewModuleError> {
415 target_dir
416 .file_name()
417 .map(OsString::from)
418 .ok_or_else(|| NewModuleError::MissingTargetName(target_dir.into()))
419}
420
421fn title_case_slug(slug: &str) -> String {
422 slug.split('-')
423 .filter(|part| !part.is_empty())
424 .map(|part| {
425 let mut chars = part.chars();
426 match chars.next() {
427 Some(first) => {
428 let mut word = first.to_uppercase().collect::<String>();
429 word.push_str(chars.as_str());
430 word
431 },
432 None => String::new(),
433 }
434 })
435 .collect::<Vec<_>>()
436 .join(" ")
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use alloc::string::ToString;
443 use tempfile::tempdir;
444
445 #[test]
446 fn rejects_invalid_module_names() {
447 assert!(matches!(
448 new_module(NewModuleOptions::new("target", "")),
449 Err(NewModuleError::EmptyName)
450 ));
451 assert!(matches!(
452 new_module(NewModuleOptions::new("target", "Not Valid")),
453 Err(NewModuleError::InvalidName(_))
454 ));
455 }
456
457 #[test]
458 fn rejects_invalid_program_name() {
459 let mut options = NewModuleOptions::new("target", "widget");
460 options.program_name = Some("Not-Valid".into());
461 assert!(matches!(
462 new_module(options),
463 Err(NewModuleError::InvalidProgramName(_))
464 ));
465 }
466
467 #[test]
468 fn rejects_program_name_not_matching_module() {
469 let mut options = NewModuleOptions::new("target", "widget");
470 options.program_name = Some("asimov-other-fetcher".into());
471 assert!(matches!(
472 new_module(options),
473 Err(NewModuleError::InvalidProgramName(_))
474 ));
475 }
476
477 #[test]
478 fn validates_program_name_shape() {
479 assert!(validate_program_name("asimov-widget-emitter").is_ok());
480 assert!(validate_program_name("asimov-widget-multi-word-kind").is_ok());
481 assert!(validate_program_name("asimov-widget-whatever-custom-kind").is_ok());
483
484 assert!(validate_program_name("").is_err());
485 assert!(validate_program_name("widget-emitter").is_err());
486 assert!(validate_program_name("asimov-widget").is_err());
487 assert!(validate_program_name("asimov-Widget-Emitter").is_err());
488 assert!(validate_program_name("asimov-widget-emitter-").is_err());
489 assert!(validate_program_name("asimov-widgetemitter").is_err());
490 }
491
492 fn fixture_program_template(dir: &std::path::Path) {
493 fs::create_dir_all(dir.join(".template")).unwrap();
494 fs::write(
495 dir.join(".template/program.rs.liquid"),
496 "// {{ program_name }} ({{ program_kind }})\nfn main() {}\n",
497 )
498 .unwrap();
499 }
500
501 #[test]
502 fn generates_module_from_local_template() {
503 let template_dir = tempdir().unwrap();
504 fs::write(
505 template_dir.path().join("Cargo.toml"),
506 "[package]\nname = \"{{package_name}}\"\ndescription = \"{{package_description}}\"\n",
507 )
508 .unwrap();
509 fs::create_dir(template_dir.path().join("src")).unwrap();
510 fs::write(
511 template_dir.path().join("src/lib.rs"),
512 "// {{module_title}}\n",
513 )
514 .unwrap();
515 fixture_program_template(template_dir.path());
516
517 let workspace = tempdir().unwrap();
518 let target_dir = workspace.path().join("widget-module");
519
520 let mut options =
521 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
522 options.package_description = Some("A widget module.".into());
523 let created = new_module(options).unwrap();
524
525 assert_eq!(created.crate_name, "asimov-widget-module");
526 assert_eq!(created.program_names, ["asimov-widget-emitter"]);
527 assert_eq!(created.target_dir, target_dir);
528
529 let cargo_toml = fs::read_to_string(target_dir.join("Cargo.toml")).unwrap();
530 assert!(cargo_toml.contains("name = \"asimov-widget-module\""));
531 assert!(cargo_toml.contains("description = \"A widget module.\""));
532 assert!(cargo_toml.contains("name = \"asimov-widget-emitter\""));
533 assert!(cargo_toml.contains("path = \"src/emitter/main.rs\""));
534
535 let lib_rs = fs::read_to_string(target_dir.join("src/lib.rs")).unwrap();
536 assert!(lib_rs.contains("ASIMOV Widget Module"));
537
538 let program_rs = fs::read_to_string(target_dir.join("src/emitter/main.rs")).unwrap();
539 assert!(program_rs.contains("asimov-widget-emitter"));
540 }
541
542 #[test]
543 fn refuses_to_overwrite_existing_target() {
544 let workspace = tempdir().unwrap();
545 let target_dir = workspace.path().join("existing");
546 fs::create_dir(&target_dir).unwrap();
547
548 let options = NewModuleOptions::new(&target_dir, "widget");
549 assert!(matches!(
550 new_module(options),
551 Err(NewModuleError::TargetExists(_))
552 ));
553 }
554
555 #[test]
556 fn passes_program_kind_for_a_custom_program_name() {
557 let template_dir = tempdir().unwrap();
558 fs::write(
559 template_dir.path().join("Cargo.toml"),
560 "[package]\nname = \"{{package_name}}\"\n",
561 )
562 .unwrap();
563 fs::create_dir(template_dir.path().join("src")).unwrap();
564 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
565 fixture_program_template(template_dir.path());
566
567 let workspace = tempdir().unwrap();
568 let target_dir = workspace.path().join("widget-module");
569
570 let mut options =
571 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
572 options.program_name = Some("asimov-widget-fetcher".into());
573 new_module(options).unwrap();
574
575 let program_rs = fs::read_to_string(target_dir.join("src/fetcher/main.rs")).unwrap();
576 assert!(program_rs.contains("(fetcher)"));
577 }
578
579 #[test]
580 fn passes_full_program_kind_for_a_multi_hyphen_kind() {
581 let template_dir = tempdir().unwrap();
582 fs::write(
583 template_dir.path().join("Cargo.toml"),
584 "[package]\nname = \"{{package_name}}\"\n",
585 )
586 .unwrap();
587 fs::create_dir(template_dir.path().join("src")).unwrap();
588 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
589 fixture_program_template(template_dir.path());
590
591 let workspace = tempdir().unwrap();
592 let target_dir = workspace.path().join("widget-module");
593
594 let mut options =
595 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
596 options.program_name = Some("asimov-widget-custom-multi-word-kind".into());
599 new_module(options).unwrap();
600
601 let program_rs =
602 fs::read_to_string(target_dir.join("src/custom-multi-word-kind/main.rs")).unwrap();
603 assert!(program_rs.contains("(custom-multi-word-kind)"));
604 }
605
606 #[test]
607 fn creates_module_without_a_program() {
608 let template_dir = tempdir().unwrap();
609 fs::write(
610 template_dir.path().join("Cargo.toml"),
611 "[package]\nname = \"{{package_name}}\"\n",
612 )
613 .unwrap();
614 fs::create_dir(template_dir.path().join("src")).unwrap();
615 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
616 fixture_program_template(template_dir.path());
617
618 let workspace = tempdir().unwrap();
619 let target_dir = workspace.path().join("bare-module");
620
621 let options = NewModuleOptions::new(&target_dir, "bare")
622 .template_path(template_dir.path())
623 .without_program();
624 let created = new_module(options).unwrap();
625
626 assert!(created.program_names.is_empty());
627 assert!(!target_dir.join("src/emitter").exists());
628 let cargo_toml = fs::read_to_string(target_dir.join("Cargo.toml")).unwrap();
629 assert!(!cargo_toml.contains("[[bin]]"));
630 }
631
632 #[test]
633 fn creates_module_with_multiple_programs() {
634 let template_dir = tempdir().unwrap();
635 fs::write(
636 template_dir.path().join("Cargo.toml"),
637 "[package]\nname = \"{{package_name}}\"\n",
638 )
639 .unwrap();
640 fs::create_dir(template_dir.path().join("src")).unwrap();
641 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
642 fixture_program_template(template_dir.path());
643
644 let workspace = tempdir().unwrap();
645 let target_dir = workspace.path().join("widget-module");
646
647 let options = NewModuleOptions::new(&target_dir, "widget")
648 .template_path(template_dir.path())
649 .programs([
650 "asimov-widget-emitter".to_string(),
651 "asimov-widget-fetcher".to_string(),
652 "asimov-widget-importer".to_string(),
653 ]);
654 let created = new_module(options).unwrap();
655
656 assert_eq!(
657 created.program_names,
658 [
659 "asimov-widget-emitter",
660 "asimov-widget-fetcher",
661 "asimov-widget-importer"
662 ]
663 );
664
665 let cargo_toml = fs::read_to_string(target_dir.join("Cargo.toml")).unwrap();
666 assert_eq!(cargo_toml.matches("[[bin]]").count(), 3);
667 assert!(cargo_toml.contains("name = \"asimov-widget-fetcher\""));
668 assert!(cargo_toml.contains("name = \"asimov-widget-importer\""));
669 assert!(target_dir.join("src/emitter/main.rs").exists());
670 assert!(target_dir.join("src/fetcher/main.rs").exists());
671 assert!(target_dir.join("src/importer/main.rs").exists());
672 }
673
674 #[test]
675 fn deduplicates_programs_to_create() {
676 let template_dir = tempdir().unwrap();
677 fs::write(
678 template_dir.path().join("Cargo.toml"),
679 "[package]\nname = \"{{package_name}}\"\n",
680 )
681 .unwrap();
682 fs::create_dir(template_dir.path().join("src")).unwrap();
683 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
684 fixture_program_template(template_dir.path());
685
686 let workspace = tempdir().unwrap();
687 let target_dir = workspace.path().join("widget-module");
688
689 let mut options =
692 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
693 options.program_name = Some("asimov-widget-fetcher".into());
694 options.extra_programs = [
695 "asimov-widget-fetcher".to_string(),
696 "asimov-widget-importer".to_string(),
697 ]
698 .into();
699 let created = new_module(options).unwrap();
700
701 assert_eq!(
702 created.program_names,
703 ["asimov-widget-fetcher", "asimov-widget-importer"]
704 );
705 }
706
707 #[test]
708 fn rejects_extra_program_not_matching_module() {
709 let template_dir = tempdir().unwrap();
710 fs::write(
711 template_dir.path().join("Cargo.toml"),
712 "[package]\nname = \"{{package_name}}\"\n",
713 )
714 .unwrap();
715 fs::create_dir(template_dir.path().join("src")).unwrap();
716 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
717
718 let workspace = tempdir().unwrap();
719 let target_dir = workspace.path().join("widget-module");
720
721 let mut options = NewModuleOptions::new(&target_dir, "widget")
722 .template_path(template_dir.path())
723 .without_program();
724 options.extra_programs = ["asimov-other-fetcher".to_string()].into();
725
726 assert!(matches!(
727 new_module(options),
728 Err(NewModuleError::InvalidProgramName(_))
729 ));
730 assert!(!target_dir.exists());
731 }
732
733 #[test]
734 #[ignore = "network smoke test against the real default template; run manually"]
735 fn smoke_test_default_git_template_with_multiple_programs() {
736 let workspace = tempdir().unwrap();
737 let target_dir = workspace.path().join("widget-module");
738
739 let options = NewModuleOptions::new(&target_dir, "widget").programs([
743 "asimov-widget-fetcher".to_string(),
744 "asimov-widget-importer".to_string(),
745 ]);
746 let created = new_module(options).unwrap();
747
748 assert_eq!(
749 created.program_names,
750 ["asimov-widget-fetcher", "asimov-widget-importer"]
751 );
752 assert!(target_dir.join("src/fetcher/main.rs").exists());
753 assert!(target_dir.join("src/importer/main.rs").exists());
754 }
755}