Skip to main content

librojo/cli/
init.rs

1use std::process::{Command, Stdio};
2use std::str::FromStr;
3use std::{
4    collections::VecDeque,
5    path::{Path, PathBuf},
6};
7use std::{
8    ffi::OsStr,
9    io::{self, Write},
10};
11
12use anyhow::{bail, format_err, Context};
13use clap::Parser;
14use fs_err as fs;
15use fs_err::OpenOptions;
16use memofs::{InMemoryFs, Vfs, VfsSnapshot};
17
18use super::resolve_path;
19
20const GIT_IGNORE_PLACEHOLDER: &str = "gitignore.txt";
21
22static TEMPLATE_BINCODE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/templates.bincode"));
23
24/// Initializes a new Rojo project.
25///
26/// By default, this will attempt to initialize a 'git' repository in the
27/// project directory if `git` is installed. To avoid this, pass `--skip-git`.
28#[derive(Debug, Parser)]
29pub struct InitCommand {
30    /// Path to the place to create the project. Defaults to the current directory.
31    #[clap(default_value = "")]
32    pub path: PathBuf,
33
34    /// The kind of project to create, 'place', 'plugin', or 'model'.
35    #[clap(long, default_value = "place")]
36    pub kind: InitKind,
37
38    /// Skips the initialization of a git repository.
39    #[clap(long)]
40    pub skip_git: bool,
41}
42
43impl InitCommand {
44    pub fn run(self) -> anyhow::Result<()> {
45        let template = self.kind.template()?;
46
47        let base_path = resolve_path(&self.path)?;
48        fs::create_dir_all(&base_path)?;
49
50        let canonical = fs::canonicalize(&base_path)?;
51        let project_name = canonical
52            .file_name()
53            .and_then(|name| name.to_str())
54            .unwrap_or("new-project");
55
56        let project_params = ProjectParams {
57            name: project_name.to_owned(),
58        };
59
60        println!(
61            "Creating new {:?} project '{}'",
62            self.kind, project_params.name
63        );
64
65        let vfs = Vfs::new(template);
66        vfs.set_watch_enabled(false);
67
68        let mut queue = VecDeque::with_capacity(8);
69        for entry in vfs.read_dir("")? {
70            queue.push_back(entry?.path().to_path_buf())
71        }
72
73        while let Some(mut path) = queue.pop_front() {
74            let metadata = vfs.metadata(&path)?;
75            if metadata.is_dir() {
76                fs_err::create_dir(base_path.join(&path))?;
77                for entry in vfs.read_dir(&path)? {
78                    queue.push_back(entry?.path().to_path_buf());
79                }
80            } else {
81                let content = vfs.read_to_string_lf_normalized(&path)?;
82                if let Some(file_stem) = path.file_name().and_then(OsStr::to_str) {
83                    if file_stem == GIT_IGNORE_PLACEHOLDER {
84                        if self.skip_git {
85                            continue;
86                        } else {
87                            path.set_file_name(".gitignore");
88                        }
89                    }
90                }
91                write_if_not_exists(
92                    &base_path.join(&path),
93                    &project_params.render_template(&content),
94                )?;
95            }
96        }
97
98        if !self.skip_git && should_git_init(&base_path) {
99            log::debug!("Initializing Git repository...");
100
101            let status = Command::new("git")
102                .arg("init")
103                .current_dir(&base_path)
104                .status()?;
105
106            if !status.success() {
107                bail!("git init failed: status code {:?}", status.code());
108            }
109        }
110
111        println!("Created project successfully.");
112
113        Ok(())
114    }
115}
116
117/// The templates we support for initializing a Rojo project.
118#[derive(Debug, Clone, Copy)]
119pub enum InitKind {
120    /// A place that contains a baseplate.
121    Place,
122
123    /// An empty model, suitable for a library.
124    Model,
125
126    /// An empty plugin.
127    Plugin,
128}
129
130impl InitKind {
131    fn template(&self) -> anyhow::Result<InMemoryFs> {
132        let template_path = match self {
133            Self::Place => "place",
134            Self::Model => "model",
135            Self::Plugin => "plugin",
136        };
137
138        let snapshot: VfsSnapshot = bincode::deserialize(TEMPLATE_BINCODE)
139            .context("Rojo's templates were not properly packed into Rojo's binary. This is a bug in Rojo; please file an issue.")?;
140
141        let VfsSnapshot::Dir { mut children } = snapshot else {
142            bail!("Rojo's templates were packed as a file instead of a directory. This is a bug in Rojo; please file an issue.");
143        };
144
145        let template = children.remove(template_path).ok_or_else(|| {
146            format_err!(
147                "The template for project type {:?} is missing. This is a bug in Rojo; please file an issue.",
148                self
149            )
150        })?;
151
152        let mut fs = InMemoryFs::new();
153        fs.load_snapshot("", template)
154            .context("Failed to load Rojo's bundled template into memory")?;
155
156        Ok(fs)
157    }
158}
159
160impl FromStr for InitKind {
161    type Err = anyhow::Error;
162
163    fn from_str(source: &str) -> Result<Self, Self::Err> {
164        match source {
165            "place" => Ok(InitKind::Place),
166            "model" => Ok(InitKind::Model),
167            "plugin" => Ok(InitKind::Plugin),
168            _ => Err(format_err!(
169                "Invalid init kind '{}'. Valid kinds are: place, model, plugin",
170                source
171            )),
172        }
173    }
174}
175
176/// Contains parameters used in templates to create a project.
177struct ProjectParams {
178    name: String,
179}
180
181impl ProjectParams {
182    /// Render a template by replacing variables with project parameters.
183    fn render_template(&self, template: &str) -> String {
184        template
185            .replace("{project_name}", &self.name)
186            .replace("{rojo_version}", env!("CARGO_PKG_VERSION"))
187    }
188}
189
190/// Tells whether we should initialize a Git repository inside the given path.
191///
192/// Will return false if the user doesn't have Git installed or if the path is
193/// already inside a Git repository.
194fn should_git_init(path: &Path) -> bool {
195    let result = Command::new("git")
196        .args(["rev-parse", "--is-inside-work-tree"])
197        .stdout(Stdio::null())
198        .stderr(Stdio::null())
199        .current_dir(path)
200        .status();
201
202    match result {
203        // If the command ran, but returned a non-zero exit code, we are not in
204        // a Git repo and we should initialize one.
205        Ok(status) => !status.success(),
206
207        // If the command failed to run, we probably don't have Git installed.
208        Err(_) => false,
209    }
210}
211
212/// Write a file if it does not exist yet, otherwise, leave it alone.
213fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> {
214    let file_res = OpenOptions::new().write(true).create_new(true).open(path);
215
216    let mut file = match file_res {
217        Ok(file) => file,
218        Err(err) => {
219            return match err.kind() {
220                io::ErrorKind::AlreadyExists => return Ok(()),
221                _ => Err(err.into()),
222            }
223        }
224    };
225
226    file.write_all(contents.as_bytes())?;
227
228    Ok(())
229}