Skip to main content

asimov_module_kit/
module.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Module authoring support.
4
5use 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    /// Additional programs to scaffold beyond `program_name`, added the same
49    /// way [`program::add_program`] would, right after the module itself is
50    /// generated.
51    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    /// Whether to scaffold an initial program at all. Defaults to `true`;
57    /// set to `false` (via [`Self::without_program`]) to create a bare
58    /// module with no programs — add them later via [`program::add_program`].
59    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    /// Creates a bare module with no initial program.
96    pub fn without_program(mut self) -> Self {
97        self.create_program = false;
98        self
99    }
100
101    /// Sets the module's initial set of programs. Given an empty list, the
102    /// template's own default program is used, unchanged (today's
103    /// `asimov-<name>-emitter`). Given one or more names, the default is
104    /// skipped entirely and *every* name is added the same way
105    /// [`program::add_program`] would, right after the template is
106    /// generated — there is no special-cased "first" program.
107    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/// The program name doesn't match the `asimov-<module>-<kind>` shape.
158///
159/// The `<kind>` segment itself is never checked against any fixed list —
160/// any lowercase, hyphenated word is accepted; only the overall shape
161/// (matching the naming convention already enforced for module names) is
162/// validated here.
163#[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    /// The names of the programs scaffolded, in the order they were added.
174    /// Empty if the module was created bare.
175    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    // Resolve the template to a single local checkout up front: a git
259    // source is cloned exactly once here, and that same local checkout is
260    // reused below both for the module's own generation and for every
261    // `add_program` call — instead of each of those independently
262    // re-fetching the same template.
263    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    // The template itself never scaffolds any program (its `Cargo.toml`
309    // and `.asimov/module.yaml` are always bare) — every program, whether
310    // it's the implicit default or an explicit list, is added uniformly
311    // through `add_program`, reusing the same resolved template checkout.
312    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
340/// Resolves the template to a single local directory. A [`TemplateSource::Path`]
341/// is used as-is; a [`TemplateSource::Git`] is cloned once into a temporary
342/// directory, returned alongside its [`tempfile::TempDir`] guard so callers
343/// can keep it alive for as long as the checkout is still needed.
344///
345/// This is a plain, unauthenticated clone — sufficient for the default,
346/// public `asimov-template-module` repository, but not for private templates
347/// requiring credentials.
348fn 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
401/// Derives a program's "kind" from its name — the last hyphen segment.
402/// Never validated against any fixed list; any word is accepted.
403pub(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                // An existing target stops generation after name validation.
464                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        // The kind itself is never restricted to a known list:
534        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        // The naive last-hyphen-segment heuristic would derive just "kind";
649        // knowing the module name lets us derive the full custom kind.
650        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        // `program_name` and `extra_programs` overlapping is possible since
742        // the fields are public; it shouldn't reach `add_program` twice.
743        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        // A `Git` source is cloned exactly once here, and that single
792        // checkout is reused for both the base module and every extra
793        // program below — not re-fetched per program.
794        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}