greentic_component/cmd/
new.rs

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    /// Name for the component (kebab-or-snake case)
27    #[arg(long = "name", value_name = "kebab_or_snake", required = true)]
28    pub name: String,
29    /// Path to create the component (defaults to ./<name>)
30    #[arg(long = "path", value_name = "dir")]
31    pub path: Option<PathBuf>,
32    /// Template identifier to scaffold from
33    #[arg(
34        long = "template",
35        default_value = "rust-wasi-p2-min",
36        value_name = "id"
37    )]
38    pub template: String,
39    /// Reverse DNS-style organisation identifier
40    #[arg(
41        long = "org",
42        default_value = "ai.greentic",
43        value_name = "reverse.dns"
44    )]
45    pub org: String,
46    /// Initial component version
47    #[arg(long = "version", default_value = "0.1.0", value_name = "semver")]
48    pub version: String,
49    /// License to embed into generated sources
50    #[arg(long = "license", default_value = "MIT", value_name = "id")]
51    pub license: String,
52    /// Exported WIT world name
53    #[arg(
54        long = "wit-world",
55        default_value = DEFAULT_WIT_WORLD,
56        value_name = "name"
57    )]
58    pub wit_world: String,
59    /// Run without prompting for confirmation
60    #[arg(long = "non-interactive")]
61    pub non_interactive: bool,
62    /// Skip the post-scaffold cargo check (hidden flag for testing/local dev)
63    #[arg(long = "no-check", hide = true)]
64    pub no_check: bool,
65    /// Skip git initialization after scaffolding
66    #[arg(long = "no-git")]
67    pub no_git: bool,
68    /// Emit JSON instead of human-readable output
69    #[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}