librojo/cli/
init.rs

1use std::io::{self, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::str::FromStr;
5
6use anyhow::{bail, format_err};
7use clap::Parser;
8use fs_err as fs;
9use fs_err::OpenOptions;
10
11use super::resolve_path;
12
13static MODEL_PROJECT: &str =
14    include_str!("../../assets/default-model-project/default.project.json");
15static MODEL_README: &str = include_str!("../../assets/default-model-project/README.md");
16static MODEL_INIT: &str = include_str!("../../assets/default-model-project/src-init.luau");
17static MODEL_GIT_IGNORE: &str = include_str!("../../assets/default-model-project/gitignore.txt");
18
19static PLACE_PROJECT: &str =
20    include_str!("../../assets/default-place-project/default.project.json");
21static PLACE_README: &str = include_str!("../../assets/default-place-project/README.md");
22static PLACE_GIT_IGNORE: &str = include_str!("../../assets/default-place-project/gitignore.txt");
23
24static PLUGIN_PROJECT: &str =
25    include_str!("../../assets/default-plugin-project/default.project.json");
26static PLUGIN_README: &str = include_str!("../../assets/default-plugin-project/README.md");
27static PLUGIN_GIT_IGNORE: &str = include_str!("../../assets/default-plugin-project/gitignore.txt");
28
29/// Initializes a new Rojo project.
30#[derive(Debug, Parser)]
31pub struct InitCommand {
32    /// Path to the place to create the project. Defaults to the current directory.
33    #[clap(default_value = "")]
34    pub path: PathBuf,
35
36    /// The kind of project to create, 'place', 'plugin', or 'model'. Defaults to place.
37    #[clap(long, default_value = "place")]
38    pub kind: InitKind,
39}
40
41impl InitCommand {
42    pub fn run(self) -> anyhow::Result<()> {
43        let base_path = resolve_path(&self.path);
44        fs::create_dir_all(&base_path)?;
45
46        let canonical = fs::canonicalize(&base_path)?;
47        let project_name = canonical
48            .file_name()
49            .and_then(|name| name.to_str())
50            .unwrap_or("new-project");
51
52        let project_params = ProjectParams {
53            name: project_name.to_owned(),
54        };
55
56        match self.kind {
57            InitKind::Place => init_place(&base_path, project_params)?,
58            InitKind::Model => init_model(&base_path, project_params)?,
59            InitKind::Plugin => init_plugin(&base_path, project_params)?,
60        }
61
62        println!("Created project successfully.");
63
64        Ok(())
65    }
66}
67
68/// The templates we support for initializing a Rojo project.
69#[derive(Debug, Clone, Copy)]
70pub enum InitKind {
71    /// A place that contains a baseplate.
72    Place,
73
74    /// An empty model, suitable for a library.
75    Model,
76
77    /// An empty plugin.
78    Plugin,
79}
80
81impl FromStr for InitKind {
82    type Err = anyhow::Error;
83
84    fn from_str(source: &str) -> Result<Self, Self::Err> {
85        match source {
86            "place" => Ok(InitKind::Place),
87            "model" => Ok(InitKind::Model),
88            "plugin" => Ok(InitKind::Plugin),
89            _ => Err(format_err!(
90                "Invalid init kind '{}'. Valid kinds are: place, model, plugin",
91                source
92            )),
93        }
94    }
95}
96
97fn init_place(base_path: &Path, project_params: ProjectParams) -> anyhow::Result<()> {
98    println!("Creating new place project '{}'", project_params.name);
99
100    let project_file = project_params.render_template(PLACE_PROJECT);
101    try_create_project(base_path, &project_file)?;
102
103    let readme = project_params.render_template(PLACE_README);
104    write_if_not_exists(&base_path.join("README.md"), &readme)?;
105
106    let src = base_path.join("src");
107    fs::create_dir_all(&src)?;
108
109    let src_shared = src.join("shared");
110    fs::create_dir_all(src.join(&src_shared))?;
111
112    let src_server = src.join("server");
113    fs::create_dir_all(src.join(&src_server))?;
114
115    let src_client = src.join("client");
116    fs::create_dir_all(src.join(&src_client))?;
117
118    write_if_not_exists(
119        &src_shared.join("Hello.luau"),
120        "return function()\n\tprint(\"Hello, world!\")\nend",
121    )?;
122
123    write_if_not_exists(
124        &src_server.join("init.server.luau"),
125        "print(\"Hello world, from server!\")",
126    )?;
127
128    write_if_not_exists(
129        &src_client.join("init.client.luau"),
130        "print(\"Hello world, from client!\")",
131    )?;
132
133    let git_ignore = project_params.render_template(PLACE_GIT_IGNORE);
134    try_git_init(base_path, &git_ignore)?;
135
136    Ok(())
137}
138
139fn init_model(base_path: &Path, project_params: ProjectParams) -> anyhow::Result<()> {
140    println!("Creating new model project '{}'", project_params.name);
141
142    let project_file = project_params.render_template(MODEL_PROJECT);
143    try_create_project(base_path, &project_file)?;
144
145    let readme = project_params.render_template(MODEL_README);
146    write_if_not_exists(&base_path.join("README.md"), &readme)?;
147
148    let src = base_path.join("src");
149    fs::create_dir_all(&src)?;
150
151    let init = project_params.render_template(MODEL_INIT);
152    write_if_not_exists(&src.join("init.luau"), &init)?;
153
154    let git_ignore = project_params.render_template(MODEL_GIT_IGNORE);
155    try_git_init(base_path, &git_ignore)?;
156
157    Ok(())
158}
159
160fn init_plugin(base_path: &Path, project_params: ProjectParams) -> anyhow::Result<()> {
161    println!("Creating new plugin project '{}'", project_params.name);
162
163    let project_file = project_params.render_template(PLUGIN_PROJECT);
164    try_create_project(base_path, &project_file)?;
165
166    let readme = project_params.render_template(PLUGIN_README);
167    write_if_not_exists(&base_path.join("README.md"), &readme)?;
168
169    let src = base_path.join("src");
170    fs::create_dir_all(&src)?;
171
172    write_if_not_exists(
173        &src.join("init.server.luau"),
174        "print(\"Hello world, from plugin!\")\n",
175    )?;
176
177    let git_ignore = project_params.render_template(PLUGIN_GIT_IGNORE);
178    try_git_init(base_path, &git_ignore)?;
179
180    Ok(())
181}
182
183/// Contains parameters used in templates to create a project.
184struct ProjectParams {
185    name: String,
186}
187
188impl ProjectParams {
189    /// Render a template by replacing variables with project parameters.
190    fn render_template(&self, template: &str) -> String {
191        template
192            .replace("{project_name}", &self.name)
193            .replace("{rojo_version}", env!("CARGO_PKG_VERSION"))
194    }
195}
196
197/// Attempt to initialize a Git repository if necessary, and create .gitignore.
198fn try_git_init(path: &Path, git_ignore: &str) -> Result<(), anyhow::Error> {
199    if should_git_init(path) {
200        log::debug!("Initializing Git repository...");
201
202        let status = Command::new("git").arg("init").current_dir(path).status()?;
203
204        if !status.success() {
205            bail!("git init failed: status code {:?}", status.code());
206        }
207    }
208
209    write_if_not_exists(&path.join(".gitignore"), git_ignore)?;
210
211    Ok(())
212}
213
214/// Tells whether we should initialize a Git repository inside the given path.
215///
216/// Will return false if the user doesn't have Git installed or if the path is
217/// already inside a Git repository.
218fn should_git_init(path: &Path) -> bool {
219    let result = Command::new("git")
220        .args(["rev-parse", "--is-inside-work-tree"])
221        .stdout(Stdio::null())
222        .stderr(Stdio::null())
223        .current_dir(path)
224        .status();
225
226    match result {
227        // If the command ran, but returned a non-zero exit code, we are not in
228        // a Git repo and we should initialize one.
229        Ok(status) => !status.success(),
230
231        // If the command failed to run, we probably don't have Git installed.
232        Err(_) => false,
233    }
234}
235
236/// Write a file if it does not exist yet, otherwise, leave it alone.
237fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> {
238    let file_res = OpenOptions::new().write(true).create_new(true).open(path);
239
240    let mut file = match file_res {
241        Ok(file) => file,
242        Err(err) => {
243            return match err.kind() {
244                io::ErrorKind::AlreadyExists => return Ok(()),
245                _ => Err(err.into()),
246            }
247        }
248    };
249
250    file.write_all(contents.as_bytes())?;
251
252    Ok(())
253}
254
255/// Try to create a project file and fail if it already exists.
256fn try_create_project(base_path: &Path, contents: &str) -> Result<(), anyhow::Error> {
257    let project_path = base_path.join("default.project.json");
258
259    let file_res = OpenOptions::new()
260        .write(true)
261        .create_new(true)
262        .open(&project_path);
263
264    let mut file = match file_res {
265        Ok(file) => file,
266        Err(err) => {
267            return match err.kind() {
268                io::ErrorKind::AlreadyExists => {
269                    bail!("Project file already exists: {}", project_path.display())
270                }
271                _ => Err(err.into()),
272            }
273        }
274    };
275
276    file.write_all(contents.as_bytes())?;
277
278    Ok(())
279}