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#[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#[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 pub name: Option<String>,
64
65 pub tree: ProjectNode,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
72 pub serve_port: Option<u16>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
80 pub serve_place_ids: Option<HashSet<u64>>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
88 pub blocked_place_ids: Option<HashSet<u64>>,
89
90 #[serde(skip_serializing_if = "Option::is_none")]
93 pub place_id: Option<u64>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
98 pub game_id: Option<u64>,
99
100 #[serde(skip_serializing_if = "Option::is_none")]
103 pub serve_address: Option<IpAddr>,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
109 pub emit_legacy_scripts: Option<bool>,
110
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub glob_ignore_paths: Vec<Glob>,
115
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub sync_rules: Vec<SyncRule>,
121
122 #[serde(skip)]
126 pub file_location: PathBuf,
127}
128
129impl Project {
130 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 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 None
163 }
164 }
165 }
166
167 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 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 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 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 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 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 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#[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
314pub struct ProjectNode {
315 #[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
322 pub class_name: Option<Ustr>,
323
324 #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
327 pub id: Option<String>,
328
329 #[serde(flatten)]
331 pub children: BTreeMap<String, ProjectNode>,
332
333 #[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 #[serde(
365 rename = "$ignoreUnknownInstances",
366 skip_serializing_if = "Option::is_none"
367 )]
368 pub ignore_unknown_instances: Option<bool>,
369
370 #[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}