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    /// Pinned third-party packages (`pite vendor add`). The table is the
21    /// lock: committed `vendor/` bytes must match it (`pite vendor verify`).
22    /// No `form` field yet — absent means zipped wheels; unpacked trees
23    /// arrive with explicit forms later, keeping this shape working.
24    #[serde(default)]
25    pub vendor: HashMap<String, VendorEntry>,
26    #[serde(flatten, default)]
27    pub extra: HashMap<String, toml::Value>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ProjectMeta {
32    #[serde(default = "default_name")]
33    pub name: String,
34    #[serde(default = "default_version")]
35    pub pite_version: String,
36    #[serde(default = "default_main_scene")]
37    pub main_scene: String,
38    #[serde(default = "default_python")]
39    pub python: String,
40    #[serde(default)]
41    pub author: String,
42    #[serde(default)]
43    pub icon: String,
44    #[serde(default = "default_window_width")]
45    pub window_width: u32,
46    #[serde(default = "default_window_height")]
47    pub window_height: u32,
48    #[serde(flatten, default)]
49    pub extra: HashMap<String, toml::Value>,
50}
51
52impl Default for ProjectMeta {
53    fn default() -> Self {
54        Self {
55            name: default_name(),
56            pite_version: default_version(),
57            main_scene: default_main_scene(),
58            python: default_python(),
59            author: String::new(),
60            icon: String::new(),
61            window_width: default_window_width(),
62            window_height: default_window_height(),
63            extra: HashMap::new(),
64        }
65    }
66}
67
68fn default_name() -> String {
69    "my-game".to_string()
70}
71fn default_version() -> String {
72    "0.2.0".to_string()
73}
74fn default_main_scene() -> String {
75    "res://scenes/main.pitescene".to_string()
76}
77fn default_python() -> String {
78    "3.12".to_string()
79}
80fn default_window_width() -> u32 {
81    1280
82}
83fn default_window_height() -> u32 {
84    720
85}
86
87/// Export settings. Everything defaults. `platforms` was removed in 0.1.1
88/// (export never read it); manifests that still carry it parse untouched
89/// and warn via `validate_manifest`.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ExportConfig {
92    #[serde(default)]
93    pub binary_name: Option<String>,
94    #[serde(default)]
95    pub include: Vec<String>,
96    #[serde(flatten, default)]
97    pub extra: HashMap<String, toml::Value>,
98}
99
100impl Default for ExportConfig {
101    fn default() -> Self {
102        Self {
103            binary_name: None,
104            include: Vec::new(),
105            extra: HashMap::new(),
106        }
107    }
108}
109
110/// One pinned package in `[vendor]`. `sha256` covers the stored artifact:
111/// the wheel file when zipped, a canonical tree hash when unpacked.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct VendorEntry {
114    pub version: String,
115    pub sha256: String,
116    /// False for transitive deps pulled in by `add`; true for asked-for ones.
117    #[serde(default)]
118    pub direct: bool,
119}
120
121pub fn load_manifest(dir: &Path) -> Result<PiteManifest> {
122    let path = dir.join(MANIFEST_FILE);
123    let text = std::fs::read_to_string(&path)
124        .with_context(|| format!("cannot read {}", path.display()))?;
125    toml::from_str(&text).with_context(|| format!("cannot parse {}", path.display()))
126}
127
128/// Write the manifest back to `dir/pite.toml`. Serializes through
129/// `toml::Value` (sorted tables) so saves are byte-stable even with unknown
130/// `extra` keys aboard; known fields land alphabetically on disk.
131pub fn save_manifest(dir: &Path, manifest: &PiteManifest) -> Result<()> {
132    let path = dir.join(MANIFEST_FILE);
133    let value =
134        toml::Value::try_from(manifest).with_context(|| "cannot encode manifest as TOML")?;
135    let mut text =
136        toml::to_string_pretty(&value).with_context(|| "cannot render manifest as TOML")?;
137    text.push('\n');
138    std::fs::write(&path, text).with_context(|| format!("cannot write {}", path.display()))
139}
140
141/// Replacement for a deprecated manifest key, if any. Empty today: the
142/// registry exists so a future rename warns instead of silently changing meaning.
143pub fn deprecated_replacement(field: &str) -> Option<&'static str> {
144    let _ = field;
145    None
146}
147
148/// Pure manifest validation: unknown/deprecated keys warn (sorted, stable),
149/// impossible values error. Filesystem checks (icon exists) live in `pite check`.
150pub fn validate_manifest(manifest: &PiteManifest) -> (Vec<String>, Vec<String>) {
151    let mut errors = Vec::new();
152    let mut warnings = Vec::new();
153    let mut unknown: Vec<String> = manifest.extra.keys().cloned().collect();
154    for key in manifest.project.extra.keys() {
155        unknown.push(format!("project.{key}"));
156    }
157    for key in manifest.export.extra.keys() {
158        unknown.push(format!("export.{key}"));
159    }
160    unknown.sort();
161    for field in unknown {
162        if field == "export.platforms" {
163            warnings.push(
164                "manifest: export.platforms is deprecated and ignored (removed in 0.1.1)"
165                    .to_string(),
166            );
167            continue;
168        }
169        match deprecated_replacement(&field) {
170            Some(use_instead) => warnings.push(format!(
171                "manifest: {field:?} is deprecated, use {use_instead:?}"
172            )),
173            None => warnings.push(format!("manifest: unknown field {field:?}")),
174        }
175    }
176    if manifest.project.python.is_empty() {
177        errors.push("manifest: project.python must not be empty".to_string());
178    }
179    if manifest.project.window_width == 0 || manifest.project.window_height == 0 {
180        errors.push(format!(
181            "manifest: window is {}x{}, dimensions must be positive",
182            manifest.project.window_width, manifest.project.window_height
183        ));
184    }
185    if !manifest.project.icon.is_empty() && !manifest.project.icon.starts_with("res://") {
186        errors.push(format!(
187            "manifest: icon {:?} must be res:// or empty (absolute paths are forbidden)",
188            manifest.project.icon
189        ));
190    }
191    (errors, warnings)
192}
193
194pub fn find_project_root(start: &Path) -> Option<PathBuf> {
195    let mut dir = if start.is_file() {
196        start.parent()?.to_path_buf()
197    } else {
198        start.to_path_buf()
199    };
200    loop {
201        if dir.join(MANIFEST_FILE).is_file() {
202            return Some(dir);
203        }
204        if !dir.pop() {
205            return None;
206        }
207    }
208}
209
210pub fn resolve_res(project_dir: &Path, res_path: &str) -> Option<PathBuf> {
211    res_path
212        .strip_prefix("res://")
213        .map(|rel| project_dir.join(rel))
214}
215
216pub fn to_res_path(project_dir: &Path, path: &Path) -> Option<String> {
217    path.strip_prefix(project_dir)
218        .ok()
219        .map(|rel| format!("res://{}", rel.to_string_lossy().replace('\\', "/")))
220}
221
222pub const TEMPLATE_PITE_TOML: &str = r#"[project]
223name = "TEMPLATE_NAME"
224pite_version = "0.2.0"
225main_scene = "res://scenes/main.pitescene"
226python = "3.12"
227author = ""
228icon = ""
229window_width = 1280
230window_height = 720
231"#;
232
233/// Fresh `main.pitescene` for `create_project`. The format version comes
234/// from the `pite-scene` const so scaffolds track format bumps.
235pub fn template_scene() -> String {
236    format!(
237        r#"format_version = {}
238root = "root"
239
240[[node]]
241id = "root"
242type = "Node2D"
243name = "Main"
244
245[[node]]
246id = "player"
247type = "Sprite2D"
248name = "Player"
249parent = "root"
250
251[node.props]
252texture = "res://assets/player.png"
253
254[node.script]
255path = "res://scripts/player.py"
256class = "Player"
257
258[[node]]
259id = "cam"
260type = "Camera2D"
261name = "Camera"
262parent = "root"
263
264[node.props]
265position = [100.0, 200.0]
266"#,
267        pite_scene::FORMAT_VERSION
268    )
269}
270
271pub const TEMPLATE_SCRIPT: &str = r#"import pite
272
273
274class Player(pite.Node2D):
275    speed: float = 200.0
276
277    def _ready(self):
278        self.position = (100.0, 200.0)
279
280    def _process(self, delta: float):
281        x, y = self.position
282        if pite.held("ArrowRight"):
283            x += self.speed * delta
284        if pite.held("ArrowLeft"):
285            x -= self.speed * delta
286        if pite.held("ArrowDown"):
287            y += self.speed * delta
288        if pite.held("ArrowUp"):
289            y -= self.speed * delta
290        if pite.pressed("Space"):
291            x += 10.0
292        self.position = (x, y)
293"#;
294
295/// Valid 1x1 RGBA PNG backing the `res://assets/player.png` reference in
296/// [`template_scene()`], so fresh scaffolds pass `pite check`.
297const TEMPLATE_PLAYER_PNG: &[u8] = &[
298    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,
299    0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 255, 127, 0,
300    9, 251, 3, 253, 42, 134, 227, 138, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
301];
302
303pub fn create_project(dest: &Path, name: &str, template: &str) -> Result<PathBuf> {
304    if template != "minimal-2d" {
305        anyhow::bail!("unknown template {template:?} (only \"minimal-2d\" exists)");
306    }
307    let dir = if dest.file_name().map(|n| n == name).unwrap_or(false) {
308        dest.to_path_buf()
309    } else {
310        dest.join(name)
311    };
312    if dir.exists() {
313        anyhow::bail!("{} already exists", dir.display());
314    }
315    std::fs::create_dir_all(dir.join("scenes")).with_context(|| "cannot create project dirs")?;
316    std::fs::create_dir_all(dir.join("scripts"))?;
317    std::fs::create_dir_all(dir.join("assets"))?;
318    std::fs::write(
319        dir.join(MANIFEST_FILE),
320        TEMPLATE_PITE_TOML.replace("TEMPLATE_NAME", name),
321    )?;
322    std::fs::write(dir.join("scenes").join("main.pitescene"), template_scene())?;
323    std::fs::write(dir.join("scripts").join("player.py"), TEMPLATE_SCRIPT)?;
324    std::fs::write(dir.join("assets").join("player.png"), TEMPLATE_PLAYER_PNG)?;
325    Ok(dir)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::sync::atomic::{AtomicU64, Ordering};
332
333    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
334
335    fn tmpdir(tag: &str) -> PathBuf {
336        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
337        let dir =
338            std::env::temp_dir().join(format!("pite-manifest-{tag}-{}-{n}", std::process::id()));
339        let _ = std::fs::remove_dir_all(&dir);
340        std::fs::create_dir_all(&dir).unwrap();
341        dir
342    }
343
344    fn load_text(text: &str) -> PiteManifest {
345        let dir = tmpdir("parse");
346        std::fs::write(dir.join(MANIFEST_FILE), text).unwrap();
347        let manifest = load_manifest(&dir).unwrap();
348        std::fs::remove_dir_all(&dir).ok();
349        manifest
350    }
351
352    #[test]
353    fn old_manifest_parses_untouched() {
354        let manifest = load_text("[project]\nname = \"old\"\n");
355        assert_eq!(manifest.project.name, "old");
356        assert_eq!(manifest.project.pite_version, "0.2.0");
357        assert_eq!(manifest.project.main_scene, "res://scenes/main.pitescene");
358        assert_eq!(manifest.project.python, "3.12");
359        assert_eq!(manifest.project.author, "");
360        assert_eq!(manifest.project.icon, "");
361        assert_eq!(manifest.project.window_width, 1280);
362        assert_eq!(manifest.project.window_height, 720);
363        assert!(manifest.project.extra.is_empty());
364        assert!(manifest.vendor.is_empty());
365        let (errors, warnings) = validate_manifest(&manifest);
366        assert!(errors.is_empty(), "got {errors:?}");
367        assert!(warnings.is_empty(), "got {warnings:?}");
368    }
369
370    #[test]
371    fn vendor_table_round_trips() {
372        let dir = tmpdir("vendor");
373        let mut manifest = load_text("[project]\nname = \"t\"\n");
374        manifest.vendor.insert(
375            "requests".to_string(),
376            VendorEntry {
377                version: "2.32.3".to_string(),
378                sha256: "abc".to_string(),
379                direct: true,
380            },
381        );
382        manifest.vendor.insert(
383            "urllib3".to_string(),
384            VendorEntry {
385                version: "2.2.2".to_string(),
386                sha256: "def".to_string(),
387                direct: false,
388            },
389        );
390        save_manifest(&dir, &manifest).unwrap();
391        let back = load_manifest(&dir).unwrap();
392        assert_eq!(back.vendor.len(), 2);
393        assert_eq!(back.vendor["requests"].version, "2.32.3");
394        assert!(back.vendor["requests"].direct);
395        assert!(!back.vendor["urllib3"].direct);
396        std::fs::remove_dir_all(&dir).ok();
397    }
398
399    #[test]
400    fn bad_type_fails_loudly() {
401        let dir = tmpdir("bad-type");
402        std::fs::write(dir.join(MANIFEST_FILE), "[project]\nname = 5\n").unwrap();
403        let err = load_manifest(&dir).unwrap_err();
404        assert!(err.to_string().contains("cannot parse"), "got {err:#}");
405        std::fs::write(
406            dir.join(MANIFEST_FILE),
407            "[project]\nname = \"x\"\nwindow_width = \"wide\"\n",
408        )
409        .unwrap();
410        let err = load_manifest(&dir).unwrap_err();
411        assert!(err.to_string().contains("cannot parse"), "got {err:#}");
412        std::fs::remove_dir_all(&dir).ok();
413    }
414
415    #[test]
416    fn unknown_keys_survive_round_trip() {
417        let manifest = load_text(
418            "custom = 1\n\n[project]\nname = \"x\"\nnickname = \"y\"\n\n[export]\nbundle = true\n",
419        );
420        assert_eq!(
421            manifest.project.extra.get("nickname"),
422            Some(&toml::Value::String("y".to_string()))
423        );
424        let again: PiteManifest = toml::from_str(&toml::to_string(&manifest).unwrap()).unwrap();
425        assert_eq!(manifest.extra, again.extra);
426        assert_eq!(manifest.project.extra, again.project.extra);
427        assert_eq!(manifest.export.extra, again.export.extra);
428        let (_, warnings) = validate_manifest(&again);
429        for field in ["custom", "project.nickname", "export.bundle"] {
430            assert!(
431                warnings.iter().any(|w| w.contains(field)),
432                "missing warning for {field}, got: {warnings:?}"
433            );
434        }
435    }
436
437    #[test]
438    fn impossible_values_are_errors() {
439        let manifest = load_text(
440            "[project]\nname = \"x\"\npython = \"\"\nwindow_width = 0\nicon = \"/abs/icon.png\"\n",
441        );
442        let (errors, _) = validate_manifest(&manifest);
443        assert!(
444            errors.iter().any(|e| e.contains("python")),
445            "got {errors:?}"
446        );
447        assert!(errors.iter().any(|e| e.contains("0x")), "got {errors:?}");
448        assert!(errors.iter().any(|e| e.contains("icon")), "got {errors:?}");
449    }
450
451    #[test]
452    fn save_keeps_unknown_keys_and_edits() {
453        let dir = tmpdir("save");
454        std::fs::write(
455            dir.join(MANIFEST_FILE),
456            "custom = 1\n\n[project]\nname = \"x\"\nnickname = \"y\"\n",
457        )
458        .unwrap();
459        let mut manifest = load_manifest(&dir).unwrap();
460        manifest.project.name = "renamed".to_string();
461        save_manifest(&dir, &manifest).unwrap();
462        let again = load_manifest(&dir).unwrap();
463        assert_eq!(again.project.name, "renamed");
464        assert_eq!(
465            again.project.extra.get("nickname"),
466            Some(&toml::Value::String("y".to_string()))
467        );
468        assert_eq!(again.extra.get("custom"), Some(&toml::Value::Integer(1)));
469        let (errors, _) = validate_manifest(&again);
470        assert!(errors.is_empty(), "got {errors:?}");
471        std::fs::remove_dir_all(&dir).ok();
472    }
473
474    #[test]
475    fn save_is_byte_stable() {
476        let dir = tmpdir("save-stable");
477        std::fs::write(
478            dir.join(MANIFEST_FILE),
479            "zeta = 1\nalpha = 2\n\n[project]\nname = \"x\"\n",
480        )
481        .unwrap();
482        let manifest = load_manifest(&dir).unwrap();
483        save_manifest(&dir, &manifest).unwrap();
484        let first = std::fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap();
485        let reloaded = load_manifest(&dir).unwrap();
486        save_manifest(&dir, &reloaded).unwrap();
487        let second = std::fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap();
488        assert_eq!(first, second, "save churned:\n{first}\n---\n{second}");
489        std::fs::remove_dir_all(&dir).ok();
490    }
491}