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