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