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};
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 && !self.skip_git {
84                        path.set_file_name(".gitignore");
85                    }
86                }
87                write_if_not_exists(
88                    &base_path.join(&path),
89                    &project_params.render_template(&content),
90                )?;
91            }
92        }
93
94        if !self.skip_git && should_git_init(&base_path) {
95            log::debug!("Initializing Git repository...");
96
97            let status = Command::new("git")
98                .arg("init")
99                .current_dir(&base_path)
100                .status()?;
101
102            if !status.success() {
103                bail!("git init failed: status code {:?}", status.code());
104            }
105        }
106
107        println!("Created project successfully.");
108
109        Ok(())
110    }
111}
112
113/// The templates we support for initializing a Rojo project.
114#[derive(Debug, Clone, Copy)]
115pub enum InitKind {
116    /// A place that contains a baseplate.
117    Place,
118
119    /// An empty model, suitable for a library.
120    Model,
121
122    /// An empty plugin.
123    Plugin,
124}
125
126impl InitKind {
127    fn template(&self) -> InMemoryFs {
128        let template_path = match self {
129            Self::Place => "place",
130            Self::Model => "model",
131            Self::Plugin => "plugin",
132        };
133
134        let snapshot: VfsSnapshot = bincode::deserialize(TEMPLATE_BINCODE)
135            .expect("Rojo's templates were not properly packed into Rojo's binary");
136
137        if let VfsSnapshot::Dir { mut children } = snapshot {
138            if let Some(template) = children.remove(template_path) {
139                let mut fs = InMemoryFs::new();
140                fs.load_snapshot("", template)
141                    .expect("loading a template in memory should never fail");
142                fs
143            } else {
144                panic!("template for project type {:?} is missing", self)
145            }
146        } else {
147            panic!("Rojo's templates were packed as a file instead of a directory")
148        }
149    }
150}
151
152impl FromStr for InitKind {
153    type Err = anyhow::Error;
154
155    fn from_str(source: &str) -> Result<Self, Self::Err> {
156        match source {
157            "place" => Ok(InitKind::Place),
158            "model" => Ok(InitKind::Model),
159            "plugin" => Ok(InitKind::Plugin),
160            _ => Err(format_err!(
161                "Invalid init kind '{}'. Valid kinds are: place, model, plugin",
162                source
163            )),
164        }
165    }
166}
167
168/// Contains parameters used in templates to create a project.
169struct ProjectParams {
170    name: String,
171}
172
173impl ProjectParams {
174    /// Render a template by replacing variables with project parameters.
175    fn render_template(&self, template: &str) -> String {
176        template
177            .replace("{project_name}", &self.name)
178            .replace("{rojo_version}", env!("CARGO_PKG_VERSION"))
179    }
180}
181
182/// Tells whether we should initialize a Git repository inside the given path.
183///
184/// Will return false if the user doesn't have Git installed or if the path is
185/// already inside a Git repository.
186fn should_git_init(path: &Path) -> bool {
187    let result = Command::new("git")
188        .args(["rev-parse", "--is-inside-work-tree"])
189        .stdout(Stdio::null())
190        .stderr(Stdio::null())
191        .current_dir(path)
192        .status();
193
194    match result {
195        // If the command ran, but returned a non-zero exit code, we are not in
196        // a Git repo and we should initialize one.
197        Ok(status) => !status.success(),
198
199        // If the command failed to run, we probably don't have Git installed.
200        Err(_) => false,
201    }
202}
203
204/// Write a file if it does not exist yet, otherwise, leave it alone.
205fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> {
206    let file_res = OpenOptions::new().write(true).create_new(true).open(path);
207
208    let mut file = match file_res {
209        Ok(file) => file,
210        Err(err) => {
211            return match err.kind() {
212                io::ErrorKind::AlreadyExists => return Ok(()),
213                _ => Err(err.into()),
214            }
215        }
216    };
217
218    file.write_all(contents.as_bytes())?;
219
220    Ok(())
221}