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(
125 "module name `{0}` is not supported; use at most 64 lowercase letters, digits, and hyphens, starting with a letter and ending without a hyphen"
126 )]
127 InvalidName(String),
128
129 #[error("target directory has no parent: {0}")]
130 MissingTargetParent(PathBuf),
131
132 #[error("target directory has no final path component: {0}")]
133 MissingTargetName(PathBuf),
134
135 #[error("target directory already exists: {0}")]
136 TargetExists(PathBuf),
137
138 #[error("failed to create target parent directory `{0}`: {1}")]
139 CreateTargetParent(PathBuf, #[source] io::Error),
140
141 #[error("failed to run `cargo generate`: {0}")]
142 CargoGenerate(#[source] anyhow::Error),
143
144 #[error(transparent)]
145 InvalidProgramName(#[from] InvalidProgramName),
146
147 #[error("failed to add program `{0}`: {1}")]
148 AddProgram(String, #[source] Box<program::AddProgramError>),
149
150 #[error("failed to create a temporary directory: {0}")]
151 TempDir(#[source] io::Error),
152
153 #[error("failed to clone template `{0}`: {1}")]
154 CloneTemplate(String, #[source] git2::Error),
155}
156
157#[derive(Clone, Debug, PartialEq, Eq, Error)]
164#[error(
165 "program name `{0}` is not supported; use the shape `asimov-<module>-<kind>` (lowercase letters, digits, and hyphens)"
166)]
167pub struct InvalidProgramName(pub String);
168
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct CreatedModule {
171 pub module_name: String,
172 pub crate_name: String,
173 pub program_names: Vec<String>,
176 pub target_dir: PathBuf,
177}
178
179pub fn new_module(options: NewModuleOptions) -> Result<CreatedModule, NewModuleError> {
180 #[cfg(feature = "tracing")]
181 tracing::info!(
182 module = %options.name,
183 target = ?options.target_dir,
184 template = ?options.template,
185 "generating new ASIMOV module"
186 );
187
188 validate_module_name(&options.name)?;
189 let expected_program_prefix = format!("asimov-{}-", options.name);
190 let mut programs_to_create: Vec<String> = Vec::new();
191 if let Some(program_name) = &options.program_name {
192 validate_program_name(program_name)?;
193 if !program_name.starts_with(&expected_program_prefix) {
194 return Err(NewModuleError::InvalidProgramName(InvalidProgramName(
195 program_name.clone(),
196 )));
197 }
198 if !programs_to_create.contains(program_name) {
199 programs_to_create.push(program_name.clone());
200 }
201 }
202 for extra_program in &options.extra_programs {
203 validate_program_name(extra_program)?;
204 if !extra_program.starts_with(&expected_program_prefix) {
205 return Err(NewModuleError::InvalidProgramName(InvalidProgramName(
206 extra_program.clone(),
207 )));
208 }
209 if !programs_to_create.contains(extra_program) {
210 programs_to_create.push(extra_program.clone());
211 }
212 }
213 if programs_to_create.is_empty() && options.create_program {
214 programs_to_create.push(format!("asimov-{}-emitter", options.name));
215 }
216
217 if options.target_dir.exists() {
218 #[cfg(feature = "tracing")]
219 tracing::error!(target = ?options.target_dir, "target directory already exists");
220 return Err(NewModuleError::TargetExists(options.target_dir));
221 }
222
223 let target_parent = options
224 .target_dir
225 .parent()
226 .ok_or_else(|| NewModuleError::MissingTargetParent(options.target_dir.clone()))?;
227 let target_name = target_dir_name(&options.target_dir)?;
228
229 fs::create_dir_all(target_parent)
230 .map_err(|err| NewModuleError::CreateTargetParent(target_parent.into(), err))?;
231
232 let crate_name = options
233 .package_name
234 .clone()
235 .unwrap_or_else(|| format!("asimov-{}-module", options.name));
236 let module_label = options
237 .module_label
238 .clone()
239 .unwrap_or_else(|| title_case_slug(&options.name));
240 let module_title = options
241 .module_title
242 .clone()
243 .unwrap_or_else(|| format!("ASIMOV {module_label} Module"));
244 let package_authors = if options.package_authors.is_empty() {
245 "ASIMOV Community".into()
246 } else {
247 options.package_authors.join(", ")
248 };
249 let repository_url = options
250 .repository_url
251 .clone()
252 .unwrap_or_else(|| format!("https://github.com/asimov-modules/{crate_name}"));
253 let asimov_version = options
254 .asimov_version
255 .clone()
256 .unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
257
258 let (template_dir, _template_clone) = resolve_template(&options)?;
264
265 let mut args = GenerateArgs {
266 template_path: TemplatePath {
267 path: Some(template_dir.to_string_lossy().into_owned()),
268 ..TemplatePath::default()
269 },
270 name: Some(target_name.to_string_lossy().into_owned()),
271 force: true,
272 silent: true,
273 destination: Some(target_parent.into()),
274 vcs: options.vcs.as_deref().map(parse_vcs).transpose()?,
275 no_workspace: true,
276 ..GenerateArgs::default()
277 };
278
279 args.define = [
280 Some(("package_name", crate_name.as_str())),
281 options
282 .package_description
283 .as_deref()
284 .map(|v| ("package_description", v)),
285 Some(("package_authors", package_authors.as_str())),
286 Some(("module_name", options.name.as_str())),
287 Some(("module_label", module_label.as_str())),
288 Some(("module_title", module_title.as_str())),
289 options
290 .module_summary
291 .as_deref()
292 .map(|v| ("module_summary", v)),
293 Some(("repository_url", repository_url.as_str())),
294 Some(("asimov_version", asimov_version.as_str())),
295 Some(("publish", if options.publish { "true" } else { "false" })),
296 ]
297 .into_iter()
298 .flatten()
299 .map(|(key, value)| format!("{key}={value}"))
300 .collect();
301
302 generate(args).map_err(|err| {
303 #[cfg(feature = "tracing")]
304 tracing::error!(error = %err, "cargo-generate failed");
305 NewModuleError::CargoGenerate(err)
306 })?;
307
308 let mut program_names = Vec::new();
313 for program_name in programs_to_create {
314 program::add_program(program::AddProgramOptions {
315 module_dir: options.target_dir.clone(),
316 program_name: program_name.clone(),
317 required_features: Vec::new(),
318 template_path: template_dir.clone(),
319 })
320 .map_err(|err| NewModuleError::AddProgram(program_name.clone(), Box::new(err)))?;
321 program_names.push(program_name);
322 }
323
324 #[cfg(feature = "tracing")]
325 tracing::info!(
326 module = %options.name,
327 crate_name = %crate_name,
328 target = ?options.target_dir,
329 "module generated"
330 );
331
332 Ok(CreatedModule {
333 module_name: options.name,
334 crate_name,
335 program_names,
336 target_dir: options.target_dir,
337 })
338}
339
340fn resolve_template(
349 options: &NewModuleOptions,
350) -> Result<(PathBuf, Option<tempfile::TempDir>), NewModuleError> {
351 match &options.template {
352 TemplateSource::Path(path) => Ok((path.clone(), None)),
353 TemplateSource::Git(url) => {
354 let scratch = tempfile::tempdir().map_err(NewModuleError::TempDir)?;
355 let mut builder = RepoBuilder::new();
356 if let Some(branch) = &options.branch {
357 builder.branch(branch);
358 }
359 builder
360 .clone(url, scratch.path())
361 .map_err(|err| NewModuleError::CloneTemplate(url.clone(), err))?;
362 let path = scratch.path().to_path_buf();
363 Ok((path, Some(scratch)))
364 },
365 }
366}
367
368fn parse_vcs(vcs: &str) -> Result<Vcs, NewModuleError> {
369 vcs.parse().map_err(NewModuleError::CargoGenerate)
370}
371
372fn validate_module_name(name: &str) -> Result<(), NewModuleError> {
373 if name.is_empty() {
374 return Err(NewModuleError::EmptyName);
375 }
376
377 asimov_core::ModuleName::try_from(name)
378 .map(|_| ())
379 .map_err(|_| NewModuleError::InvalidName(name.into()))
380}
381
382fn validate_program_name(name: &str) -> Result<(), InvalidProgramName> {
383 let Some(rest) = name.strip_prefix("asimov-") else {
384 return Err(InvalidProgramName(name.into()));
385 };
386
387 if rest.is_empty() || rest.starts_with('-') || rest.ends_with('-') || !rest.contains('-') {
388 return Err(InvalidProgramName(name.into()));
389 }
390
391 let valid = rest
392 .bytes()
393 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
394 if valid {
395 Ok(())
396 } else {
397 Err(InvalidProgramName(name.into()))
398 }
399}
400
401pub(crate) fn program_kind_of(program_name: &str) -> &str {
404 program_name.rsplit('-').next().unwrap_or(program_name)
405}
406
407fn target_dir_name(target_dir: &Path) -> Result<OsString, NewModuleError> {
408 target_dir
409 .file_name()
410 .map(OsString::from)
411 .ok_or_else(|| NewModuleError::MissingTargetName(target_dir.into()))
412}
413
414fn title_case_slug(slug: &str) -> String {
415 slug.split('-')
416 .filter(|part| !part.is_empty())
417 .map(|part| {
418 let mut chars = part.chars();
419 match chars.next() {
420 Some(first) => {
421 let mut word = first.to_uppercase().collect::<String>();
422 word.push_str(chars.as_str());
423 word
424 },
425 None => String::new(),
426 }
427 })
428 .collect::<Vec<_>>()
429 .join(" ")
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use alloc::string::ToString;
436 use tempfile::tempdir;
437
438 #[test]
439 fn creation_and_linting_share_module_name_boundaries() {
440 let workspace = tempdir().unwrap();
441 let target = workspace.path();
442 fs::write(target.join("Cargo.toml"), "[package]\nname = 'fixture'\n").unwrap();
443 fs::create_dir(target.join(".asimov")).unwrap();
444
445 for (name, valid) in [
446 ("x", true),
447 ("a1-b2", true),
448 (&"a".repeat(64), true),
449 (&"a".repeat(65), false),
450 ("", false),
451 ("0-leading-digit", false),
452 ("-sample", false),
453 ("sample-", false),
454 ("Sample", false),
455 ("sample_name", false),
456 ("sample name", false),
457 ("é", false),
458 ("../sample", false),
459 ] {
460 assert_eq!(asimov_core::ModuleName::try_from(name).is_ok(), valid);
461 let result = new_module(NewModuleOptions::new(target, name));
462 if valid {
463 assert!(
465 matches!(result, Err(NewModuleError::TargetExists(_))),
466 "{name}"
467 );
468 } else {
469 assert!(
470 matches!(
471 result,
472 Err(NewModuleError::EmptyName | NewModuleError::InvalidName(_))
473 ),
474 "{name}"
475 );
476 }
477
478 #[cfg(feature = "lint")]
479 {
480 fs::write(
481 target.join(".asimov/module.yaml"),
482 format!("name: '{name}'\n"),
483 )
484 .unwrap();
485 let findings = lint::lint_module(lint::LintOptions::new(target)).unwrap();
486 assert_eq!(
487 findings
488 .iter()
489 .any(|finding| finding.code == lint::LintCode::InvalidModuleName),
490 !valid,
491 "{name}"
492 );
493 }
494 }
495 }
496
497 #[test]
498 fn rejects_invalid_module_names() {
499 assert!(matches!(
500 new_module(NewModuleOptions::new("target", "")),
501 Err(NewModuleError::EmptyName)
502 ));
503 assert!(matches!(
504 new_module(NewModuleOptions::new("target", "Not Valid")),
505 Err(NewModuleError::InvalidName(_))
506 ));
507 }
508
509 #[test]
510 fn rejects_invalid_program_name() {
511 let mut options = NewModuleOptions::new("target", "widget");
512 options.program_name = Some("Not-Valid".into());
513 assert!(matches!(
514 new_module(options),
515 Err(NewModuleError::InvalidProgramName(_))
516 ));
517 }
518
519 #[test]
520 fn rejects_program_name_not_matching_module() {
521 let mut options = NewModuleOptions::new("target", "widget");
522 options.program_name = Some("asimov-other-fetcher".into());
523 assert!(matches!(
524 new_module(options),
525 Err(NewModuleError::InvalidProgramName(_))
526 ));
527 }
528
529 #[test]
530 fn validates_program_name_shape() {
531 assert!(validate_program_name("asimov-widget-emitter").is_ok());
532 assert!(validate_program_name("asimov-widget-multi-word-kind").is_ok());
533 assert!(validate_program_name("asimov-widget-whatever-custom-kind").is_ok());
535
536 assert!(validate_program_name("").is_err());
537 assert!(validate_program_name("widget-emitter").is_err());
538 assert!(validate_program_name("asimov-widget").is_err());
539 assert!(validate_program_name("asimov-Widget-Emitter").is_err());
540 assert!(validate_program_name("asimov-widget-emitter-").is_err());
541 assert!(validate_program_name("asimov-widgetemitter").is_err());
542 }
543
544 fn fixture_program_template(dir: &std::path::Path) {
545 fs::create_dir_all(dir.join(".template")).unwrap();
546 fs::write(
547 dir.join(".template/program.rs.liquid"),
548 "// {{ program_name }} ({{ program_kind }})\nfn main() {}\n",
549 )
550 .unwrap();
551 }
552
553 #[test]
554 fn generates_module_from_local_template() {
555 let template_dir = tempdir().unwrap();
556 fs::write(
557 template_dir.path().join("Cargo.toml"),
558 "[package]\nname = \"{{package_name}}\"\ndescription = \"{{package_description}}\"\n",
559 )
560 .unwrap();
561 fs::create_dir(template_dir.path().join("src")).unwrap();
562 fs::write(
563 template_dir.path().join("src/lib.rs"),
564 "// {{module_title}}\n",
565 )
566 .unwrap();
567 fixture_program_template(template_dir.path());
568
569 let workspace = tempdir().unwrap();
570 let target_dir = workspace.path().join("widget-module");
571
572 let mut options =
573 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
574 options.package_description = Some("A widget module.".into());
575 let created = new_module(options).unwrap();
576
577 assert_eq!(created.crate_name, "asimov-widget-module");
578 assert_eq!(created.program_names, ["asimov-widget-emitter"]);
579 assert_eq!(created.target_dir, target_dir);
580
581 let cargo_toml = fs::read_to_string(target_dir.join("Cargo.toml")).unwrap();
582 assert!(cargo_toml.contains("name = \"asimov-widget-module\""));
583 assert!(cargo_toml.contains("description = \"A widget module.\""));
584 assert!(cargo_toml.contains("name = \"asimov-widget-emitter\""));
585 assert!(cargo_toml.contains("path = \"src/emitter/main.rs\""));
586
587 let lib_rs = fs::read_to_string(target_dir.join("src/lib.rs")).unwrap();
588 assert!(lib_rs.contains("ASIMOV Widget Module"));
589
590 let program_rs = fs::read_to_string(target_dir.join("src/emitter/main.rs")).unwrap();
591 assert!(program_rs.contains("asimov-widget-emitter"));
592 }
593
594 #[test]
595 fn refuses_to_overwrite_existing_target() {
596 let workspace = tempdir().unwrap();
597 let target_dir = workspace.path().join("existing");
598 fs::create_dir(&target_dir).unwrap();
599
600 let options = NewModuleOptions::new(&target_dir, "widget");
601 assert!(matches!(
602 new_module(options),
603 Err(NewModuleError::TargetExists(_))
604 ));
605 }
606
607 #[test]
608 fn passes_program_kind_for_a_custom_program_name() {
609 let template_dir = tempdir().unwrap();
610 fs::write(
611 template_dir.path().join("Cargo.toml"),
612 "[package]\nname = \"{{package_name}}\"\n",
613 )
614 .unwrap();
615 fs::create_dir(template_dir.path().join("src")).unwrap();
616 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
617 fixture_program_template(template_dir.path());
618
619 let workspace = tempdir().unwrap();
620 let target_dir = workspace.path().join("widget-module");
621
622 let mut options =
623 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
624 options.program_name = Some("asimov-widget-fetcher".into());
625 new_module(options).unwrap();
626
627 let program_rs = fs::read_to_string(target_dir.join("src/fetcher/main.rs")).unwrap();
628 assert!(program_rs.contains("(fetcher)"));
629 }
630
631 #[test]
632 fn passes_full_program_kind_for_a_multi_hyphen_kind() {
633 let template_dir = tempdir().unwrap();
634 fs::write(
635 template_dir.path().join("Cargo.toml"),
636 "[package]\nname = \"{{package_name}}\"\n",
637 )
638 .unwrap();
639 fs::create_dir(template_dir.path().join("src")).unwrap();
640 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
641 fixture_program_template(template_dir.path());
642
643 let workspace = tempdir().unwrap();
644 let target_dir = workspace.path().join("widget-module");
645
646 let mut options =
647 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
648 options.program_name = Some("asimov-widget-custom-multi-word-kind".into());
651 new_module(options).unwrap();
652
653 let program_rs =
654 fs::read_to_string(target_dir.join("src/custom-multi-word-kind/main.rs")).unwrap();
655 assert!(program_rs.contains("(custom-multi-word-kind)"));
656 }
657
658 #[test]
659 fn creates_module_without_a_program() {
660 let template_dir = tempdir().unwrap();
661 fs::write(
662 template_dir.path().join("Cargo.toml"),
663 "[package]\nname = \"{{package_name}}\"\n",
664 )
665 .unwrap();
666 fs::create_dir(template_dir.path().join("src")).unwrap();
667 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
668 fixture_program_template(template_dir.path());
669
670 let workspace = tempdir().unwrap();
671 let target_dir = workspace.path().join("bare-module");
672
673 let options = NewModuleOptions::new(&target_dir, "bare")
674 .template_path(template_dir.path())
675 .without_program();
676 let created = new_module(options).unwrap();
677
678 assert!(created.program_names.is_empty());
679 assert!(!target_dir.join("src/emitter").exists());
680 let cargo_toml = fs::read_to_string(target_dir.join("Cargo.toml")).unwrap();
681 assert!(!cargo_toml.contains("[[bin]]"));
682 }
683
684 #[test]
685 fn creates_module_with_multiple_programs() {
686 let template_dir = tempdir().unwrap();
687 fs::write(
688 template_dir.path().join("Cargo.toml"),
689 "[package]\nname = \"{{package_name}}\"\n",
690 )
691 .unwrap();
692 fs::create_dir(template_dir.path().join("src")).unwrap();
693 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
694 fixture_program_template(template_dir.path());
695
696 let workspace = tempdir().unwrap();
697 let target_dir = workspace.path().join("widget-module");
698
699 let options = NewModuleOptions::new(&target_dir, "widget")
700 .template_path(template_dir.path())
701 .programs([
702 "asimov-widget-emitter".to_string(),
703 "asimov-widget-fetcher".to_string(),
704 "asimov-widget-importer".to_string(),
705 ]);
706 let created = new_module(options).unwrap();
707
708 assert_eq!(
709 created.program_names,
710 [
711 "asimov-widget-emitter",
712 "asimov-widget-fetcher",
713 "asimov-widget-importer"
714 ]
715 );
716
717 let cargo_toml = fs::read_to_string(target_dir.join("Cargo.toml")).unwrap();
718 assert_eq!(cargo_toml.matches("[[bin]]").count(), 3);
719 assert!(cargo_toml.contains("name = \"asimov-widget-fetcher\""));
720 assert!(cargo_toml.contains("name = \"asimov-widget-importer\""));
721 assert!(target_dir.join("src/emitter/main.rs").exists());
722 assert!(target_dir.join("src/fetcher/main.rs").exists());
723 assert!(target_dir.join("src/importer/main.rs").exists());
724 }
725
726 #[test]
727 fn deduplicates_programs_to_create() {
728 let template_dir = tempdir().unwrap();
729 fs::write(
730 template_dir.path().join("Cargo.toml"),
731 "[package]\nname = \"{{package_name}}\"\n",
732 )
733 .unwrap();
734 fs::create_dir(template_dir.path().join("src")).unwrap();
735 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
736 fixture_program_template(template_dir.path());
737
738 let workspace = tempdir().unwrap();
739 let target_dir = workspace.path().join("widget-module");
740
741 let mut options =
744 NewModuleOptions::new(&target_dir, "widget").template_path(template_dir.path());
745 options.program_name = Some("asimov-widget-fetcher".into());
746 options.extra_programs = [
747 "asimov-widget-fetcher".to_string(),
748 "asimov-widget-importer".to_string(),
749 ]
750 .into();
751 let created = new_module(options).unwrap();
752
753 assert_eq!(
754 created.program_names,
755 ["asimov-widget-fetcher", "asimov-widget-importer"]
756 );
757 }
758
759 #[test]
760 fn rejects_extra_program_not_matching_module() {
761 let template_dir = tempdir().unwrap();
762 fs::write(
763 template_dir.path().join("Cargo.toml"),
764 "[package]\nname = \"{{package_name}}\"\n",
765 )
766 .unwrap();
767 fs::create_dir(template_dir.path().join("src")).unwrap();
768 fs::write(template_dir.path().join("src/lib.rs"), "// lib\n").unwrap();
769
770 let workspace = tempdir().unwrap();
771 let target_dir = workspace.path().join("widget-module");
772
773 let mut options = NewModuleOptions::new(&target_dir, "widget")
774 .template_path(template_dir.path())
775 .without_program();
776 options.extra_programs = ["asimov-other-fetcher".to_string()].into();
777
778 assert!(matches!(
779 new_module(options),
780 Err(NewModuleError::InvalidProgramName(_))
781 ));
782 assert!(!target_dir.exists());
783 }
784
785 #[test]
786 #[ignore = "network smoke test against the real default template; run manually"]
787 fn smoke_test_default_git_template_with_multiple_programs() {
788 let workspace = tempdir().unwrap();
789 let target_dir = workspace.path().join("widget-module");
790
791 let options = NewModuleOptions::new(&target_dir, "widget").programs([
795 "asimov-widget-fetcher".to_string(),
796 "asimov-widget-importer".to_string(),
797 ]);
798 let created = new_module(options).unwrap();
799
800 assert_eq!(
801 created.program_names,
802 ["asimov-widget-fetcher", "asimov-widget-importer"]
803 );
804 assert!(target_dir.join("src/fetcher/main.rs").exists());
805 assert!(target_dir.join("src/importer/main.rs").exists());
806 }
807}