Skip to main content

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