Skip to main content

torvyn_cli/commands/
init.rs

1//! `torvyn init` — create a new Torvyn project.
2//!
3//! Scaffolds a complete project with WIT contracts, implementation stubs,
4//! a Torvyn.toml manifest, and build configuration.
5
6use crate::cli::InitArgs;
7use crate::errors::CliError;
8use crate::output::terminal;
9use crate::output::{CommandResult, HumanRenderable, OutputContext};
10use crate::templates::{self, TemplateVars};
11use serde::Serialize;
12use std::path::{Path, PathBuf};
13
14/// Result of a successful `torvyn init`.
15#[derive(Debug, Serialize)]
16pub struct InitResult {
17    /// The project name.
18    pub project_name: String,
19    /// The template used.
20    pub template: String,
21    /// The directory created.
22    pub directory: PathBuf,
23    /// Files created.
24    pub files_created: Vec<PathBuf>,
25    /// Whether git was initialized.
26    pub git_initialized: bool,
27}
28
29impl HumanRenderable for InitResult {
30    fn render_human(&self, ctx: &OutputContext) {
31        terminal::print_success(
32            ctx,
33            &format!(
34                "Created project \"{}\" with template \"{}\"",
35                self.project_name, self.template
36            ),
37        );
38        eprintln!();
39
40        // Render directory tree
41        let mut entries: Vec<(usize, &str, bool)> = Vec::new();
42        entries.push((0, &self.project_name, true));
43        for (i, file) in self.files_created.iter().enumerate() {
44            let is_last = i == self.files_created.len() - 1;
45            let display = file.to_str().unwrap_or("???");
46            entries.push((1, display, is_last));
47        }
48        terminal::print_tree(ctx, &entries);
49
50        eprintln!();
51        eprintln!("  Next steps:");
52        eprintln!("    cd {}", self.directory.display());
53        eprintln!("    torvyn check              # Validate contracts and manifest");
54        eprintln!("    torvyn build              # Compile to WebAssembly component");
55        eprintln!("    torvyn run --limit 10     # Run and see output");
56    }
57}
58
59/// Execute the `torvyn init` command.
60///
61/// COLD PATH.
62pub async fn execute(
63    args: &InitArgs,
64    ctx: &OutputContext,
65) -> Result<CommandResult<InitResult>, CliError> {
66    // Determine project name and directory
67    let project_name = match &args.project_name {
68        Some(name) => name.clone(),
69        None => {
70            let cwd = std::env::current_dir().map_err(|e| CliError::Io {
71                detail: format!("Cannot determine current directory: {e}"),
72                path: None,
73            })?;
74            cwd.file_name()
75                .and_then(|n| n.to_str())
76                .map(|s| s.to_string())
77                .ok_or_else(|| CliError::Config {
78                    detail: "Cannot determine project name from current directory".into(),
79                    file: None,
80                    suggestion: "Provide a project name: torvyn init my-project".into(),
81                })?
82        }
83    };
84
85    // Validate project name
86    validate_project_name(&project_name)?;
87
88    let target_dir = if args.project_name.is_some() {
89        PathBuf::from(&project_name)
90    } else {
91        PathBuf::from(".")
92    };
93
94    // Check if directory exists and is non-empty
95    if target_dir.exists() && target_dir != Path::new(".") && !args.force {
96        let entries: Vec<_> = std::fs::read_dir(&target_dir)
97            .map_err(|e| CliError::Io {
98                detail: e.to_string(),
99                path: Some(target_dir.display().to_string()),
100            })?
101            .collect();
102
103        if !entries.is_empty() {
104            return Err(CliError::Config {
105                detail: format!(
106                    "Directory \"{}\" already exists and is not empty",
107                    target_dir.display()
108                ),
109                file: None,
110                suggestion: "Use --force to overwrite, or choose a different name.".into(),
111            });
112        }
113    }
114
115    ctx.print_debug(&format!(
116        "Creating project '{}' with template '{:?}'",
117        project_name, args.template
118    ));
119
120    // Create project directory
121    std::fs::create_dir_all(&target_dir).map_err(|e| CliError::Io {
122        detail: format!("Failed to create directory: {e}"),
123        path: Some(target_dir.display().to_string()),
124    })?;
125
126    // Get and expand template
127    let template = templates::get_template(args.template);
128    let vars = TemplateVars::new(&project_name, &args.contract_version);
129    let files_created =
130        templates::expand_template(&template, &vars, &target_dir).map_err(|e| CliError::Io {
131            detail: format!("Failed to write template files: {e}"),
132            path: Some(target_dir.display().to_string()),
133        })?;
134
135    // Initialize git
136    let git_initialized = if !args.no_git {
137        init_git_repo(&target_dir).unwrap_or(false)
138    } else {
139        false
140    };
141
142    let result = InitResult {
143        project_name: project_name.clone(),
144        template: format!("{:?}", args.template).to_lowercase(),
145        directory: target_dir,
146        files_created,
147        git_initialized,
148    };
149
150    Ok(CommandResult {
151        success: true,
152        command: "init".into(),
153        data: result,
154        warnings: vec![],
155    })
156}
157
158/// Validate that a project name is acceptable.
159fn validate_project_name(name: &str) -> Result<(), CliError> {
160    if name.is_empty() || name.len() > 64 {
161        return Err(CliError::Config {
162            detail: format!(
163                "Project name must be 1\u{2013}64 characters, got {}",
164                name.len()
165            ),
166            file: None,
167            suggestion: "Choose a shorter name.".into(),
168        });
169    }
170
171    if !name
172        .chars()
173        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
174    {
175        return Err(CliError::Config {
176            detail: format!("Project name contains invalid characters: \"{name}\""),
177            file: None,
178            suggestion: "Use only alphanumeric characters, hyphens, and underscores.".into(),
179        });
180    }
181
182    if name.starts_with('-') || name.starts_with(|c: char| c.is_ascii_digit()) {
183        return Err(CliError::Config {
184            detail: format!("Project name cannot start with a hyphen or digit: \"{name}\""),
185            file: None,
186            suggestion: "Start with a letter or underscore.".into(),
187        });
188    }
189
190    Ok(())
191}
192
193/// Attempt to initialize a git repository.
194fn init_git_repo(dir: &Path) -> Result<bool, std::io::Error> {
195    let status = std::process::Command::new("git")
196        .args(["init"])
197        .current_dir(dir)
198        .stdout(std::process::Stdio::null())
199        .stderr(std::process::Stdio::null())
200        .status();
201
202    match status {
203        Ok(s) => Ok(s.success()),
204        Err(_) => Ok(false),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_validate_project_name_valid() {
214        assert!(validate_project_name("my-project").is_ok());
215        assert!(validate_project_name("hello_world").is_ok());
216        assert!(validate_project_name("a").is_ok());
217    }
218
219    #[test]
220    fn test_validate_project_name_empty() {
221        assert!(validate_project_name("").is_err());
222    }
223
224    #[test]
225    fn test_validate_project_name_too_long() {
226        let name: String = "a".repeat(65);
227        assert!(validate_project_name(&name).is_err());
228    }
229
230    #[test]
231    fn test_validate_project_name_invalid_chars() {
232        assert!(validate_project_name("my project").is_err());
233        assert!(validate_project_name("hello/world").is_err());
234    }
235
236    #[test]
237    fn test_validate_project_name_starts_with_hyphen() {
238        assert!(validate_project_name("-hello").is_err());
239    }
240
241    #[test]
242    fn test_validate_project_name_starts_with_digit() {
243        assert!(validate_project_name("1hello").is_err());
244    }
245}