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("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/// The program name doesn't match the `asimov-<module>-<kind>` shape.
156///
157/// The `<kind>` segment itself is never checked against any fixed list —
158/// any lowercase, hyphenated word is accepted; only the overall shape
159/// (matching the naming convention already enforced for module names) is
160/// validated here.
161#[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    /// The names of the programs scaffolded, in the order they were added.
172    /// Empty if the module was created bare.
173    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    // Resolve the template to a single local checkout up front: a git
255    // source is cloned exactly once here, and that same local checkout is
256    // reused below both for the module's own generation and for every
257    // `add_program` call — instead of each of those independently
258    // re-fetching the same template.
259    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    // The template itself never scaffolds any program (its `Cargo.toml`
304    // and `.asimov/module.yaml` are always bare) — every program, whether
305    // it's the implicit default or an explicit list, is added uniformly
306    // through `add_program`, reusing the same resolved template checkout.
307    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
334/// Resolves the template to a single local directory. A [`TemplateSource::Path`]
335/// is used as-is; a [`TemplateSource::Git`] is cloned once into a temporary
336/// directory, returned alongside its [`tempfile::TempDir`] guard so callers
337/// can keep it alive for as long as the checkout is still needed.
338///
339/// This is a plain, unauthenticated clone — sufficient for the default,
340/// public `asimov-template-module` repository, but not for private templates
341/// requiring credentials.
342fn 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
408/// Derives a program's "kind" from its name — the last hyphen segment.
409/// Never validated against any fixed list; any word is accepted.
410pub(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        // The kind itself is never restricted to a known list:
482        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        // The naive last-hyphen-segment heuristic would derive just "kind";
597        // knowing the module name lets us derive the full custom kind.
598        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        // `program_name` and `extra_programs` overlapping is possible since
690        // the fields are public; it shouldn't reach `add_program` twice.
691        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        // A `Git` source is cloned exactly once here, and that single
740        // checkout is reused for both the base module and every extra
741        // program below — not re-fetched per program.
742        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}