BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

59dd3616 History
project.rs 29.5 KiB Download Raw
1//! `.ppd` project manifest: one JSON file in a project directory.
2//!
3//! Version 1 stores paths and render settings. It does not import models and
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
5//! cannot drop them. A newer or older `format_version` is rejected first.
6
7use std::fs::{self, File};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15pub const FORMAT_VERSION: u32 = 1;
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_DIMENSION: u32 = 4096;
18pub const MAX_FPS: u32 = 240;
19pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
20
21const PROJECT_DIRS: [&str; 5] = [
22 "assets/body",
23 "assets/equipment",
24 "presets",
25 "cache",
26 "renders",
27];
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct RenderSettings {
32 pub target_width: u32,
33 pub target_height: u32,
34 pub fps: u32,
35}
36
37impl Default for RenderSettings {
38 fn default() -> Self {
39 Self {
40 target_width: 128,
41 target_height: 128,
42 fps: 12,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ProjectFile {
50 pub format_version: u32,
51 pub id: String,
52 pub name: String,
53 pub male_model: Option<String>,
54 pub female_model: Option<String>,
55 pub equipment_presets: Vec<String>,
56 pub output_directory: String,
57 pub render: RenderSettings,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ProjectDraft {
62 pub id: String,
63 pub name: String,
64 pub male_model: String,
65 pub female_model: String,
66 pub equipment_presets: Vec<String>,
67 pub output_directory: String,
68 pub target_width: u32,
69 pub target_height: u32,
70 pub fps: u32,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct OpenedProject {
75 pub manifest_path: PathBuf,
76 pub document: ProjectFile,
77 pub disk_issues: Vec<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct DirEntry {
82 pub name: String,
83 pub relative: String,
84 pub is_dir: bool,
85}
86
87#[derive(Debug)]
88pub enum ProjectError {
89 Validation(Vec<String>),
90 UnsupportedVersion { found: u64, supported: u32 },
91 UnknownField(String),
92 Json(String),
93 Io { path: String, message: String },
94}
95
96impl std::fmt::Display for ProjectError {
97 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::Validation(issues) => {
100 write!(formatter, "{}", issues.join("\n"))
101 }
102 Self::UnsupportedVersion { found, supported } => write!(
103 formatter,
104 "dit project gebruikt format_version {found}; deze build leest alleen versie {supported}"
105 ),
106 Self::UnknownField(detail) => write!(
107 formatter,
108 "het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven."
109 ),
110 Self::Json(detail) => write!(formatter, "het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}"),
111 Self::Io { path, message } => write!(formatter, "{message} ({path})"),
112 }
113 }
114}
115
116impl std::error::Error for ProjectError {}
117
118impl ProjectFile {
119 pub fn new_empty(name: impl Into<String>) -> Self {
120 Self {
121 format_version: FORMAT_VERSION,
122 id: new_id(),
123 name: name.into(),
124 male_model: None,
125 female_model: None,
126 equipment_presets: Vec::new(),
127 output_directory: "renders".to_string(),
128 render: RenderSettings::default(),
129 }
130 }
131
132 pub fn validate_shape(&self) -> Result<(), ProjectError> {
133 let mut issues = Vec::new();
134 if self.format_version != FORMAT_VERSION {
135 issues.push(format!(
136 "dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",
137 self.format_version
138 ));
139 }
140 if self.id.trim().is_empty()
141 || !self
142 .id
143 .chars()
144 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
145 || self.id.len() > 64
146 {
147 issues.push(
148 "het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'"
149 .to_string(),
150 );
151 }
152 let name = self.name.trim();
153 if name.is_empty() || name.chars().any(|ch| ch.is_control()) || name.len() > 80 {
154 issues.push("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens".to_string());
155 }
156 check_optional_path(
157 "het mannelijke model",
158 self.male_model.as_deref(),
159 &mut issues,
160 );
161 check_optional_path(
162 "het vrouwelijke model",
163 self.female_model.as_deref(),
164 &mut issues,
165 );
166 let mut seen = std::collections::BTreeSet::new();
167 for preset in &self.equipment_presets {
168 match portable_relative(preset) {
169 Ok(clean) => {
170 if clean != *preset {
171 issues.push(format!("de preset '{preset}' is niet genormaliseerd"));
172 }
173 if !seen.insert(clean.clone()) {
174 issues.push(format!("de preset '{clean}' staat dubbel in het project"));
175 }
176 match preset_stem(&clean) {
177 Ok(stem) if stem == "body" => issues.push(
178 "een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam"
179 .to_string(),
180 ),
181 Ok(_) => {}
182 Err(message) => issues.push(message),
183 }
184 if clean == MANIFEST_NAME {
185 issues.push("character.ppd is het projectbestand, geen preset".to_string());
186 }
187 }
188 Err(message) => issues.push(format!("preset '{preset}': {message}")),
189 }
190 }
191 match portable_relative(&self.output_directory) {
192 Ok(clean) => {
193 if clean != self.output_directory {
194 issues.push("de uitvoermap is niet genormaliseerd".to_string());
195 }
196 if clean == "." {
197 issues.push(
198 "de uitvoermap moet een submap zijn, niet de projectmap zelf".to_string(),
199 );
200 }
201 if clean == MANIFEST_NAME {
202 issues.push("de uitvoermap mag niet het .ppd-bestand zijn".to_string());
203 }
204 for source in self.sources() {
205 if source == clean || source.starts_with(&(clean.clone() + "/")) {
206 issues.push(format!(
207 "de uitvoermap '{clean}' mag de bron '{source}' niet bevatten"
208 ));
209 }
210 }
211 }
212 Err(message) => issues.push(format!("uitvoermap: {message}")),
213 }
214 match memory_estimate(self.render.target_width, self.render.target_height) {
215 Some(bytes) if bytes <= MAX_ESTIMATE_BYTES => {}
216 Some(bytes) => issues.push(format!(
217 "de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}"
218 )),
219 None => issues.push(
220 "breedte en hoogte lopen buiten de geheugenschatting van deze versie".to_string(),
221 ),
222 }
223 if self.render.target_width == 0 || self.render.target_height == 0 {
224 issues.push("breedte en hoogte moeten groter zijn dan 0".to_string());
225 }
226 if self.render.target_width > MAX_DIMENSION || self.render.target_height > MAX_DIMENSION {
227 issues.push(format!(
228 "breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn"
229 ));
230 }
231 if self.render.fps == 0 {
232 issues.push("fps moet groter zijn dan 0".to_string());
233 }
234 if self.render.fps > MAX_FPS {
235 issues.push(format!("fps mag hoogstens {MAX_FPS} zijn"));
236 }
237 if issues.is_empty() {
238 Ok(())
239 } else {
240 Err(ProjectError::Validation(issues))
241 }
242 }
243
244 fn sources(&self) -> Vec<&str> {
245 self.male_model
246 .iter()
247 .chain(self.female_model.iter())
248 .map(String::as_str)
249 .chain(self.equipment_presets.iter().map(String::as_str))
250 .collect()
251 }
252}
253
254pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {
255 let document = ProjectFile {
256 format_version: FORMAT_VERSION,
257 id: draft.id,
258 name: draft.name,
259 male_model: blank_to_none(draft.male_model),
260 female_model: blank_to_none(draft.female_model),
261 equipment_presets: draft.equipment_presets,
262 output_directory: draft.output_directory,
263 render: RenderSettings {
264 target_width: draft.target_width,
265 target_height: draft.target_height,
266 fps: draft.fps,
267 },
268 };
269 document.validate_shape()?;
270 Ok(document)
271}
272
273pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
274 let value: Value =
275 serde_json::from_str(text).map_err(|err| ProjectError::Json(err.to_string()))?;
276 let Some(object) = value.as_object() else {
277 return Err(ProjectError::Json(
278 "de wortel moet een object zijn".to_string(),
279 ));
280 };
281 let Some(version_value) = object.get("format_version") else {
282 return Err(ProjectError::Json("format_version ontbreekt".to_string()));
283 };
284 let Some(version) = version_value.as_u64() else {
285 return Err(ProjectError::Json(
286 "format_version moet een geheel getal zijn".to_string(),
287 ));
288 };
289 if version != u64::from(FORMAT_VERSION) {
290 return Err(ProjectError::UnsupportedVersion {
291 found: version,
292 supported: FORMAT_VERSION,
293 });
294 }
295 serde_json::from_value(value).map_err(|err| {
296 let detail = err.to_string();
297 if detail.contains("unknown field") {
298 ProjectError::UnknownField(detail)
299 } else {
300 ProjectError::Json(detail)
301 }
302 })
303}
304
305pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
306 if root.exists() && !root.is_dir() {
307 return Err(io_error(root, "de projectmap is een bestand"));
308 }
309 let manifest = root.join(MANIFEST_NAME);
310 if manifest.exists() {
311 return Err(io_error(
312 &manifest,
313 "er staat al een character.ppd; open dat project in plaats van het te overschrijven",
314 ));
315 }
316 fs::create_dir_all(root).map_err(|err| io_error(root, &err.to_string()))?;
317 for relative in PROJECT_DIRS {
318 let path = root.join(relative);
319 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
320 }
321 let document = ProjectFile::new_empty(name);
322 document.validate_shape()?;
323 write_manifest(&manifest, &document)?;
324 Ok(OpenedProject {
325 manifest_path: manifest,
326 document,
327 disk_issues: Vec::new(),
328 })
329}
330
331pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {
332 if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
333 return Err(io_error(manifest, "kies een .ppd-bestand"));
334 }
335 let text = fs::read_to_string(manifest).map_err(|err| {
336 io_error(
337 manifest,
338 &format!("openen is mislukt: {err}. Er is niets gewijzigd"),
339 )
340 })?;
341 let document = parse_manifest(&text)?;
342 document.validate_shape()?;
343 let root = manifest
344 .parent()
345 .ok_or_else(|| io_error(manifest, "het .ppd-bestand heeft geen projectmap"))?;
346 let disk_issues = disk_issues(root, &document);
347 Ok(OpenedProject {
348 manifest_path: manifest.to_path_buf(),
349 document,
350 disk_issues,
351 })
352}
353
354pub fn save_project(opened: &OpenedProject) -> Result<(), ProjectError> {
355 opened.document.validate_shape()?;
356 let root = opened.manifest_path.parent().ok_or_else(|| {
357 io_error(
358 &opened.manifest_path,
359 "het .ppd-bestand heeft geen projectmap",
360 )
361 })?;
362 let mut issues = Vec::new();
363 for relative in opened.document.sources() {
364 match require_file_inside(root, relative) {
365 Ok(()) => {}
366 Err(ProjectError::Validation(extra)) => issues.extend(extra),
367 Err(other) => return Err(other),
368 }
369 }
370 if !issues.is_empty() {
371 return Err(ProjectError::Validation(issues));
372 }
373 prepare_output_dir(root, &opened.document.output_directory)?;
374 write_manifest(&opened.manifest_path, &opened.document)
375}
376
377pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectError> {
378 let relative = if relative.is_empty() {
379 String::new()
380 } else {
381 portable_relative(relative).map_err(|message| ProjectError::Validation(vec![message]))?
382 };
383 let path = if relative.is_empty() {
384 root.to_path_buf()
385 } else {
386 root.join(&relative)
387 };
388 let read = fs::read_dir(&path).map_err(|err| io_error(&path, &err.to_string()))?;
389 let mut entries = Vec::new();
390 for item in read {
391 let item = item.map_err(|err| io_error(&path, &err.to_string()))?;
392 let name = item.file_name().to_string_lossy().to_string();
393 if name.starts_with('.') {
394 continue;
395 }
396 let child = if relative.is_empty() {
397 name.clone()
398 } else {
399 format!("{relative}/{name}")
400 };
401 let is_dir = item
402 .file_type()
403 .map_err(|err| io_error(&item.path(), &err.to_string()))?
404 .is_dir();
405 entries.push(DirEntry {
406 name,
407 relative: child,
408 is_dir,
409 });
410 }
411 entries.sort_by(|left, right| {
412 right
413 .is_dir
414 .cmp(&left.is_dir)
415 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
416 });
417 Ok(entries)
418}
419
420pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
421 // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
422 u64::from(width)
423 .checked_mul(u64::from(height))?
424 .checked_mul(64)?
425 .checked_mul(36)?
426 .checked_mul(3)?
427 .checked_mul(4)
428}
429
430fn new_id() -> String {
431 let nanos = SystemTime::now()
432 .duration_since(UNIX_EPOCH)
433 .map(|duration| duration.as_nanos())
434 .unwrap_or(0);
435 format!("ppd-{nanos:x}")
436}
437
438fn blank_to_none(value: String) -> Option<String> {
439 let trimmed = value.trim();
440 if trimmed.is_empty() {
441 None
442 } else {
443 Some(trimmed.to_string())
444 }
445}
446
447fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec<String>) {
448 let Some(value) = value else {
449 return;
450 };
451 match portable_relative(value) {
452 Ok(clean) if clean == value && clean != MANIFEST_NAME => {}
453 Ok(_) if value == MANIFEST_NAME => {
454 issues.push(format!("{label} mag niet character.ppd zijn"));
455 }
456 Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")),
457 Err(message) => issues.push(format!("{label}: {message}")),
458 }
459}
460
461pub fn portable_relative(value: &str) -> Result<String, String> {
462 let value = value.trim();
463 if value.is_empty() {
464 return Err("het pad is leeg".to_string());
465 }
466 if value.contains('\\') {
467 return Err("gebruik '/' in projectpaden".to_string());
468 }
469 if value.starts_with('/') || windows_drive(value) {
470 return Err(
471 "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap"
472 .to_string(),
473 );
474 }
475 let mut parts = Vec::new();
476 for part in value.split('/') {
477 if part.is_empty() || part == "." || part == ".." {
478 return Err(
479 "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben"
480 .to_string(),
481 );
482 }
483 if part.contains('\0') {
484 return Err("het pad bevat een leeg teken".to_string());
485 }
486 parts.push(part);
487 }
488 Ok(parts.join("/"))
489}
490
491fn windows_drive(value: &str) -> bool {
492 let mut chars = value.chars();
493 matches!(
494 (chars.next(), chars.next(), chars.next()),
495 (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic()
496 )
497}
498
499fn preset_stem(relative: &str) -> Result<String, String> {
500 let file_name = relative.rsplit('/').next().unwrap_or(relative);
501 let stem = file_name.split('.').next().unwrap_or(file_name);
502 if stem.is_empty()
503 || !stem
504 .chars()
505 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
506 || stem.contains(' ')
507 {
508 return Err(format!(
509 "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties"
510 ));
511 }
512 Ok(stem.to_string())
513}
514
515fn disk_issues(root: &Path, document: &ProjectFile) -> Vec<String> {
516 let mut issues = Vec::new();
517 for relative in document.sources() {
518 if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) {
519 issues.extend(extra);
520 }
521 }
522 let output = root.join(&document.output_directory);
523 if output.exists() && !output.is_dir() {
524 issues.push(format!(
525 "de uitvoermap '{}' is een bestand",
526 document.output_directory
527 ));
528 } else if !output.exists() {
529 issues.push(format!(
530 "de uitvoermap '{}' bestaat nog niet",
531 document.output_directory
532 ));
533 }
534 issues
535}
536
537fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> {
538 let path = root.join(relative);
539 if !path.is_file() {
540 return Err(ProjectError::Validation(vec![format!(
541 "het bestand ontbreekt in de projectmap: {relative}"
542 )]));
543 }
544 ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?;
545 match (root.canonicalize(), path.canonicalize()) {
546 (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()),
547 (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!(
548 "het pad '{relative}' wijst buiten de projectmap"
549 )])),
550 (Err(err), _) => Err(io_error(root, &err.to_string())),
551 (_, Err(err)) => Err(io_error(&path, &err.to_string())),
552 }
553}
554
555fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
556 let path = root.join(relative);
557 if path.exists() && !path.is_dir() {
558 return Err(ProjectError::Validation(vec![format!(
559 "de uitvoermap '{relative}' is een bestand"
560 )]));
561 }
562 if !path.exists() {
563 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
564 }
565 ensure_lexically_inside(root, &path)
566 .map_err(|message| ProjectError::Validation(vec![message]))?;
567 Ok(())
568}
569
570fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> {
571 let root = root.components().collect::<Vec<_>>();
572 let path = path.components().collect::<Vec<_>>();
573 if path.starts_with(&root) {
574 Ok(())
575 } else {
576 Err("het pad wijst buiten de projectmap".to_string())
577 }
578}
579
580fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> {
581 let parent = path
582 .parent()
583 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?;
584 let text = serde_json::to_string_pretty(document)
585 .map_err(|err| ProjectError::Json(err.to_string()))?
586 + "\n";
587 let mut temporary_name = path
588 .file_name()
589 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
590 .to_os_string();
591 temporary_name.push(".tmp");
592 let temporary = parent.join(temporary_name);
593 let write_result = (|| {
594 let mut file =
595 File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?;
596 file.write_all(text.as_bytes())
597 .map_err(|err| io_error(&temporary, &err.to_string()))?;
598 file.sync_all()
599 .map_err(|err| io_error(&temporary, &err.to_string()))?;
600 Ok(())
601 })();
602 if let Err(err) = write_result {
603 let _ = fs::remove_file(&temporary);
604 return Err(err);
605 }
606 if let Err(err) = fs::rename(&temporary, path) {
607 let _ = fs::remove_file(&temporary);
608 return Err(io_error(
609 path,
610 &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"),
611 ));
612 }
613 Ok(())
614}
615
616fn io_error(path: &Path, message: &str) -> ProjectError {
617 ProjectError::Io {
618 path: path.display().to_string(),
619 message: message.to_string(),
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 fn temp_root(label: &str) -> PathBuf {
628 let path = std::env::temp_dir().join(format!(
629 "paperdoll-{label}-{}-{}",
630 std::process::id(),
631 new_id()
632 ));
633 let _ = fs::remove_dir_all(&path);
634 path
635 }
636
637 fn draft_from(document: &ProjectFile) -> ProjectDraft {
638 ProjectDraft {
639 id: document.id.clone(),
640 name: document.name.clone(),
641 male_model: document.male_model.clone().unwrap_or_default(),
642 female_model: document.female_model.clone().unwrap_or_default(),
643 equipment_presets: document.equipment_presets.clone(),
644 output_directory: document.output_directory.clone(),
645 target_width: document.render.target_width,
646 target_height: document.render.target_height,
647 fps: document.render.fps,
648 }
649 }
650
651 #[test]
652 fn roundtrip_keeps_null_slots_and_relative_paths() {
653 let root = temp_root("roundtrip");
654 let opened = create_project(&root, "Ridder").unwrap();
655 let text = fs::read_to_string(&opened.manifest_path).unwrap();
656 assert!(text.contains("\"male_model\": null"));
657 assert!(text.contains("\"female_model\": null"));
658 assert!(text.contains("\"output_directory\": \"renders\""));
659 assert!(!text.contains(root.to_string_lossy().as_ref()));
660 let loaded = open_project(&opened.manifest_path).unwrap();
661 assert_eq!(loaded.document, opened.document);
662 assert!(loaded.disk_issues.is_empty());
663 assert!(root.join("assets/body").is_dir());
664 assert!(root.join("presets").is_dir());
665 assert!(root.join("renders").is_dir());
666 let _ = fs::remove_dir_all(&root);
667 }
668
669 #[test]
670 fn save_writes_model_and_preset_paths_and_reloads_them() {
671 let root = temp_root("paths");
672 let mut opened = create_project(&root, "Ridder").unwrap();
673 fs::write(root.join("assets/body/male.glb"), b"glb").unwrap();
674 fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap();
675 opened.document.male_model = Some("assets/body/male.glb".to_string());
676 opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()];
677 save_project(&opened).unwrap();
678 let loaded = open_project(&opened.manifest_path).unwrap();
679 assert_eq!(
680 loaded.document.male_model.as_deref(),
681 Some("assets/body/male.glb")
682 );
683 assert_eq!(loaded.document.female_model, None);
684 assert_eq!(
685 loaded.document.equipment_presets,
686 vec!["presets/zwaard.bin"]
687 );
688 assert!(loaded.disk_issues.is_empty());
689 let _ = fs::remove_dir_all(&root);
690 }
691
692 #[test]
693 fn validation_rejects_escape_absolute_body_and_output_overlap() {
694 let mut document = ProjectFile::new_empty("Ridder");
695 document.id = "ridder-1".to_string();
696 document.male_model = Some("../geheim.glb".to_string());
697 document.female_model = Some("/tmp/vrouw.glb".to_string());
698 document.equipment_presets = vec![
699 "presets/body.bin".to_string(),
700 "presets/Zwaard.bin".to_string(),
701 ];
702 document.output_directory = "presets".to_string();
703 let error = document.validate_shape().unwrap_err();
704 let text = error.to_string();
705 assert!(text.contains("buiten de projectmap"), "{text}");
706 assert!(text.contains("absoluut"), "{text}");
707 assert!(text.contains("body"), "{text}");
708 assert!(text.contains("kleine letters"), "{text}");
709 assert!(text.contains("uitvoermap"), "{text}");
710 }
711
712 #[test]
713 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
714 let newer = r#"{"format_version":2,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
715 let error = parse_manifest(newer).unwrap_err();
716 assert!(matches!(
717 error,
718 ProjectError::UnsupportedVersion {
719 found: 2,
720 supported: 1
721 }
722 ));
723 assert!(error.to_string().contains("format_version 2"));
724 let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
725 let error = parse_manifest(unknown).unwrap_err();
726 assert!(matches!(error, ProjectError::UnknownField(_)));
727 assert!(error.to_string().contains("niet herschreven"));
728 }
729
730 #[test]
731 fn failed_save_keeps_the_previous_manifest() {
732 let root = temp_root("atomic");
733 let opened = create_project(&root, "Ridder").unwrap();
734 let before = fs::read(&opened.manifest_path).unwrap();
735 let mut broken = opened.clone();
736 broken.document.output_directory = "..".to_string();
737 let error = save_project(&broken).unwrap_err();
738 assert!(error.to_string().contains("buiten de projectmap"));
739 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
740 broken.document.output_directory = "renders".to_string();
741 broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string());
742 let error = save_project(&broken).unwrap_err();
743 assert!(error.to_string().contains("ontbreekt"), "{error}");
744 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
745 let _ = fs::remove_dir_all(&root);
746 }
747
748 #[test]
749 fn missing_manifest_is_a_recoverable_io_error() {
750 let path = temp_root("missing").join(MANIFEST_NAME);
751 let error = open_project(&path).unwrap_err();
752 let text = error.to_string();
753 assert!(text.contains("openen is mislukt"), "{text}");
754 assert!(matches!(error, ProjectError::Io { .. }));
755 }
756
757 #[test]
758 fn create_refuses_to_overwrite_an_existing_project() {
759 let root = temp_root("twice");
760 create_project(&root, "Eerste").unwrap();
761 let error = create_project(&root, "Tweede").unwrap_err();
762 assert!(error.to_string().contains("al een character.ppd"));
763 let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap();
764 assert_eq!(loaded.document.name, "Eerste");
765 let _ = fs::remove_dir_all(&root);
766 }
767
768 #[test]
769 fn render_bounds_and_assemble_roundtrip() {
770 let mut document = ProjectFile::new_empty("Ridder");
771 document.id = "ridder-1".to_string();
772 document.render.target_width = 0;
773 assert!(document
774 .validate_shape()
775 .unwrap_err()
776 .to_string()
777 .contains("groter zijn dan 0"));
778 document.render = RenderSettings {
779 target_width: 4096,
780 target_height: 4096,
781 fps: 12,
782 };
783 assert!(document
784 .validate_shape()
785 .unwrap_err()
786 .to_string()
787 .contains("geschatte renderbuffer"));
788 document.render.fps = 0;
789 document.render.target_width = 128;
790 document.render.target_height = 128;
791 assert!(document
792 .validate_shape()
793 .unwrap_err()
794 .to_string()
795 .contains("fps"));
796 let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap();
797 let text = serde_json::to_string(&assembled).unwrap();
798 let parsed = parse_manifest(&text).unwrap();
799 assert_eq!(parsed.render, RenderSettings::default());
800 assert_eq!(parsed.male_model, None);
801 }
802
803 #[cfg(unix)]
804 #[test]
805 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
806 let root = temp_root("link");
807 let opened = create_project(&root, "Ridder").unwrap();
808 let outside = temp_root("outside-file");
809 fs::create_dir_all(&outside).unwrap();
810 let secret = outside.join("geheim.glb");
811 fs::write(&secret, b"nope").unwrap();
812 std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap();
813 let mut broken = opened.clone();
814 broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string());
815 let error = save_project(&broken).unwrap_err();
816 assert!(
817 error.to_string().contains("buiten de projectmap"),
818 "{error}"
819 );
820 fs::write(root.join("assets/body/male.glb"), b"ok").unwrap();
821 let entries = list_dir(&root, "assets/body").unwrap();
822 assert!(entries
823 .iter()
824 .any(|entry| entry.name == "male.glb" && !entry.is_dir));
825 let error = list_dir(&root, "../").unwrap_err();
826 assert!(error.to_string().contains("buiten de projectmap"));
827 let _ = fs::remove_dir_all(&root);
828 let _ = fs::remove_dir_all(&outside);
829 }
830}