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}