1use std::{
2 collections::{BTreeMap, HashSet},
3 ffi::OsStr,
4 fs, io,
5 net::IpAddr,
6 path::{Path, PathBuf},
7};
8
9use memofs::Vfs;
10use rbx_dom_weak::Ustr;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::{
15 glob::IgnorableGlob, json, resolution::UnresolvedValue, snapshot::SyncRule,
16 syncback::SyncbackRules,
17};
18
19pub static DEFAULT_PROJECT_NAMES: [&str; 2] = ["default.project.json", "default.project.jsonc"];
21
22#[derive(Debug, Error)]
24#[error(transparent)]
25pub struct ProjectError(#[from] Error);
26
27#[derive(Debug, Error)]
28enum Error {
29 #[error(
30 "Rojo requires a project file, but no project file was found in path {}\n\
31 See https://rojo.space/docs/ for guides and documentation.",
32 .path.display()
33 )]
34 NoProjectFound { path: PathBuf },
35
36 #[error("The folder for the provided project cannot be used as a project name: {}\n\
37 Consider setting the `name` field on this project.", .path.display())]
38 FolderNameInvalid { path: PathBuf },
39
40 #[error("The file name of the provided project cannot be used as a project name: {}.\n\
41 Consider setting the `name` field on this project.", .path.display())]
42 ProjectNameInvalid { path: PathBuf },
43
44 #[error(transparent)]
45 Io {
46 #[from]
47 source: io::Error,
48 },
49
50 #[error("Error parsing Rojo project in path {}", .path.display())]
51 Json {
52 source: serde_json::Error,
53 path: PathBuf,
54 },
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields, rename_all = "camelCase")]
62pub struct Project {
63 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
64 schema: Option<String>,
65
66 pub name: Option<String>,
68
69 pub tree: ProjectNode,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
76 pub serve_port: Option<u16>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
84 pub serve_place_ids: Option<HashSet<u64>>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
92 pub blocked_place_ids: Option<HashSet<u64>>,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
97 pub place_id: Option<u64>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
102 pub game_id: Option<u64>,
103
104 #[serde(skip_serializing_if = "Option::is_none")]
107 pub serve_address: Option<IpAddr>,
108
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub serve_allowed_hosts: Vec<String>,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
122 pub emit_legacy_scripts: Option<bool>,
123
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub glob_ignore_paths: Vec<IgnorableGlob>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub syncback_rules: Option<SyncbackRules>,
132
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub sync_rules: Vec<SyncRule>,
138
139 #[serde(skip)]
143 pub file_location: PathBuf,
144}
145
146impl Project {
147 pub fn is_project_file(path: &Path) -> bool {
149 path.file_name()
150 .and_then(|name| name.to_str())
151 .map(|name| name.ends_with(".project.json") || name.ends_with(".project.jsonc"))
152 .unwrap_or(false)
153 }
154
155 fn locate(path: &Path) -> Option<PathBuf> {
160 let meta = fs::metadata(path).ok()?;
161
162 if meta.is_file() {
163 if Project::is_project_file(path) {
164 Some(path.to_path_buf())
165 } else {
166 None
167 }
168 } else {
169 for filename in DEFAULT_PROJECT_NAMES {
170 let child_path = path.join(filename);
171 let child_meta = fs::metadata(&child_path).ok()?;
172
173 if child_meta.is_file() {
174 return Some(child_path);
175 }
176 }
177 None
182 }
183 }
184
185 fn set_file_name(&mut self, fallback: Option<&str>) -> Result<(), Error> {
192 let file_name = self
193 .file_location
194 .file_name()
195 .and_then(OsStr::to_str)
196 .ok_or_else(|| Error::ProjectNameInvalid {
197 path: self.file_location.clone(),
198 })?;
199
200 for default_file_name in DEFAULT_PROJECT_NAMES {
203 if file_name == default_file_name {
204 let folder_name = self.folder_location().file_name().and_then(OsStr::to_str);
205 if let Some(folder_name) = folder_name {
206 self.name = Some(folder_name.to_string());
207 return Ok(());
208 } else {
209 return Err(Error::FolderNameInvalid {
210 path: self.file_location.clone(),
211 });
212 }
213 }
214 }
215 if let Some(fallback) = fallback {
216 self.name = Some(fallback.to_string());
217 } else {
218 todo!(
225 "set_file_name doesn't support loading project files that aren't default.project.json without a fallback provided"
226 );
227 }
228
229 Ok(())
230 }
231
232 fn load_from_slice(
235 contents: &[u8],
236 project_file_location: PathBuf,
237 fallback_name: Option<&str>,
238 ) -> Result<Self, Error> {
239 let mut project: Self = json::from_slice(contents).map_err(|e| Error::Json {
240 source: serde_json::Error::io(std::io::Error::new(
241 std::io::ErrorKind::InvalidData,
242 e.to_string(),
243 )),
244 path: project_file_location.clone(),
245 })?;
246 project.file_location = project_file_location;
247 project.check_compatibility();
248 if project.name.is_none() {
249 project.set_file_name(fallback_name)?;
250 }
251
252 Ok(project)
253 }
254
255 pub fn load_fuzzy(
259 vfs: &Vfs,
260 fuzzy_project_location: &Path,
261 ) -> Result<Option<Self>, ProjectError> {
262 if let Some(project_path) = Self::locate(fuzzy_project_location) {
263 let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
264 io::ErrorKind::NotFound => Error::NoProjectFound {
265 path: project_path.to_path_buf(),
266 },
267 _ => e.into(),
268 })?;
269
270 Ok(Some(Self::load_from_slice(&contents, project_path, None)?))
271 } else {
272 Ok(None)
273 }
274 }
275
276 pub fn load_exact(
278 vfs: &Vfs,
279 project_file_location: &Path,
280 fallback_name: Option<&str>,
281 ) -> Result<Self, ProjectError> {
282 log::debug!(
283 "Loading project file from {}",
284 project_file_location.display()
285 );
286 let project_path = project_file_location.to_path_buf();
287 let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
288 io::ErrorKind::NotFound => Error::NoProjectFound {
289 path: project_path.to_path_buf(),
290 },
291 _ => e.into(),
292 })?;
293
294 Ok(Self::load_from_slice(
295 &contents,
296 project_path,
297 fallback_name,
298 )?)
299 }
300
301 pub(crate) fn load_initial_project(vfs: &Vfs, path: &Path) -> Result<Self, ProjectError> {
302 if Self::is_project_file(path) {
303 Self::load_exact(vfs, path, None)
304 } else {
305 for default_project_name in DEFAULT_PROJECT_NAMES {
307 let project_path = path.join(default_project_name);
308 if let Ok(true) = vfs.exists(&project_path) {
309 return Self::load_exact(vfs, &project_path, None);
310 }
311 }
312 Err(Error::NoProjectFound {
313 path: path.to_path_buf(),
314 }
315 .into())
316 }
317 }
318
319 fn check_compatibility(&self) {
322 self.tree.validate_reserved_names();
323 }
324
325 pub fn folder_location(&self) -> &Path {
326 self.file_location.parent().unwrap()
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
331pub struct OptionalPathNode {
332 #[serde(serialize_with = "crate::path_serializer::serialize_absolute")]
333 pub optional: PathBuf,
334}
335
336impl OptionalPathNode {
337 pub fn new(optional: PathBuf) -> Self {
338 OptionalPathNode { optional }
339 }
340}
341
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(untagged)]
345pub enum PathNode {
346 Required(#[serde(serialize_with = "crate::path_serializer::serialize_absolute")] PathBuf),
347 Optional(OptionalPathNode),
348}
349
350impl PathNode {
351 #[inline]
354 pub fn path(&self) -> &Path {
355 match self {
356 PathNode::Required(pathbuf) => pathbuf,
357 PathNode::Optional(OptionalPathNode { optional }) => optional,
358 }
359 }
360
361 #[inline]
363 pub fn is_optional(&self) -> bool {
364 matches!(self, PathNode::Optional(_))
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
370pub struct ProjectNode {
371 #[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
378 pub class_name: Option<Ustr>,
379
380 #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
383 pub id: Option<String>,
384
385 #[serde(flatten)]
387 pub children: BTreeMap<String, ProjectNode>,
388
389 #[serde(
393 rename = "$properties",
394 default,
395 skip_serializing_if = "BTreeMap::is_empty"
396 )]
397 pub properties: BTreeMap<Ustr, UnresolvedValue>,
398
399 #[serde(
400 rename = "$attributes",
401 default,
402 skip_serializing_if = "BTreeMap::is_empty"
403 )]
404 pub attributes: BTreeMap<String, UnresolvedValue>,
405
406 #[serde(
421 rename = "$ignoreUnknownInstances",
422 skip_serializing_if = "Option::is_none"
423 )]
424 pub ignore_unknown_instances: Option<bool>,
425
426 #[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
431 pub path: Option<PathNode>,
432}
433
434impl ProjectNode {
435 fn validate_reserved_names(&self) {
436 for (name, child) in &self.children {
437 if name.starts_with('$') {
438 log::warn!(
439 "Keys starting with '$' are reserved by Rojo to ensure forward compatibility."
440 );
441 log::warn!(
442 "This project uses the key '{}', which should be renamed.",
443 name
444 );
445 }
446
447 child.validate_reserved_names();
448 }
449 }
450}
451
452#[cfg(test)]
453mod test {
454 use super::*;
455
456 #[test]
457 fn path_node_required() {
458 let path_node: PathNode = json::from_str(r#""src""#).unwrap();
459 assert_eq!(path_node, PathNode::Required(PathBuf::from("src")));
460 }
461
462 #[test]
463 fn path_node_optional() {
464 let path_node: PathNode = json::from_str(r#"{ "optional": "src" }"#).unwrap();
465 assert_eq!(
466 path_node,
467 PathNode::Optional(OptionalPathNode::new(PathBuf::from("src")))
468 );
469 }
470
471 #[test]
472 fn project_node_required() {
473 let project_node: ProjectNode = json::from_str(
474 r#"{
475 "$path": "src"
476 }"#,
477 )
478 .unwrap();
479
480 assert_eq!(
481 project_node.path,
482 Some(PathNode::Required(PathBuf::from("src")))
483 );
484 }
485
486 #[test]
487 fn project_node_optional() {
488 let project_node: ProjectNode = json::from_str(
489 r#"{
490 "$path": { "optional": "src" }
491 }"#,
492 )
493 .unwrap();
494
495 assert_eq!(
496 project_node.path,
497 Some(PathNode::Optional(OptionalPathNode::new(PathBuf::from(
498 "src"
499 ))))
500 );
501 }
502
503 #[test]
504 fn project_node_none() {
505 let project_node: ProjectNode = json::from_str(
506 r#"{
507 "$className": "Folder"
508 }"#,
509 )
510 .unwrap();
511
512 assert_eq!(project_node.path, None);
513 }
514
515 #[test]
516 fn project_node_optional_serialize_absolute() {
517 let project_node: ProjectNode = json::from_str(
518 r#"{
519 "$path": { "optional": "..\\src" }
520 }"#,
521 )
522 .unwrap();
523
524 let serialized = serde_json::to_string(&project_node).unwrap();
525 assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
526 }
527
528 #[test]
529 fn project_node_optional_serialize_absolute_no_change() {
530 let project_node: ProjectNode = json::from_str(
531 r#"{
532 "$path": { "optional": "../src" }
533 }"#,
534 )
535 .unwrap();
536
537 let serialized = serde_json::to_string(&project_node).unwrap();
538 assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
539 }
540
541 #[test]
542 fn project_node_optional_serialize_optional() {
543 let project_node: ProjectNode = json::from_str(
544 r#"{
545 "$path": "..\\src"
546 }"#,
547 )
548 .unwrap();
549
550 let serialized = serde_json::to_string(&project_node).unwrap();
551 assert_eq!(serialized, r#"{"$path":"../src"}"#);
552 }
553
554 #[test]
555 fn project_with_jsonc_features() {
556 let project_json = r#"{
558 // This is a single-line comment
559 "name": "TestProject",
560 /* This is a
561 multi-line comment */
562 "tree": {
563 "$path": "src", // Comment after value
564 },
565 "servePort": 34567,
566 "emitLegacyScripts": false,
567 // Test glob parsing with comments
568 "globIgnorePaths": [
569 "**/*.spec.lua", // Ignore test files
570 "**/*.test.lua",
571 ],
572 "syncRules": [
573 {
574 "pattern": "*.data.json",
575 "use": "json", // Trailing comma in object
576 },
577 {
578 "pattern": "*.module.lua",
579 "use": "moduleScript",
580 }, // Trailing comma in array
581 ], // Another trailing comma
582 }"#;
583
584 let project = Project::load_from_slice(
585 project_json.as_bytes(),
586 PathBuf::from("/test/default.project.jsonc"),
587 None,
588 )
589 .expect("Failed to parse project with JSONC features");
590
591 assert_eq!(project.name, Some("TestProject".to_string()));
593 assert_eq!(project.serve_port, Some(34567));
594 assert_eq!(project.emit_legacy_scripts, Some(false));
595
596 assert_eq!(project.glob_ignore_paths.len(), 2);
598 assert!(project.glob_ignore_paths[0].is_match("test/foo.spec.lua"));
599 assert!(project.glob_ignore_paths[1].is_match("test/bar.test.lua"));
600
601 assert_eq!(project.sync_rules.len(), 2);
603 assert!(project.sync_rules[0].include.is_match("data.data.json"));
604 assert!(project.sync_rules[1].include.is_match("init.module.lua"));
605 }
606
607 #[test]
608 fn project_with_serve_allowed_hosts() {
609 let project_json = r#"{
610 "name": "TestProject",
611 "tree": { "$path": "src" },
612 "serveAllowedHosts": ["mypc.lan", "192.168.1.5"]
613 }"#;
614
615 let project = Project::load_from_slice(
616 project_json.as_bytes(),
617 PathBuf::from("/test/default.project.json"),
618 None,
619 )
620 .expect("Failed to parse project with serveAllowedHosts");
621
622 assert_eq!(project.serve_allowed_hosts, vec!["mypc.lan", "192.168.1.5"]);
623 }
624
625 #[test]
626 fn project_without_serve_allowed_hosts_defaults_to_empty() {
627 let project_json = r#"{
628 "name": "TestProject",
629 "tree": { "$path": "src" }
630 }"#;
631
632 let project = Project::load_from_slice(
633 project_json.as_bytes(),
634 PathBuf::from("/test/default.project.json"),
635 None,
636 )
637 .expect("Failed to parse project");
638
639 assert!(project.serve_allowed_hosts.is_empty());
640 }
641
642 #[test]
643 fn glob_ignore_paths_negation() {
644 let project_json = r#"{
645 "name": "TestProject",
646 "tree": { "$path": "src" },
647 "globIgnorePaths": [
648 "**/*.spec.lua",
649 "!keep.spec.lua",
650 "\\!literal.lua"
651 ]
652 }"#;
653
654 let project = Project::load_from_slice(
655 project_json.as_bytes(),
656 PathBuf::from("/test/default.project.json"),
657 None,
658 )
659 .expect("project should parse");
660
661 let paths = &project.glob_ignore_paths;
662 assert_eq!(paths.len(), 3);
663
664 assert!(!paths[0].is_negation());
665 assert!(paths[0].is_match("foo.spec.lua"));
666
667 assert!(paths[1].is_negation());
668 assert!(paths[1].is_match("keep.spec.lua"));
669
670 assert!(!paths[2].is_negation());
673 assert!(paths[2].is_match("!literal.lua"));
674
675 let rules: Vec<_> = paths
676 .iter()
677 .map(|g| crate::snapshot::PathIgnoreRule {
678 base_path: PathBuf::from("/test"),
679 glob: g.clone(),
680 })
681 .collect();
682 assert!(crate::snapshot::is_path_ignored(
683 &rules,
684 "/test/foo.spec.lua"
685 ));
686 assert!(!crate::snapshot::is_path_ignored(
687 &rules,
688 "/test/keep.spec.lua"
689 ));
690 assert!(!crate::snapshot::is_path_ignored(&rules, "/test/plain.lua"));
691 }
692}