librojo/
project.rs

1use std::{
2    collections::{BTreeMap, HashMap, HashSet},
3    ffi::OsStr,
4    fs, io,
5    net::IpAddr,
6    path::{Path, PathBuf},
7};
8
9use memofs::Vfs;
10use rbx_dom_weak::{Ustr, UstrMap};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::{glob::Glob, resolution::UnresolvedValue, snapshot::SyncRule};
15
16static PROJECT_FILENAME: &str = "default.project.json";
17
18/// Error type returned by any function that handles projects.
19#[derive(Debug, Error)]
20#[error(transparent)]
21pub struct ProjectError(#[from] Error);
22
23#[derive(Debug, Error)]
24enum Error {
25    #[error(
26        "Rojo requires a project file, but no project file was found in path {}\n\
27        See https://rojo.space/docs/ for guides and documentation.",
28        .path.display()
29    )]
30    NoProjectFound { path: PathBuf },
31
32    #[error("The folder for the provided project cannot be used as a project name: {}\n\
33            Consider setting the `name` field on this project.", .path.display())]
34    FolderNameInvalid { path: PathBuf },
35
36    #[error("The file name of the provided project cannot be used as a project name: {}.\n\
37            Consider setting the `name` field on this project.", .path.display())]
38    ProjectNameInvalid { path: PathBuf },
39
40    #[error(transparent)]
41    Io {
42        #[from]
43        source: io::Error,
44    },
45
46    #[error("Error parsing Rojo project in path {}", .path.display())]
47    Json {
48        source: serde_json::Error,
49        path: PathBuf,
50    },
51}
52
53/// Contains all of the configuration for a Rojo-managed project.
54///
55/// Project files are stored in `.project.json` files.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields, rename_all = "camelCase")]
58pub struct Project {
59    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
60    schema: Option<String>,
61
62    /// The name of the top-level instance described by the project.
63    pub name: Option<String>,
64
65    /// The tree of instances described by this project. Projects always
66    /// describe at least one instance.
67    pub tree: ProjectNode,
68
69    /// If specified, sets the default port that `rojo serve` should use when
70    /// using this project for live sync.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub serve_port: Option<u16>,
73
74    /// If specified, contains the set of place IDs that this project is
75    /// compatible with when doing live sync.
76    ///
77    /// This setting is intended to help prevent syncing a Rojo project into the
78    /// wrong Roblox place.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub serve_place_ids: Option<HashSet<u64>>,
81
82    /// If specified, contains a set of place IDs that this project is
83    /// not compatible with when doing live sync.
84    ///
85    /// This setting is intended to help prevent syncing a Rojo project into the
86    /// wrong Roblox place.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub blocked_place_ids: Option<HashSet<u64>>,
89
90    /// If specified, sets the current place's place ID when connecting to the
91    /// Rojo server from Roblox Studio.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub place_id: Option<u64>,
94
95    /// If specified, sets the current place's game ID when connecting to the
96    /// Rojo server from Roblox Studio.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub game_id: Option<u64>,
99
100    /// If specified, this address will be used in place of the default address
101    /// As long as --address is unprovided.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub serve_address: Option<IpAddr>,
104
105    /// Determines if Rojo should emit scripts with the appropriate `RunContext`
106    /// for `*.client.lua` and `*.server.lua` files in the project instead of
107    /// using `Script` and `LocalScript` Instances.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub emit_legacy_scripts: Option<bool>,
110
111    /// A list of globs, relative to the folder the project file is in, that
112    /// match files that should be excluded if Rojo encounters them.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub glob_ignore_paths: Vec<Glob>,
115
116    /// A list of mappings of globs to syncing rules. If a file matches a glob,
117    /// it will be 'transformed' into an Instance following the rule provided.
118    /// Globs are relative to the folder the project file is in.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub sync_rules: Vec<SyncRule>,
121
122    /// The path to the file that this project came from. Relative paths in the
123    /// project should be considered relative to the parent of this field, also
124    /// given by `Project::folder_location`.
125    #[serde(skip)]
126    pub file_location: PathBuf,
127}
128
129impl Project {
130    /// Tells whether the given path describes a Rojo project.
131    pub fn is_project_file(path: &Path) -> bool {
132        path.file_name()
133            .and_then(|name| name.to_str())
134            .map(|name| name.ends_with(".project.json"))
135            .unwrap_or(false)
136    }
137
138    /// Attempt to locate a project represented by the given path.
139    ///
140    /// This will find a project if the path refers to a `.project.json` file,
141    /// or is a folder that contains a `default.project.json` file.
142    fn locate(path: &Path) -> Option<PathBuf> {
143        let meta = fs::metadata(path).ok()?;
144
145        if meta.is_file() {
146            if Project::is_project_file(path) {
147                Some(path.to_path_buf())
148            } else {
149                None
150            }
151        } else {
152            let child_path = path.join(PROJECT_FILENAME);
153            let child_meta = fs::metadata(&child_path).ok()?;
154
155            if child_meta.is_file() {
156                Some(child_path)
157            } else {
158                // This is a folder with the same name as a Rojo default project
159                // file.
160                //
161                // That's pretty weird, but we can roll with it.
162                None
163            }
164        }
165    }
166
167    /// Sets the name of a project. The order it handles is as follows:
168    ///
169    /// - If the project is a `default.project.json`, uses the folder's name
170    /// - If a fallback is specified, uses that blindly
171    /// - Otherwise, loops through sync rules (including the default ones!) and
172    ///   uses the name of the first one that matches and is a project file
173    fn set_file_name(&mut self, fallback: Option<&str>) -> Result<(), Error> {
174        let file_name = self
175            .file_location
176            .file_name()
177            .and_then(OsStr::to_str)
178            .ok_or_else(|| Error::ProjectNameInvalid {
179                path: self.file_location.clone(),
180            })?;
181
182        // If you're editing this to be generic, make sure you also alter the
183        // snapshot middleware to support generic init paths.
184        if file_name == PROJECT_FILENAME {
185            let folder_name = self.folder_location().file_name().and_then(OsStr::to_str);
186            if let Some(folder_name) = folder_name {
187                self.name = Some(folder_name.to_string());
188            } else {
189                return Err(Error::FolderNameInvalid {
190                    path: self.file_location.clone(),
191                });
192            }
193        } else if let Some(fallback) = fallback {
194            self.name = Some(fallback.to_string());
195        } else {
196            // As of the time of writing (July 10, 2024) there is no way for
197            // this code path to be reachable. It can in theory be reached from
198            // both `load_fuzzy` and `load_exact` but in practice it's never
199            // invoked.
200            // If you're adding this codepath, make sure a test for it exists
201            // and that it handles sync rules appropriately.
202            todo!(
203                "set_file_name doesn't support loading project files that aren't default.project.json without a fallback provided"
204            );
205        }
206
207        Ok(())
208    }
209
210    /// Loads a Project file from the provided contents with its source set as
211    /// the provided location.
212    fn load_from_slice(
213        contents: &[u8],
214        project_file_location: PathBuf,
215        fallback_name: Option<&str>,
216    ) -> Result<Self, Error> {
217        let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json {
218            source,
219            path: project_file_location.clone(),
220        })?;
221        project.file_location = project_file_location;
222        project.check_compatibility();
223        if project.name.is_none() {
224            project.set_file_name(fallback_name)?;
225        }
226
227        Ok(project)
228    }
229
230    /// Loads a Project from a path. This will find the project if it refers to
231    /// a `.project.json` file or if it refers to a directory that contains a
232    /// file named `default.project.json`.
233    pub fn load_fuzzy(
234        vfs: &Vfs,
235        fuzzy_project_location: &Path,
236    ) -> Result<Option<Self>, ProjectError> {
237        if let Some(project_path) = Self::locate(fuzzy_project_location) {
238            let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
239                io::ErrorKind::NotFound => Error::NoProjectFound {
240                    path: project_path.to_path_buf(),
241                },
242                _ => e.into(),
243            })?;
244
245            Ok(Some(Self::load_from_slice(&contents, project_path, None)?))
246        } else {
247            Ok(None)
248        }
249    }
250
251    /// Loads a Project from a path.
252    pub fn load_exact(
253        vfs: &Vfs,
254        project_file_location: &Path,
255        fallback_name: Option<&str>,
256    ) -> Result<Self, ProjectError> {
257        let project_path = project_file_location.to_path_buf();
258        let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
259            io::ErrorKind::NotFound => Error::NoProjectFound {
260                path: project_path.to_path_buf(),
261            },
262            _ => e.into(),
263        })?;
264
265        Ok(Self::load_from_slice(
266            &contents,
267            project_path,
268            fallback_name,
269        )?)
270    }
271
272    /// Checks if there are any compatibility issues with this project file and
273    /// warns the user if there are any.
274    fn check_compatibility(&self) {
275        self.tree.validate_reserved_names();
276    }
277
278    pub fn folder_location(&self) -> &Path {
279        self.file_location.parent().unwrap()
280    }
281}
282
283#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
284pub struct OptionalPathNode {
285    #[serde(serialize_with = "crate::path_serializer::serialize_absolute")]
286    pub optional: PathBuf,
287}
288
289impl OptionalPathNode {
290    pub fn new(optional: PathBuf) -> Self {
291        OptionalPathNode { optional }
292    }
293}
294
295/// Describes a path that is either optional or required
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297#[serde(untagged)]
298pub enum PathNode {
299    Required(#[serde(serialize_with = "crate::path_serializer::serialize_absolute")] PathBuf),
300    Optional(OptionalPathNode),
301}
302
303impl PathNode {
304    pub fn path(&self) -> &Path {
305        match self {
306            PathNode::Required(pathbuf) => pathbuf,
307            PathNode::Optional(OptionalPathNode { optional }) => optional,
308        }
309    }
310}
311
312/// Describes an instance and its descendants in a project.
313#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
314pub struct ProjectNode {
315    /// If set, defines the ClassName of the described instance.
316    ///
317    /// `$className` MUST be set if `$path` is not set.
318    ///
319    /// `$className` CANNOT be set if `$path` is set and the instance described
320    /// by that path has a ClassName other than Folder.
321    #[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
322    pub class_name: Option<Ustr>,
323
324    /// If set, defines an ID for the described Instance that can be used
325    /// to refer to it for the purpose of referent properties.
326    #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
327    pub id: Option<String>,
328
329    /// Contains all of the children of the described instance.
330    #[serde(flatten)]
331    pub children: BTreeMap<String, ProjectNode>,
332
333    /// The properties that will be assigned to the resulting instance.
334    ///
335    // TODO: Is this legal to set if $path is set?
336    #[serde(
337        rename = "$properties",
338        default,
339        skip_serializing_if = "HashMap::is_empty"
340    )]
341    pub properties: UstrMap<UnresolvedValue>,
342
343    #[serde(
344        rename = "$attributes",
345        default,
346        skip_serializing_if = "HashMap::is_empty"
347    )]
348    pub attributes: HashMap<String, UnresolvedValue>,
349
350    /// Defines the behavior when Rojo encounters unknown instances in Roblox
351    /// Studio during live sync. `$ignoreUnknownInstances` should be considered
352    /// a large hammer and used with care.
353    ///
354    /// If set to `true`, those instances will be left alone. This may cause
355    /// issues when files that turn into instances are removed while Rojo is not
356    /// running.
357    ///
358    /// If set to `false`, Rojo will destroy any instances it does not
359    /// recognize.
360    ///
361    /// If unset, its default value depends on other settings:
362    /// - If `$path` is not set, defaults to `true`
363    /// - If `$path` is set, defaults to `false`
364    #[serde(
365        rename = "$ignoreUnknownInstances",
366        skip_serializing_if = "Option::is_none"
367    )]
368    pub ignore_unknown_instances: Option<bool>,
369
370    /// Defines that this instance should come from the given file path. This
371    /// path can point to any file type supported by Rojo, including Lua files
372    /// (`.lua`), Roblox models (`.rbxm`, `.rbxmx`), and localization table
373    /// spreadsheets (`.csv`).
374    #[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
375    pub path: Option<PathNode>,
376}
377
378impl ProjectNode {
379    fn validate_reserved_names(&self) {
380        for (name, child) in &self.children {
381            if name.starts_with('$') {
382                log::warn!(
383                    "Keys starting with '$' are reserved by Rojo to ensure forward compatibility."
384                );
385                log::warn!(
386                    "This project uses the key '{}', which should be renamed.",
387                    name
388                );
389            }
390
391            child.validate_reserved_names();
392        }
393    }
394}
395
396#[cfg(test)]
397mod test {
398    use super::*;
399
400    #[test]
401    fn path_node_required() {
402        let path_node: PathNode = serde_json::from_str(r#""src""#).unwrap();
403        assert_eq!(path_node, PathNode::Required(PathBuf::from("src")));
404    }
405
406    #[test]
407    fn path_node_optional() {
408        let path_node: PathNode = serde_json::from_str(r#"{ "optional": "src" }"#).unwrap();
409        assert_eq!(
410            path_node,
411            PathNode::Optional(OptionalPathNode::new(PathBuf::from("src")))
412        );
413    }
414
415    #[test]
416    fn project_node_required() {
417        let project_node: ProjectNode = serde_json::from_str(
418            r#"{
419                "$path": "src"
420            }"#,
421        )
422        .unwrap();
423
424        assert_eq!(
425            project_node.path,
426            Some(PathNode::Required(PathBuf::from("src")))
427        );
428    }
429
430    #[test]
431    fn project_node_optional() {
432        let project_node: ProjectNode = serde_json::from_str(
433            r#"{
434                "$path": { "optional": "src" }
435            }"#,
436        )
437        .unwrap();
438
439        assert_eq!(
440            project_node.path,
441            Some(PathNode::Optional(OptionalPathNode::new(PathBuf::from(
442                "src"
443            ))))
444        );
445    }
446
447    #[test]
448    fn project_node_none() {
449        let project_node: ProjectNode = serde_json::from_str(
450            r#"{
451                "$className": "Folder"
452            }"#,
453        )
454        .unwrap();
455
456        assert_eq!(project_node.path, None);
457    }
458
459    #[test]
460    fn project_node_optional_serialize_absolute() {
461        let project_node: ProjectNode = serde_json::from_str(
462            r#"{
463                "$path": { "optional": "..\\src" }
464            }"#,
465        )
466        .unwrap();
467
468        let serialized = serde_json::to_string(&project_node).unwrap();
469        assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
470    }
471
472    #[test]
473    fn project_node_optional_serialize_absolute_no_change() {
474        let project_node: ProjectNode = serde_json::from_str(
475            r#"{
476                "$path": { "optional": "../src" }
477            }"#,
478        )
479        .unwrap();
480
481        let serialized = serde_json::to_string(&project_node).unwrap();
482        assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
483    }
484
485    #[test]
486    fn project_node_optional_serialize_optional() {
487        let project_node: ProjectNode = serde_json::from_str(
488            r#"{
489                "$path": "..\\src"
490            }"#,
491        )
492        .unwrap();
493
494        let serialized = serde_json::to_string(&project_node).unwrap();
495        assert_eq!(serialized, r#"{"$path":"../src"}"#);
496    }
497}