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