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}