torvyn_cli/commands/
init.rs1use 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#[derive(Debug, Serialize)]
16pub struct InitResult {
17 pub project_name: String,
19 pub template: String,
21 pub directory: PathBuf,
23 pub files_created: Vec<PathBuf>,
25 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 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
59pub async fn execute(
63 args: &InitArgs,
64 ctx: &OutputContext,
65) -> Result<CommandResult<InitResult>, CliError> {
66 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(&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 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 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 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 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
158fn 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
193fn 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}