greentic_component/scaffold/
engine.rs

1#![cfg(feature = "cli")]
2
3use std::borrow::Cow;
4use std::collections::HashSet;
5use std::env;
6use std::fmt;
7use std::fs;
8use std::io;
9use std::path::{Component, Path, PathBuf};
10use std::str;
11
12use directories::BaseDirs;
13use handlebars::{Handlebars, no_escape};
14use include_dir::{Dir, DirEntry, include_dir};
15use serde::{Deserialize, Serialize, Serializer};
16use thiserror::Error;
17use time::OffsetDateTime;
18use walkdir::WalkDir;
19
20use super::deps::{self, DependencyMode};
21use super::validate::{self, ValidationError};
22use super::write::{GeneratedFile, WriteError, Writer};
23
24static BUILTIN_COMPONENT_TEMPLATES: Dir<'_> =
25    include_dir!("$CARGO_MANIFEST_DIR/assets/templates/component");
26
27pub const DEFAULT_WIT_WORLD: &str = "greentic:component/component@0.5.0";
28
29const METADATA_FILE: &str = "template.json";
30const TEMPLATE_HOME_ENV: &str = "GREENTIC_TEMPLATE_ROOT";
31const TEMPLATE_YEAR_ENV: &str = "GREENTIC_TEMPLATE_YEAR";
32
33#[derive(Debug, Clone, Default)]
34pub struct ScaffoldEngine;
35
36impl ScaffoldEngine {
37    pub fn new() -> Self {
38        Self
39    }
40
41    pub fn templates(&self) -> Result<Vec<TemplateDescriptor>, ScaffoldError> {
42        let mut templates = self.builtin_templates();
43        templates.extend(self.user_templates()?);
44        templates.sort();
45        Ok(templates)
46    }
47
48    pub fn resolve_template(&self, id: &str) -> Result<TemplateDescriptor, ScaffoldError> {
49        let list = self.templates()?;
50        list.into_iter()
51            .find(|tpl| tpl.id == id)
52            .ok_or_else(|| ScaffoldError::TemplateNotFound(id.to_owned()))
53    }
54
55    pub fn scaffold(&self, request: ScaffoldRequest) -> Result<ScaffoldOutcome, ScaffoldError> {
56        let descriptor = self.resolve_template(&request.template_id)?;
57        validate::ensure_path_available(&request.path)?;
58        let package = self.load_template(&descriptor)?;
59        let context = TemplateContext::from_request(&request);
60        let rendered = self.render_files(&package, &context)?;
61        let created = Writer::new().write_all(&request.path, &rendered)?;
62
63        if matches!(request.dependency_mode, DependencyMode::CratesIo) {
64            deps::ensure_cratesio_manifest_clean(&request.path)?;
65        }
66
67        Ok(ScaffoldOutcome {
68            name: request.name,
69            template: package.metadata.id.clone(),
70            template_description: descriptor.description.clone(),
71            template_tags: descriptor.tags.clone(),
72            path: request.path,
73            created,
74        })
75    }
76
77    fn builtin_templates(&self) -> Vec<TemplateDescriptor> {
78        BUILTIN_COMPONENT_TEMPLATES
79            .dirs()
80            .filter_map(|dir| {
81                let fallback_id = dir.path().file_name()?.to_string_lossy().to_string();
82                let metadata = match embedded_metadata(dir, &fallback_id) {
83                    Ok(meta) => meta,
84                    Err(_) => ResolvedTemplateMetadata::fallback(fallback_id.clone()),
85                };
86                Some(TemplateDescriptor {
87                    id: metadata.id,
88                    location: TemplateLocation::BuiltIn,
89                    path: None,
90                    description: metadata.description,
91                    tags: metadata.tags,
92                })
93            })
94            .collect()
95    }
96
97    fn user_templates(&self) -> Result<Vec<TemplateDescriptor>, ScaffoldError> {
98        let Some(root) = Self::user_templates_root() else {
99            return Ok(Vec::new());
100        };
101        let metadata = match fs::metadata(&root) {
102            Ok(meta) => meta,
103            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
104            Err(err) => return Err(ScaffoldError::UserTemplatesIo(root.clone(), err)),
105        };
106        if !metadata.is_dir() {
107            return Ok(Vec::new());
108        }
109        let mut templates = Vec::new();
110        let iter =
111            fs::read_dir(&root).map_err(|err| ScaffoldError::UserTemplatesIo(root.clone(), err))?;
112        for entry in iter {
113            let entry = entry.map_err(|err| ScaffoldError::UserTemplatesIo(root.clone(), err))?;
114            let path = entry.path();
115            if !path.is_dir() {
116                continue;
117            }
118            let fallback_id = match path.file_name() {
119                Some(id) => id.to_string_lossy().to_string(),
120                None => continue,
121            };
122            if !validate::is_valid_name(&fallback_id) {
123                continue;
124            }
125            let metadata = match user_metadata(&path, &fallback_id) {
126                Ok(meta) => meta,
127                Err(_) => ResolvedTemplateMetadata::fallback(fallback_id.clone()),
128            };
129            templates.push(TemplateDescriptor {
130                id: metadata.id,
131                location: TemplateLocation::User,
132                path: Some(path),
133                description: metadata.description,
134                tags: metadata.tags,
135            });
136        }
137        templates.sort();
138        Ok(templates)
139    }
140
141    fn load_template(
142        &self,
143        descriptor: &TemplateDescriptor,
144    ) -> Result<TemplatePackage, ScaffoldError> {
145        let id = descriptor.id.clone();
146        match descriptor.location {
147            TemplateLocation::BuiltIn => {
148                let dir = BUILTIN_COMPONENT_TEMPLATES
149                    .get_dir(&descriptor.id)
150                    .ok_or_else(|| ScaffoldError::TemplateNotFound(descriptor.id.clone()))?;
151                TemplatePackage::from_embedded(dir)
152                    .map_err(|source| ScaffoldError::TemplateLoad { id, source })
153            }
154            TemplateLocation::User => {
155                let path = descriptor
156                    .path
157                    .as_ref()
158                    .ok_or_else(|| ScaffoldError::TemplateNotFound(descriptor.id.clone()))?;
159                TemplatePackage::from_disk(path)
160                    .map_err(|source| ScaffoldError::TemplateLoad { id, source })
161            }
162        }
163    }
164
165    fn render_files(
166        &self,
167        package: &TemplatePackage,
168        context: &TemplateContext,
169    ) -> Result<Vec<GeneratedFile>, ScaffoldError> {
170        let mut handlebars = Handlebars::new();
171        handlebars.set_strict_mode(true);
172        handlebars.register_escape_fn(no_escape);
173
174        let template_id = package.metadata.id.clone();
175        let executable_paths: HashSet<PathBuf> = package
176            .metadata
177            .executables
178            .iter()
179            .map(|path| render_path(path, &handlebars, context))
180            .collect::<Result<_, _>>()
181            .map_err(|source| ScaffoldError::Render {
182                id: template_id.clone(),
183                source,
184            })?;
185
186        let mut rendered = Vec::with_capacity(package.entries.len());
187        for entry in &package.entries {
188            let path_template = entry.path_template();
189            let target_path =
190                render_path(path_template, &handlebars, context).map_err(|source| {
191                    ScaffoldError::Render {
192                        id: template_id.clone(),
193                        source,
194                    }
195                })?;
196            let contents = if entry.templated {
197                let source =
198                    str::from_utf8(&entry.contents).map_err(|source| ScaffoldError::Render {
199                        id: template_id.clone(),
200                        source: RenderError::Utf8 {
201                            path: entry.relative_path.clone(),
202                            source,
203                        },
204                    })?;
205                handlebars
206                    .render_template(source, context)
207                    .map(|value| value.into_bytes())
208                    .map_err(|source| ScaffoldError::Render {
209                        id: template_id.clone(),
210                        source: RenderError::Handlebars {
211                            path: entry.relative_path.clone(),
212                            source,
213                        },
214                    })?
215            } else {
216                entry.contents.clone()
217            };
218
219            let executable =
220                executable_paths.contains(&target_path) || is_executable_heuristic(&target_path);
221
222            rendered.push(GeneratedFile {
223                relative_path: target_path,
224                contents,
225                executable,
226            });
227        }
228
229        Ok(rendered)
230    }
231
232    fn user_templates_root() -> Option<PathBuf> {
233        if let Some(root) = env::var_os(TEMPLATE_HOME_ENV) {
234            return Some(PathBuf::from(root));
235        }
236        BaseDirs::new().map(|dirs| {
237            dirs.home_dir()
238                .join(".greentic")
239                .join("templates")
240                .join("component")
241        })
242    }
243}
244
245#[derive(Debug, Error)]
246pub enum ScaffoldError {
247    #[error("template `{0}` not found")]
248    TemplateNotFound(String),
249    #[error("failed to read user templates from {0}: {1}")]
250    UserTemplatesIo(PathBuf, #[source] io::Error),
251    #[error("failed to load template `{id}`: {source}")]
252    TemplateLoad {
253        id: String,
254        #[source]
255        source: TemplateLoadError,
256    },
257    #[error("failed to render template `{id}`: {source}")]
258    Render {
259        id: String,
260        #[source]
261        source: RenderError,
262    },
263    #[error(transparent)]
264    Write(#[from] WriteError),
265    #[error(transparent)]
266    Validation(#[from] ValidationError),
267    #[error(transparent)]
268    Dependency(#[from] deps::DependencyError),
269}
270
271#[derive(Debug, Clone)]
272pub struct ScaffoldRequest {
273    pub name: String,
274    pub path: PathBuf,
275    pub template_id: String,
276    pub org: String,
277    pub version: String,
278    pub license: String,
279    pub wit_world: String,
280    pub non_interactive: bool,
281    pub year_override: Option<i32>,
282    pub dependency_mode: DependencyMode,
283}
284
285#[derive(Debug, Clone, Serialize)]
286pub struct ScaffoldOutcome {
287    pub name: String,
288    pub template: String,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub template_description: Option<String>,
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub template_tags: Vec<String>,
293    #[serde(serialize_with = "serialize_path")]
294    pub path: PathBuf,
295    pub created: Vec<String>,
296}
297
298impl ScaffoldOutcome {
299    pub fn human_summary(&self) -> String {
300        format!(
301            "Scaffolded component `{}` in {} ({} files)",
302            self.name,
303            self.path.display(),
304            self.created.len()
305        )
306    }
307}
308
309#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)]
310pub struct TemplateDescriptor {
311    pub id: String,
312    pub location: TemplateLocation,
313    #[serde(serialize_with = "serialize_optional_path")]
314    pub path: Option<PathBuf>,
315    pub description: Option<String>,
316    #[serde(default)]
317    pub tags: Vec<String>,
318}
319
320impl TemplateDescriptor {
321    pub fn display_path(&self) -> Cow<'_, str> {
322        match &self.path {
323            Some(path) => Cow::Owned(path.display().to_string()),
324            None => Cow::Borrowed("<embedded>"),
325        }
326    }
327}
328
329#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)]
330#[serde(rename_all = "kebab-case")]
331pub enum TemplateLocation {
332    #[serde(rename = "built-in")]
333    BuiltIn,
334    User,
335}
336
337impl fmt::Display for TemplateLocation {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        match self {
340            TemplateLocation::BuiltIn => write!(f, "built-in"),
341            TemplateLocation::User => write!(f, "user"),
342        }
343    }
344}
345
346#[derive(Debug, Error)]
347pub enum TemplateLoadError {
348    #[error("failed to parse metadata {path}: {source}")]
349    Metadata {
350        path: String,
351        #[source]
352        source: serde_json::Error,
353    },
354    #[error("failed to read {path}: {source}")]
355    Io {
356        path: PathBuf,
357        #[source]
358        source: io::Error,
359    },
360}
361
362#[derive(Debug, Error)]
363pub enum RenderError {
364    #[error("template `{path}` is not valid UTF-8: {source}")]
365    Utf8 {
366        path: String,
367        #[source]
368        source: str::Utf8Error,
369    },
370    #[error("failed to render `{path}`: {source}")]
371    Handlebars {
372        path: String,
373        #[source]
374        source: handlebars::RenderError,
375    },
376    #[error("rendered path `{0}` escapes the target directory")]
377    Traversal(String),
378}
379
380struct TemplatePackage {
381    metadata: ResolvedTemplateMetadata,
382    entries: Vec<TemplateEntry>,
383}
384
385impl TemplatePackage {
386    fn from_embedded(dir: &Dir<'_>) -> Result<Self, TemplateLoadError> {
387        let fallback_id = dir
388            .path()
389            .file_name()
390            .unwrap()
391            .to_string_lossy()
392            .to_string();
393        let metadata = embedded_metadata(dir, &fallback_id)?;
394        let mut entries = Vec::new();
395        collect_embedded_entries(dir, "", &mut entries);
396        Ok(Self { metadata, entries })
397    }
398
399    fn from_disk(path: &Path) -> Result<Self, TemplateLoadError> {
400        let fallback_id = path
401            .file_name()
402            .map(|id| id.to_string_lossy().to_string())
403            .unwrap_or_else(|| "user".into());
404        let metadata = user_metadata(path, &fallback_id)?;
405        let mut entries = Vec::new();
406        collect_fs_entries(path, &mut entries)?;
407        Ok(Self { metadata, entries })
408    }
409}
410
411#[derive(Debug, Clone)]
412struct TemplateEntry {
413    relative_path: String,
414    contents: Vec<u8>,
415    templated: bool,
416}
417
418impl TemplateEntry {
419    fn path_template(&self) -> &str {
420        if self.templated && self.relative_path.ends_with(".hbs") {
421            &self.relative_path[..self.relative_path.len() - 4]
422        } else {
423            &self.relative_path
424        }
425    }
426}
427
428#[derive(Debug, Clone)]
429struct ResolvedTemplateMetadata {
430    id: String,
431    description: Option<String>,
432    tags: Vec<String>,
433    executables: Vec<String>,
434}
435
436impl ResolvedTemplateMetadata {
437    fn fallback(id: String) -> Self {
438        Self {
439            id,
440            description: None,
441            tags: Vec::new(),
442            executables: Vec::new(),
443        }
444    }
445}
446
447#[derive(Debug, Deserialize)]
448struct TemplateMetadataFile {
449    id: Option<String>,
450    description: Option<String>,
451    #[serde(default)]
452    tags: Vec<String>,
453    #[serde(default)]
454    executables: Vec<String>,
455}
456
457fn embedded_metadata(
458    dir: &Dir<'_>,
459    fallback_id: &str,
460) -> Result<ResolvedTemplateMetadata, TemplateLoadError> {
461    let path = dir.path().join(METADATA_FILE);
462    let metadata = match dir.get_file(&path) {
463        Some(file) => deserialize_metadata(file.contents(), path.to_string_lossy().as_ref())?,
464        None => None,
465    };
466    Ok(resolve_metadata(metadata, fallback_id))
467}
468
469fn user_metadata(
470    path: &Path,
471    fallback_id: &str,
472) -> Result<ResolvedTemplateMetadata, TemplateLoadError> {
473    let metadata_path = path.join(METADATA_FILE);
474    if !metadata_path.exists() {
475        return Ok(ResolvedTemplateMetadata::fallback(fallback_id.to_string()));
476    }
477    let contents = fs::read(&metadata_path).map_err(|source| TemplateLoadError::Io {
478        path: metadata_path.clone(),
479        source,
480    })?;
481    let metadata = deserialize_metadata(&contents, metadata_path.to_string_lossy().as_ref())?;
482    Ok(resolve_metadata(metadata, fallback_id))
483}
484
485fn deserialize_metadata<T: AsRef<[u8]>>(
486    bytes: T,
487    path: &str,
488) -> Result<Option<TemplateMetadataFile>, TemplateLoadError> {
489    if bytes.as_ref().is_empty() {
490        return Ok(None);
491    }
492    serde_json::from_slice(bytes.as_ref())
493        .map(Some)
494        .map_err(|source| TemplateLoadError::Metadata {
495            path: path.to_string(),
496            source,
497        })
498}
499
500fn resolve_metadata(
501    metadata: Option<TemplateMetadataFile>,
502    fallback_id: &str,
503) -> ResolvedTemplateMetadata {
504    match metadata {
505        Some(file) => ResolvedTemplateMetadata {
506            id: file.id.unwrap_or_else(|| fallback_id.to_string()),
507            description: file.description,
508            tags: file.tags,
509            executables: file.executables,
510        },
511        None => ResolvedTemplateMetadata::fallback(fallback_id.to_string()),
512    }
513}
514
515fn collect_embedded_entries(dir: &Dir<'_>, prefix: &str, entries: &mut Vec<TemplateEntry>) {
516    for entry in dir.entries() {
517        match entry {
518            DirEntry::Dir(sub) => {
519                let new_prefix = if prefix.is_empty() {
520                    sub.path()
521                        .file_name()
522                        .unwrap()
523                        .to_string_lossy()
524                        .to_string()
525                } else {
526                    format!(
527                        "{}/{}",
528                        prefix,
529                        sub.path().file_name().unwrap().to_string_lossy()
530                    )
531                };
532                collect_embedded_entries(sub, &new_prefix, entries);
533            }
534            DirEntry::File(file) => {
535                if file.path().ends_with(METADATA_FILE) {
536                    continue;
537                }
538                entries.push(TemplateEntry {
539                    relative_path: join_relative(
540                        prefix,
541                        file.path().file_name().unwrap().to_string_lossy().as_ref(),
542                    ),
543                    contents: file.contents().to_vec(),
544                    templated: file.path().extension().and_then(|ext| ext.to_str()) == Some("hbs"),
545                });
546            }
547        }
548    }
549}
550
551fn collect_fs_entries(
552    root: &Path,
553    entries: &mut Vec<TemplateEntry>,
554) -> Result<(), TemplateLoadError> {
555    for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
556        if entry.file_type().is_dir() {
557            continue;
558        }
559        let path = entry.path();
560        if path.file_name().and_then(|f| f.to_str()) == Some(METADATA_FILE) {
561            continue;
562        }
563        let relative = path
564            .strip_prefix(root)
565            .map_err(|source| TemplateLoadError::Io {
566                path: path.to_path_buf(),
567                source: io::Error::other(source),
568            })?;
569        let contents = fs::read(path).map_err(|source| TemplateLoadError::Io {
570            path: path.to_path_buf(),
571            source,
572        })?;
573        entries.push(TemplateEntry {
574            relative_path: relative.to_string_lossy().replace('\\', "/"),
575            contents,
576            templated: relative.extension().and_then(|ext| ext.to_str()) == Some("hbs"),
577        });
578    }
579    Ok(())
580}
581
582fn join_relative(prefix: &str, name: &str) -> String {
583    if prefix.is_empty() {
584        name.to_string()
585    } else {
586        format!("{prefix}/{name}")
587    }
588}
589
590#[derive(Serialize)]
591struct TemplateContext {
592    name: String,
593    name_snake: String,
594    name_kebab: String,
595    package_id: String,
596    namespace_wit: String,
597    org: String,
598    version: String,
599    license: String,
600    wit_world: String,
601    year: i32,
602    repo: String,
603    author: Option<String>,
604    dependency_mode: &'static str,
605    greentic_interfaces_dep: String,
606    greentic_interfaces_guest_dep: String,
607    greentic_types_dep: String,
608    relative_patch_path: Option<String>,
609}
610
611impl TemplateContext {
612    fn from_request(request: &ScaffoldRequest) -> Self {
613        let name_snake = request.name.replace('-', "_");
614        let name_kebab = request.name.replace('_', "-");
615        let package_id = format!("{}.{}", request.org, name_snake);
616        let namespace_wit = sanitize_namespace(&request.org);
617        let year = request.year_override.unwrap_or_else(template_year);
618        let deps = deps::resolve_dependency_templates(request.dependency_mode, &request.path);
619        Self {
620            name: request.name.clone(),
621            name_snake,
622            name_kebab,
623            package_id,
624            namespace_wit,
625            org: request.org.clone(),
626            version: request.version.clone(),
627            license: request.license.clone(),
628            wit_world: request.wit_world.clone(),
629            year,
630            repo: request.name.clone(),
631            author: detect_author(),
632            dependency_mode: request.dependency_mode.as_str(),
633            greentic_interfaces_dep: deps.greentic_interfaces,
634            greentic_interfaces_guest_dep: deps.greentic_interfaces_guest,
635            greentic_types_dep: deps.greentic_types,
636            relative_patch_path: deps.relative_patch_path,
637        }
638    }
639}
640
641fn template_year() -> i32 {
642    if let Ok(value) = env::var(TEMPLATE_YEAR_ENV)
643        && let Ok(parsed) = value.parse()
644    {
645        return parsed;
646    }
647    OffsetDateTime::now_utc().year()
648}
649
650fn sanitize_namespace(value: &str) -> String {
651    value
652        .chars()
653        .map(|c| {
654            let lower = c.to_ascii_lowercase();
655            if lower.is_ascii_lowercase() || lower.is_ascii_digit() || lower == '-' {
656                lower
657            } else {
658                '-'
659            }
660        })
661        .collect()
662}
663
664fn detect_author() -> Option<String> {
665    for key in ["GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME", "USER", "USERNAME"] {
666        if let Ok(value) = env::var(key) {
667            let trimmed = value.trim();
668            if !trimmed.is_empty() {
669                return Some(trimmed.to_string());
670            }
671        }
672    }
673    None
674}
675
676fn render_path(
677    template: &str,
678    handlebars: &Handlebars<'_>,
679    context: &TemplateContext,
680) -> Result<PathBuf, RenderError> {
681    let rendered = handlebars
682        .render_template(template, context)
683        .map_err(|source| RenderError::Handlebars {
684            path: template.to_string(),
685            source,
686        })?;
687    normalize_relative(&rendered)
688}
689
690fn normalize_relative(value: &str) -> Result<PathBuf, RenderError> {
691    let path = PathBuf::from(value);
692    if path.is_absolute() {
693        return Err(RenderError::Traversal(value.to_string()));
694    }
695    for component in path.components() {
696        match component {
697            Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
698                return Err(RenderError::Traversal(value.to_string()));
699            }
700            _ => {}
701        }
702    }
703    Ok(path)
704}
705
706fn is_executable_heuristic(path: &Path) -> bool {
707    matches!(
708        path.extension().and_then(|ext| ext.to_str()),
709        Some("sh" | "bash" | "zsh" | "ps1")
710    ) || path
711        .file_name()
712        .and_then(|name| name.to_str())
713        .map(|name| name == "Makefile")
714        .unwrap_or(false)
715}
716
717fn serialize_path<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
718where
719    S: Serializer,
720{
721    serializer.serialize_str(&path.display().to_string())
722}
723
724fn serialize_optional_path<S>(path: &Option<PathBuf>, serializer: S) -> Result<S::Ok, S::Error>
725where
726    S: Serializer,
727{
728    match path {
729        Some(value) => serializer.serialize_some(&value.display().to_string()),
730        None => serializer.serialize_none(),
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use assert_fs::TempDir;
738    use std::fs;
739
740    #[test]
741    fn lists_built_in_template_ids() {
742        let engine = ScaffoldEngine::new();
743        let templates = engine.templates().unwrap();
744        assert!(!templates.is_empty());
745        assert!(templates.iter().any(|tpl| tpl.id == "rust-wasi-p2-min"));
746    }
747
748    #[test]
749    fn resolves_template() {
750        let engine = ScaffoldEngine::new();
751        let descriptor = engine.resolve_template("rust-wasi-p2-min").unwrap();
752        assert_eq!(descriptor.id, "rust-wasi-p2-min");
753    }
754
755    #[test]
756    fn builtin_metadata_is_available() {
757        let dir = BUILTIN_COMPONENT_TEMPLATES
758            .get_dir("rust-wasi-p2-min")
759            .expect("template dir");
760        let meta_path = dir.path().join(METADATA_FILE);
761        assert!(dir.get_file(&meta_path).is_some());
762        let metadata = embedded_metadata(dir, "rust-wasi-p2-min").expect("metadata");
763        assert_eq!(
764            metadata.description.as_deref(),
765            Some("Minimal Rust + WASI-P2 component starter")
766        );
767        assert_eq!(metadata.tags, vec!["rust", "wasi-p2", "component"]);
768    }
769
770    #[test]
771    fn scaffolds_into_empty_directory() {
772        let temp = TempDir::new().unwrap();
773        let target = temp.path().join("demo-component");
774        let engine = ScaffoldEngine::new();
775        let request = ScaffoldRequest {
776            name: "demo-component".into(),
777            path: target.clone(),
778            template_id: "rust-wasi-p2-min".into(),
779            org: "ai.greentic".into(),
780            version: "0.1.0".into(),
781            license: "MIT".into(),
782            wit_world: DEFAULT_WIT_WORLD.into(),
783            non_interactive: true,
784            year_override: Some(2030),
785            dependency_mode: DependencyMode::Local,
786        };
787        let outcome = engine.scaffold(request).unwrap();
788        assert!(target.join("Cargo.toml").exists());
789        assert!(
790            outcome
791                .created
792                .iter()
793                .any(|path| path.contains("Cargo.toml"))
794        );
795    }
796
797    #[test]
798    fn refuses_non_empty_directory() {
799        let temp = TempDir::new().unwrap();
800        let target = temp.path().join("demo");
801        fs::create_dir_all(&target).unwrap();
802        fs::write(target.join("file"), "data").unwrap();
803        let engine = ScaffoldEngine::new();
804        let request = ScaffoldRequest {
805            name: "demo".into(),
806            path: target.clone(),
807            template_id: "rust-wasi-p2-min".into(),
808            org: "ai.greentic".into(),
809            version: "0.1.0".into(),
810            license: "MIT".into(),
811            wit_world: DEFAULT_WIT_WORLD.into(),
812            non_interactive: true,
813            year_override: None,
814            dependency_mode: DependencyMode::Local,
815        };
816        let err = engine.scaffold(request).unwrap_err();
817        assert!(matches!(err, ScaffoldError::Validation(_)));
818    }
819}