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