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!("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}