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, json, 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 = json::from_slice(contents).map_err(|e| Error::Json {
218 source: serde_json::Error::io(std::io::Error::new(
219 std::io::ErrorKind::InvalidData,
220 e.to_string(),
221 )),
222 path: project_file_location.clone(),
223 })?;
224 project.file_location = project_file_location;
225 project.check_compatibility();
226 if project.name.is_none() {
227 project.set_file_name(fallback_name)?;
228 }
229
230 Ok(project)
231 }
232
233 pub fn load_fuzzy(
237 vfs: &Vfs,
238 fuzzy_project_location: &Path,
239 ) -> Result<Option<Self>, ProjectError> {
240 if let Some(project_path) = Self::locate(fuzzy_project_location) {
241 let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
242 io::ErrorKind::NotFound => Error::NoProjectFound {
243 path: project_path.to_path_buf(),
244 },
245 _ => e.into(),
246 })?;
247
248 Ok(Some(Self::load_from_slice(&contents, project_path, None)?))
249 } else {
250 Ok(None)
251 }
252 }
253
254 pub fn load_exact(
256 vfs: &Vfs,
257 project_file_location: &Path,
258 fallback_name: Option<&str>,
259 ) -> Result<Self, ProjectError> {
260 let project_path = project_file_location.to_path_buf();
261 let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
262 io::ErrorKind::NotFound => Error::NoProjectFound {
263 path: project_path.to_path_buf(),
264 },
265 _ => e.into(),
266 })?;
267
268 Ok(Self::load_from_slice(
269 &contents,
270 project_path,
271 fallback_name,
272 )?)
273 }
274
275 fn check_compatibility(&self) {
278 self.tree.validate_reserved_names();
279 }
280
281 pub fn folder_location(&self) -> &Path {
282 self.file_location.parent().unwrap()
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
287pub struct OptionalPathNode {
288 #[serde(serialize_with = "crate::path_serializer::serialize_absolute")]
289 pub optional: PathBuf,
290}
291
292impl OptionalPathNode {
293 pub fn new(optional: PathBuf) -> Self {
294 OptionalPathNode { optional }
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300#[serde(untagged)]
301pub enum PathNode {
302 Required(#[serde(serialize_with = "crate::path_serializer::serialize_absolute")] PathBuf),
303 Optional(OptionalPathNode),
304}
305
306impl PathNode {
307 pub fn path(&self) -> &Path {
308 match self {
309 PathNode::Required(pathbuf) => pathbuf,
310 PathNode::Optional(OptionalPathNode { optional }) => optional,
311 }
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
317pub struct ProjectNode {
318 #[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
325 pub class_name: Option<Ustr>,
326
327 #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
330 pub id: Option<String>,
331
332 #[serde(flatten)]
334 pub children: BTreeMap<String, ProjectNode>,
335
336 #[serde(
340 rename = "$properties",
341 default,
342 skip_serializing_if = "HashMap::is_empty"
343 )]
344 pub properties: UstrMap<UnresolvedValue>,
345
346 #[serde(
347 rename = "$attributes",
348 default,
349 skip_serializing_if = "HashMap::is_empty"
350 )]
351 pub attributes: HashMap<String, UnresolvedValue>,
352
353 #[serde(
368 rename = "$ignoreUnknownInstances",
369 skip_serializing_if = "Option::is_none"
370 )]
371 pub ignore_unknown_instances: Option<bool>,
372
373 #[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
378 pub path: Option<PathNode>,
379}
380
381impl ProjectNode {
382 fn validate_reserved_names(&self) {
383 for (name, child) in &self.children {
384 if name.starts_with('$') {
385 log::warn!(
386 "Keys starting with '$' are reserved by Rojo to ensure forward compatibility."
387 );
388 log::warn!(
389 "This project uses the key '{}', which should be renamed.",
390 name
391 );
392 }
393
394 child.validate_reserved_names();
395 }
396 }
397}
398
399#[cfg(test)]
400mod test {
401 use super::*;
402
403 #[test]
404 fn path_node_required() {
405 let path_node: PathNode = json::from_str(r#""src""#).unwrap();
406 assert_eq!(path_node, PathNode::Required(PathBuf::from("src")));
407 }
408
409 #[test]
410 fn path_node_optional() {
411 let path_node: PathNode = json::from_str(r#"{ "optional": "src" }"#).unwrap();
412 assert_eq!(
413 path_node,
414 PathNode::Optional(OptionalPathNode::new(PathBuf::from("src")))
415 );
416 }
417
418 #[test]
419 fn project_node_required() {
420 let project_node: ProjectNode = json::from_str(
421 r#"{
422 "$path": "src"
423 }"#,
424 )
425 .unwrap();
426
427 assert_eq!(
428 project_node.path,
429 Some(PathNode::Required(PathBuf::from("src")))
430 );
431 }
432
433 #[test]
434 fn project_node_optional() {
435 let project_node: ProjectNode = json::from_str(
436 r#"{
437 "$path": { "optional": "src" }
438 }"#,
439 )
440 .unwrap();
441
442 assert_eq!(
443 project_node.path,
444 Some(PathNode::Optional(OptionalPathNode::new(PathBuf::from(
445 "src"
446 ))))
447 );
448 }
449
450 #[test]
451 fn project_node_none() {
452 let project_node: ProjectNode = json::from_str(
453 r#"{
454 "$className": "Folder"
455 }"#,
456 )
457 .unwrap();
458
459 assert_eq!(project_node.path, None);
460 }
461
462 #[test]
463 fn project_node_optional_serialize_absolute() {
464 let project_node: ProjectNode = json::from_str(
465 r#"{
466 "$path": { "optional": "..\\src" }
467 }"#,
468 )
469 .unwrap();
470
471 let serialized = serde_json::to_string(&project_node).unwrap();
472 assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
473 }
474
475 #[test]
476 fn project_node_optional_serialize_absolute_no_change() {
477 let project_node: ProjectNode = json::from_str(
478 r#"{
479 "$path": { "optional": "../src" }
480 }"#,
481 )
482 .unwrap();
483
484 let serialized = serde_json::to_string(&project_node).unwrap();
485 assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
486 }
487
488 #[test]
489 fn project_node_optional_serialize_optional() {
490 let project_node: ProjectNode = json::from_str(
491 r#"{
492 "$path": "..\\src"
493 }"#,
494 )
495 .unwrap();
496
497 let serialized = serde_json::to_string(&project_node).unwrap();
498 assert_eq!(serialized, r#"{"$path":"../src"}"#);
499 }
500
501 #[test]
502 fn project_with_jsonc_features() {
503 let project_json = r#"{
505 // This is a single-line comment
506 "name": "TestProject",
507 /* This is a
508 multi-line comment */
509 "tree": {
510 "$path": "src", // Comment after value
511 },
512 "servePort": 34567,
513 "emitLegacyScripts": false,
514 // Test glob parsing with comments
515 "globIgnorePaths": [
516 "**/*.spec.lua", // Ignore test files
517 "**/*.test.lua",
518 ],
519 "syncRules": [
520 {
521 "pattern": "*.data.json",
522 "use": "json", // Trailing comma in object
523 },
524 {
525 "pattern": "*.module.lua",
526 "use": "moduleScript",
527 }, // Trailing comma in array
528 ], // Another trailing comma
529 }"#;
530
531 let project = Project::load_from_slice(
532 project_json.as_bytes(),
533 PathBuf::from("/test/default.project.json"),
534 None,
535 )
536 .expect("Failed to parse project with JSONC features");
537
538 assert_eq!(project.name, Some("TestProject".to_string()));
540 assert_eq!(project.serve_port, Some(34567));
541 assert_eq!(project.emit_legacy_scripts, Some(false));
542
543 assert_eq!(project.glob_ignore_paths.len(), 2);
545 assert!(project.glob_ignore_paths[0].is_match("test/foo.spec.lua"));
546 assert!(project.glob_ignore_paths[1].is_match("test/bar.test.lua"));
547
548 assert_eq!(project.sync_rules.len(), 2);
550 assert!(project.sync_rules[0].include.is_match("data.data.json"));
551 assert!(project.sync_rules[1].include.is_match("init.module.lua"));
552 }
553}