Skip to main content

pite_project/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `pite-project`: `pite.toml` manifest, `res://` paths, templates.
3
4#![forbid(unsafe_code)]
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12pub const MANIFEST_FILE: &str = "pite.toml";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PiteManifest {
16    #[serde(default)]
17    pub project: ProjectMeta,
18    #[serde(default)]
19    pub export: ExportConfig,
20    #[serde(flatten, default)]
21    pub extra: HashMap<String, toml::Value>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ProjectMeta {
26    #[serde(default = "default_name")]
27    pub name: String,
28    #[serde(default = "default_version")]
29    pub pite_version: String,
30    #[serde(default = "default_main_scene")]
31    pub main_scene: String,
32    #[serde(default = "default_python")]
33    pub python: String,
34    #[serde(default)]
35    pub author: String,
36    #[serde(default)]
37    pub icon: String,
38    #[serde(default = "default_window_width")]
39    pub window_width: u32,
40    #[serde(default = "default_window_height")]
41    pub window_height: u32,
42    #[serde(flatten, default)]
43    pub extra: HashMap<String, toml::Value>,
44}
45
46impl Default for ProjectMeta {
47    fn default() -> Self {
48        Self {
49            name: default_name(),
50            pite_version: default_version(),
51            main_scene: default_main_scene(),
52            python: default_python(),
53            author: String::new(),
54            icon: String::new(),
55            window_width: default_window_width(),
56            window_height: default_window_height(),
57            extra: HashMap::new(),
58        }
59    }
60}
61
62fn default_name() -> String {
63    "my-game".to_string()
64}
65fn default_version() -> String {
66    "0.1.1".to_string()
67}
68fn default_main_scene() -> String {
69    "res://scenes/main.pitescene".to_string()
70}
71fn default_python() -> String {
72    "3.12".to_string()
73}
74fn default_window_width() -> u32 {
75    800
76}
77fn default_window_height() -> u32 {
78    600
79}
80
81/// Export settings. Everything defaults. `platforms` was removed in 0.1.1
82/// (export never read it); manifests that still carry it parse untouched
83/// and warn via `validate_manifest`.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ExportConfig {
86    #[serde(default)]
87    pub binary_name: Option<String>,
88    #[serde(default)]
89    pub include: Vec<String>,
90    #[serde(flatten, default)]
91    pub extra: HashMap<String, toml::Value>,
92}
93
94impl Default for ExportConfig {
95    fn default() -> Self {
96        Self {
97            binary_name: None,
98            include: Vec::new(),
99            extra: HashMap::new(),
100        }
101    }
102}
103
104pub fn load_manifest(dir: &Path) -> Result<PiteManifest> {
105    let path = dir.join(MANIFEST_FILE);
106    let text = std::fs::read_to_string(&path)
107        .with_context(|| format!("cannot read {}", path.display()))?;
108    toml::from_str(&text).with_context(|| format!("cannot parse {}", path.display()))
109}
110
111/// Replacement for a deprecated manifest key, if any. Empty today: the
112/// registry exists so a future rename warns instead of silently changing meaning.
113pub fn deprecated_replacement(field: &str) -> Option<&'static str> {
114    let _ = field;
115    None
116}
117
118/// Pure manifest validation: unknown/deprecated keys warn (sorted, stable),
119/// impossible values error. Filesystem checks (icon exists) live in `pite check`.
120pub fn validate_manifest(manifest: &PiteManifest) -> (Vec<String>, Vec<String>) {
121    let mut errors = Vec::new();
122    let mut warnings = Vec::new();
123    let mut unknown: Vec<String> = manifest.extra.keys().cloned().collect();
124    for key in manifest.project.extra.keys() {
125        unknown.push(format!("project.{key}"));
126    }
127    for key in manifest.export.extra.keys() {
128        unknown.push(format!("export.{key}"));
129    }
130    unknown.sort();
131    for field in unknown {
132        if field == "export.platforms" {
133            warnings.push(
134                "manifest: export.platforms is deprecated and ignored (removed in 0.1.1)"
135                    .to_string(),
136            );
137            continue;
138        }
139        match deprecated_replacement(&field) {
140            Some(use_instead) => warnings.push(format!(
141                "manifest: {field:?} is deprecated, use {use_instead:?}"
142            )),
143            None => warnings.push(format!("manifest: unknown field {field:?}")),
144        }
145    }
146    if manifest.project.python.is_empty() {
147        errors.push("manifest: project.python must not be empty".to_string());
148    }
149    if manifest.project.window_width == 0 || manifest.project.window_height == 0 {
150        errors.push(format!(
151            "manifest: window is {}x{}, dimensions must be positive",
152            manifest.project.window_width, manifest.project.window_height
153        ));
154    }
155    if !manifest.project.icon.is_empty() && !manifest.project.icon.starts_with("res://") {
156        errors.push(format!(
157            "manifest: icon {:?} must be res:// or empty (absolute paths are forbidden)",
158            manifest.project.icon
159        ));
160    }
161    (errors, warnings)
162}
163
164pub fn find_project_root(start: &Path) -> Option<PathBuf> {
165    let mut dir = if start.is_file() {
166        start.parent()?.to_path_buf()
167    } else {
168        start.to_path_buf()
169    };
170    loop {
171        if dir.join(MANIFEST_FILE).is_file() {
172            return Some(dir);
173        }
174        if !dir.pop() {
175            return None;
176        }
177    }
178}
179
180pub fn resolve_res(project_dir: &Path, res_path: &str) -> Option<PathBuf> {
181    res_path
182        .strip_prefix("res://")
183        .map(|rel| project_dir.join(rel))
184}
185
186pub fn to_res_path(project_dir: &Path, path: &Path) -> Option<String> {
187    path.strip_prefix(project_dir)
188        .ok()
189        .map(|rel| format!("res://{}", rel.to_string_lossy().replace('\\', "/")))
190}
191
192pub const TEMPLATE_PITE_TOML: &str = r#"[project]
193name = "TEMPLATE_NAME"
194pite_version = "0.1.1"
195main_scene = "res://scenes/main.pitescene"
196python = "3.12"
197author = ""
198icon = ""
199window_width = 800
200window_height = 600
201"#;
202
203/// Fresh `main.pitescene` for `create_project`. The format version comes
204/// from the `pite-scene` const so scaffolds track format bumps.
205pub fn template_scene() -> String {
206    format!(
207        r#"format_version = {}
208root = "root"
209
210[[node]]
211id = "root"
212type = "Node2D"
213name = "Main"
214
215[[node]]
216id = "player"
217type = "Sprite2D"
218name = "Player"
219parent = "root"
220
221[node.props]
222texture = "res://assets/player.png"
223
224[node.script]
225path = "res://scripts/player.py"
226class = "Player"
227
228[[node]]
229id = "cam"
230type = "Camera2D"
231name = "Camera"
232parent = "root"
233
234[node.props]
235position = [100.0, 200.0]
236"#,
237        pite_scene::FORMAT_VERSION
238    )
239}
240
241pub const TEMPLATE_SCRIPT: &str = r#"import pite
242
243
244class Player(pite.Node2D):
245    speed: float = 200.0
246
247    def _ready(self):
248        self.position = (100.0, 200.0)
249
250    def _process(self, delta: float):
251        x, y = self.position
252        if pite.held("ArrowRight"):
253            x += self.speed * delta
254        if pite.held("ArrowLeft"):
255            x -= self.speed * delta
256        if pite.held("ArrowDown"):
257            y += self.speed * delta
258        if pite.held("ArrowUp"):
259            y -= self.speed * delta
260        if pite.pressed("Space"):
261            x += 10.0
262        self.position = (x, y)
263"#;
264
265/// Valid 1x1 RGBA PNG backing the `res://assets/player.png` reference in
266/// [`template_scene()`], so fresh scaffolds pass `pite check`.
267const TEMPLATE_PLAYER_PNG: &[u8] = &[
268    137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
269    0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 255, 127, 0,
270    9, 251, 3, 253, 42, 134, 227, 138, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
271];
272
273pub fn create_project(dest: &Path, name: &str, template: &str) -> Result<PathBuf> {
274    if template != "minimal-2d" {
275        anyhow::bail!("unknown template {template:?} (only \"minimal-2d\" exists)");
276    }
277    let dir = if dest.file_name().map(|n| n == name).unwrap_or(false) {
278        dest.to_path_buf()
279    } else {
280        dest.join(name)
281    };
282    if dir.exists() {
283        anyhow::bail!("{} already exists", dir.display());
284    }
285    std::fs::create_dir_all(dir.join("scenes")).with_context(|| "cannot create project dirs")?;
286    std::fs::create_dir_all(dir.join("scripts"))?;
287    std::fs::create_dir_all(dir.join("assets"))?;
288    std::fs::write(
289        dir.join(MANIFEST_FILE),
290        TEMPLATE_PITE_TOML.replace("TEMPLATE_NAME", name),
291    )?;
292    std::fs::write(dir.join("scenes").join("main.pitescene"), template_scene())?;
293    std::fs::write(dir.join("scripts").join("player.py"), TEMPLATE_SCRIPT)?;
294    std::fs::write(dir.join("assets").join("player.png"), TEMPLATE_PLAYER_PNG)?;
295    Ok(dir)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use std::sync::atomic::{AtomicU64, Ordering};
302
303    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
304
305    fn tmpdir(tag: &str) -> PathBuf {
306        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
307        let dir =
308            std::env::temp_dir().join(format!("pite-manifest-{tag}-{}-{n}", std::process::id()));
309        let _ = std::fs::remove_dir_all(&dir);
310        std::fs::create_dir_all(&dir).unwrap();
311        dir
312    }
313
314    fn load_text(text: &str) -> PiteManifest {
315        let dir = tmpdir("parse");
316        std::fs::write(dir.join(MANIFEST_FILE), text).unwrap();
317        let manifest = load_manifest(&dir).unwrap();
318        std::fs::remove_dir_all(&dir).ok();
319        manifest
320    }
321
322    #[test]
323    fn old_manifest_parses_untouched() {
324        let manifest = load_text("[project]\nname = \"old\"\n");
325        assert_eq!(manifest.project.name, "old");
326        assert_eq!(manifest.project.pite_version, "0.1.1");
327        assert_eq!(manifest.project.main_scene, "res://scenes/main.pitescene");
328        assert_eq!(manifest.project.python, "3.12");
329        assert_eq!(manifest.project.author, "");
330        assert_eq!(manifest.project.icon, "");
331        assert_eq!(manifest.project.window_width, 800);
332        assert_eq!(manifest.project.window_height, 600);
333        assert!(manifest.project.extra.is_empty());
334        let (errors, warnings) = validate_manifest(&manifest);
335        assert!(errors.is_empty(), "got {errors:?}");
336        assert!(warnings.is_empty(), "got {warnings:?}");
337    }
338
339    #[test]
340    fn bad_type_fails_loudly() {
341        let dir = tmpdir("bad-type");
342        std::fs::write(dir.join(MANIFEST_FILE), "[project]\nname = 5\n").unwrap();
343        let err = load_manifest(&dir).unwrap_err();
344        assert!(err.to_string().contains("cannot parse"), "got {err:#}");
345        std::fs::write(
346            dir.join(MANIFEST_FILE),
347            "[project]\nname = \"x\"\nwindow_width = \"wide\"\n",
348        )
349        .unwrap();
350        let err = load_manifest(&dir).unwrap_err();
351        assert!(err.to_string().contains("cannot parse"), "got {err:#}");
352        std::fs::remove_dir_all(&dir).ok();
353    }
354
355    #[test]
356    fn unknown_keys_survive_round_trip() {
357        let manifest = load_text(
358            "custom = 1\n\n[project]\nname = \"x\"\nnickname = \"y\"\n\n[export]\nbundle = true\n",
359        );
360        assert_eq!(
361            manifest.project.extra.get("nickname"),
362            Some(&toml::Value::String("y".to_string()))
363        );
364        let again: PiteManifest = toml::from_str(&toml::to_string(&manifest).unwrap()).unwrap();
365        assert_eq!(manifest.extra, again.extra);
366        assert_eq!(manifest.project.extra, again.project.extra);
367        assert_eq!(manifest.export.extra, again.export.extra);
368        let (_, warnings) = validate_manifest(&again);
369        for field in ["custom", "project.nickname", "export.bundle"] {
370            assert!(
371                warnings.iter().any(|w| w.contains(field)),
372                "missing warning for {field}, got: {warnings:?}"
373            );
374        }
375    }
376
377    #[test]
378    fn impossible_values_are_errors() {
379        let manifest = load_text(
380            "[project]\nname = \"x\"\npython = \"\"\nwindow_width = 0\nicon = \"/abs/icon.png\"\n",
381        );
382        let (errors, _) = validate_manifest(&manifest);
383        assert!(
384            errors.iter().any(|e| e.contains("python")),
385            "got {errors:?}"
386        );
387        assert!(errors.iter().any(|e| e.contains("0x")), "got {errors:?}");
388        assert!(errors.iter().any(|e| e.contains("icon")), "got {errors:?}");
389    }
390}