1#![cfg(feature = "cli")]
2
3use std::env;
4use std::io::{Write, stdout};
5use std::path::{Path, PathBuf};
6use std::process::{self, Command};
7use std::time::Instant;
8
9use anyhow::{Context, Result};
10use clap::Args;
11use serde::Serialize;
12use serde_json::json;
13
14use crate::cmd::post::{self, GitInitStatus, PostInitReport};
15use crate::scaffold::deps::DependencyMode;
16use crate::scaffold::engine::{
17 DEFAULT_WIT_WORLD, ScaffoldEngine, ScaffoldOutcome, ScaffoldRequest,
18};
19use crate::scaffold::validate::{self, ComponentName, OrgNamespace, ValidationError};
20
21type ValidationResult<T> = std::result::Result<T, ValidationError>;
22const SKIP_GIT_ENV: &str = "GREENTIC_SKIP_GIT";
23
24#[derive(Args, Debug, Clone)]
25pub struct NewArgs {
26 #[arg(long = "name", value_name = "kebab_or_snake", required = true)]
28 pub name: String,
29 #[arg(long = "path", value_name = "dir")]
31 pub path: Option<PathBuf>,
32 #[arg(
34 long = "template",
35 default_value = "rust-wasi-p2-min",
36 value_name = "id"
37 )]
38 pub template: String,
39 #[arg(
41 long = "org",
42 default_value = "ai.greentic",
43 value_name = "reverse.dns"
44 )]
45 pub org: String,
46 #[arg(long = "version", default_value = "0.1.0", value_name = "semver")]
48 pub version: String,
49 #[arg(long = "license", default_value = "MIT", value_name = "id")]
51 pub license: String,
52 #[arg(
54 long = "wit-world",
55 default_value = DEFAULT_WIT_WORLD,
56 value_name = "name"
57 )]
58 pub wit_world: String,
59 #[arg(long = "non-interactive")]
61 pub non_interactive: bool,
62 #[arg(long = "no-check", hide = true)]
64 pub no_check: bool,
65 #[arg(long = "no-git")]
67 pub no_git: bool,
68 #[arg(long = "json")]
70 pub json: bool,
71}
72
73pub fn run(args: NewArgs, engine: &ScaffoldEngine) -> Result<()> {
74 let request = match build_request(&args) {
75 Ok(req) => req,
76 Err(err) => {
77 emit_validation_failure(&err, args.json)?;
78 return Err(err.into());
79 }
80 };
81 if !args.json {
82 println!("processing...");
83 println!(
84 " - template: {} -> {}",
85 request.template_id,
86 request.path.display()
87 );
88 stdout().flush().ok();
89 }
90 let scaffold_started = Instant::now();
91 let outcome = engine.scaffold(request)?;
92 if !args.json {
93 println!("scaffolded files in {:.2?}", scaffold_started.elapsed());
94 stdout().flush().ok();
95 }
96 let post_started = Instant::now();
97 let skip_git = should_skip_git(&args);
98 let post_init = post::run_post_init(&outcome, skip_git);
99 let compile_check = run_compile_check(&outcome.path, args.no_check)?;
100 if args.json {
101 let payload = NewCliOutput {
102 scaffold: &outcome,
103 compile_check: &compile_check,
104 post_init: &post_init,
105 };
106 print_json(&payload)?;
107 } else {
108 print_human(&outcome, &compile_check, &post_init);
109 println!("post-init + checks in {:.2?}", post_started.elapsed());
110 }
111 if compile_check.ran && !compile_check.passed {
112 anyhow::bail!("cargo check --target wasm32-wasip2 failed");
113 }
114 Ok(())
115}
116
117fn build_request(args: &NewArgs) -> ValidationResult<ScaffoldRequest> {
118 let component_name = ComponentName::parse(&args.name)?;
119 let org = OrgNamespace::parse(&args.org)?;
120 let version = validate::normalize_version(&args.version)?;
121 let target_path = resolve_path(&component_name, args.path.as_deref())?;
122 Ok(ScaffoldRequest {
123 name: component_name.into_string(),
124 path: target_path,
125 template_id: args.template.clone(),
126 org: org.into_string(),
127 version,
128 license: args.license.clone(),
129 wit_world: args.wit_world.clone(),
130 non_interactive: args.non_interactive,
131 year_override: None,
132 dependency_mode: DependencyMode::from_env(),
133 })
134}
135
136fn resolve_path(name: &ComponentName, provided: Option<&Path>) -> ValidationResult<PathBuf> {
137 let path = validate::resolve_target_path(name, provided)?;
138 Ok(path)
139}
140
141fn print_json<T: Serialize>(value: &T) -> Result<()> {
142 let mut handle = std::io::stdout();
143 serde_json::to_writer_pretty(&mut handle, value)?;
144 handle.write_all(b"\n").ok();
145 Ok(())
146}
147
148fn print_human(outcome: &ScaffoldOutcome, check: &CompileCheckReport, post: &PostInitReport) {
149 println!("{}", outcome.human_summary());
150 print_template_metadata(outcome);
151 for path in &outcome.created {
152 println!(" - {path}");
153 }
154 print_git_summary(&post.git);
155 if !check.ran {
156 println!("cargo check (wasm32-wasip2): skipped (--no-check)");
157 } else if check.passed {
158 println!("cargo check (wasm32-wasip2): ok");
159 } else {
160 println!(
161 "cargo check (wasm32-wasip2): FAILED (exit code {:?})",
162 check.exit_code
163 );
164 if let Some(stderr) = &check.stderr
165 && !stderr.is_empty()
166 {
167 println!("{stderr}");
168 }
169 }
170 if !post.next_steps.is_empty() {
171 println!("Next steps:");
172 for step in &post.next_steps {
173 println!(" $ {step}");
174 }
175 }
176}
177
178fn print_git_summary(report: &post::GitInitReport) {
179 match report.status {
180 GitInitStatus::Initialized => {
181 if let Some(commit) = &report.commit {
182 println!("git init: ok (commit {commit})");
183 } else {
184 println!("git init: ok");
185 }
186 }
187 GitInitStatus::AlreadyPresent => {
188 println!(
189 "git init: skipped ({})",
190 report
191 .message
192 .as_deref()
193 .unwrap_or("directory already contains .git")
194 );
195 }
196 GitInitStatus::InsideWorktree => {
197 println!(
198 "git init: skipped ({})",
199 report
200 .message
201 .as_deref()
202 .unwrap_or("already inside an existing git worktree")
203 );
204 }
205 GitInitStatus::Skipped => {
206 println!(
207 "git init: skipped ({})",
208 report.message.as_deref().unwrap_or("not requested")
209 );
210 }
211 GitInitStatus::Failed => {
212 println!(
213 "git init: failed ({})",
214 report
215 .message
216 .as_deref()
217 .unwrap_or("see logs for more details")
218 );
219 }
220 }
221}
222
223fn print_template_metadata(outcome: &ScaffoldOutcome) {
224 match &outcome.template_description {
225 Some(desc) => println!("Template: {} — {desc}", outcome.template),
226 None => println!("Template: {}", outcome.template),
227 }
228 if !outcome.template_tags.is_empty() {
229 println!(" tags: {}", outcome.template_tags.join(", "));
230 }
231}
232
233fn should_skip_git(args: &NewArgs) -> bool {
234 if args.no_git {
235 return true;
236 }
237 match env::var(SKIP_GIT_ENV) {
238 Ok(value) => matches!(
239 value.trim().to_ascii_lowercase().as_str(),
240 "1" | "true" | "yes"
241 ),
242 Err(_) => false,
243 }
244}
245
246fn run_compile_check(path: &Path, skip: bool) -> Result<CompileCheckReport> {
247 const COMMAND_DISPLAY: &str = "cargo check --target wasm32-wasip2";
248 if skip {
249 return Ok(CompileCheckReport::skipped(COMMAND_DISPLAY));
250 }
251 let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
252 let mut cmd = Command::new(cargo);
253 cmd.arg("check").arg("--target").arg("wasm32-wasip2");
254 cmd.current_dir(path);
255 let start = Instant::now();
256 let output = cmd
257 .output()
258 .with_context(|| format!("failed to run `{COMMAND_DISPLAY}`"))?;
259 let duration_ms = start.elapsed().as_millis();
260 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
261 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
262 Ok(CompileCheckReport {
263 command: COMMAND_DISPLAY.to_string(),
264 ran: true,
265 passed: output.status.success(),
266 exit_code: output.status.code(),
267 duration_ms: Some(duration_ms),
268 stdout: if stdout.is_empty() {
269 None
270 } else {
271 Some(stdout)
272 },
273 stderr: if stderr.is_empty() {
274 None
275 } else {
276 Some(stderr)
277 },
278 reason: None,
279 })
280}
281
282fn emit_validation_failure(err: &ValidationError, json: bool) -> Result<()> {
283 if json {
284 let payload = json!({
285 "error": {
286 "kind": "validation",
287 "code": err.code(),
288 "message": err.to_string()
289 }
290 });
291 print_json(&payload)?;
292 process::exit(1);
293 }
294 Ok(())
295}
296
297#[derive(Serialize)]
298struct NewCliOutput<'a> {
299 scaffold: &'a ScaffoldOutcome,
300 compile_check: &'a CompileCheckReport,
301 post_init: &'a PostInitReport,
302}
303
304#[derive(Debug, Serialize)]
305struct CompileCheckReport {
306 command: String,
307 ran: bool,
308 passed: bool,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 exit_code: Option<i32>,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 duration_ms: Option<u128>,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 stdout: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 stderr: Option<String>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 reason: Option<String>,
319}
320
321impl CompileCheckReport {
322 fn skipped(command: &str) -> Self {
323 Self {
324 command: command.to_string(),
325 ran: false,
326 passed: true,
327 exit_code: None,
328 duration_ms: None,
329 stdout: None,
330 stderr: None,
331 reason: Some("skipped (--no-check)".into()),
332 }
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn default_path_uses_name() {
342 let args = NewArgs {
343 name: "demo-component".into(),
344 path: None,
345 template: "rust-wasi-p2-min".into(),
346 org: "ai.greentic".into(),
347 version: "0.1.0".into(),
348 license: "MIT".into(),
349 wit_world: DEFAULT_WIT_WORLD.into(),
350 non_interactive: false,
351 no_check: false,
352 no_git: false,
353 json: false,
354 };
355 let request = build_request(&args).unwrap();
356 assert!(request.path.ends_with("demo-component"));
357 }
358}