Skip to main content

morph_config/
lib.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct WindowConfig {
7    #[serde(default = "default_width")]
8    pub width: u32,
9    #[serde(default = "default_height")]
10    pub height: u32,
11    #[serde(default = "default_title")]
12    pub title: String,
13}
14
15impl Default for WindowConfig {
16    fn default() -> Self {
17        Self { width: 800, height: 600, title: "Morph App".to_string() }
18    }
19}
20
21fn default_width() -> u32 { 800 }
22fn default_height() -> u32 { 600 }
23fn default_title() -> String { "Morph App".to_string() }
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BuildConfig {
27    #[serde(default)]
28    pub wayland: bool,
29    #[serde(default)]
30    pub system_freetype: bool,
31    #[serde(default = "default_true")]
32    pub upx: bool,
33    #[serde(default)]
34    pub upx_version: String,
35    #[serde(default)]
36    pub cxx: String,
37    #[serde(default)]
38    pub dev_cxx: String,
39    #[serde(default)]
40    pub cmake: String,
41}
42
43impl Default for BuildConfig {
44    fn default() -> Self {
45        Self { wayland: false, system_freetype: false, upx: true, upx_version: String::new(), cxx: String::new(), dev_cxx: String::new(), cmake: String::new() }
46    }
47}
48
49fn default_true() -> bool { true }
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RuntimeConfig {
53    #[serde(default = "default_runtime_type")]
54    #[serde(rename = "type")]
55    pub runtime_type: String,
56    #[serde(default = "default_runtime_version")]
57    pub version: String,
58}
59
60impl Default for RuntimeConfig {
61    fn default() -> Self {
62        Self { runtime_type: "cpp".to_string(), version: "0.1.0".to_string() }
63    }
64}
65
66fn default_runtime_type() -> String { "cpp".to_string() }
67fn default_runtime_version() -> String { "0.1.0".to_string() }
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70pub struct NativeConfig {
71    #[serde(default)]
72    pub include_dirs: Vec<String>,
73    #[serde(default)]
74    pub library_dirs: Vec<String>,
75    #[serde(default)]
76    pub libraries: Vec<String>,
77    #[serde(default)]
78    pub cflags: Vec<String>,
79    #[serde(default)]
80    pub ldflags: Vec<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, Default)]
84pub struct LintConfig {
85    #[serde(default)]
86    pub disable: Vec<String>,
87    #[serde(default)]
88    pub severities: std::collections::HashMap<String, String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct MorphConfig {
93    #[serde(default = "default_name")]
94    pub name: String,
95    #[serde(default = "default_entry")]
96    pub entry: String,
97    #[serde(default = "default_output")]
98    pub output: String,
99    #[serde(default)]
100    pub window: WindowConfig,
101    #[serde(default = "default_renderer")]
102    pub renderer: String,
103    #[serde(rename = "types", default = "default_type_mode")]
104    pub type_mode: String,
105    #[serde(default)]
106    pub dependencies: std::collections::HashMap<String, String>,
107    #[serde(default)]
108    pub cpp_sources: Vec<String>,
109    #[serde(default)]
110    pub native: NativeConfig,
111    #[serde(default)]
112    pub node_bridge: bool,
113    #[serde(default)]
114    pub build: BuildConfig,
115    #[serde(default)]
116    pub lint: LintConfig,
117    #[serde(default)]
118    pub runtime: RuntimeConfig,
119}
120
121fn default_name() -> String { "my-app".to_string() }
122fn default_entry() -> String { "src/App.mx".to_string() }
123fn default_output() -> String { ".morph/output".to_string() }
124fn default_type_mode() -> String { "infer".to_string() }
125
126pub fn clean_app_name(name: &str) -> String {
127    let mut out = String::with_capacity(name.len());
128    for ch in name.chars() {
129        if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
130            out.push(ch);
131        } else if ch.is_whitespace() {
132            out.push('_');
133        } else {
134            out.push('_');
135        }
136    }
137    // Collapse multiple _ and trim
138    let mut cleaned = String::new();
139    let mut prev_us = false;
140    for ch in out.chars() {
141        if ch == '_' {
142            if !prev_us { cleaned.push('_'); }
143            prev_us = true;
144        } else {
145            cleaned.push(ch);
146            prev_us = false;
147        }
148    }
149    let cleaned = cleaned.trim_matches('_').to_string();
150    if cleaned.is_empty() { "app".to_string() } else { cleaned }
151}
152
153/// Source extensions a project entry/scan may use (strict TS/TSX + Morph's .mx).
154pub fn is_supported_source_ext(ext: &str) -> bool {
155    matches!(ext, "mx" | "ts" | "tsx")
156}
157
158/// Detected-but-disallowed JS-family extensions. Morph intentionally only supports
159/// strict TypeScript/TSX, so these trigger a hard error instead of being parsed.
160pub fn is_disallowed_js_ext(ext: &str) -> bool {
161    matches!(ext, "js" | "jsx" | "mjs" | "cjs")
162}
163
164/// Validate a file path's extension as a morph source entry. Returns a hard-error
165/// message when the extension is disallowed, or `None` when it is supported.
166pub fn validate_entry_ext(path: &std::path::Path) -> Result<(), String> {
167    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
168    if is_supported_source_ext(ext) {
169        return Ok(());
170    }
171    if is_disallowed_js_ext(ext) {
172        return Err(format!(
173            "`.{}` files are not supported — Morph only supports strict `.ts`, `.tsx`, and `.mx`. Found: {}",
174            ext,
175            path.display()
176        ));
177    }
178    Err(format!(
179        "unsupported source extension `.{}` (expected `.ts`, `.tsx`, or `.mx`): {}",
180        ext,
181        path.display()
182    ))
183}
184
185fn default_renderer() -> String { "flash".to_string() }
186
187impl Default for MorphConfig {
188    fn default() -> Self {
189        Self {
190            name: default_name(),
191            entry: default_entry(),
192            output: default_output(),
193            window: WindowConfig::default(),
194            renderer: default_renderer(),
195            type_mode: default_type_mode(),
196            dependencies: Default::default(),
197            cpp_sources: Default::default(),
198            native: NativeConfig::default(),
199            node_bridge: false,
200            build: BuildConfig::default(),
201            lint: LintConfig::default(),
202            runtime: RuntimeConfig::default(),
203        }
204    }
205}
206
207impl MorphConfig {
208    pub fn from_file(path: &Path) -> Result<Self> {
209        let content = std::fs::read_to_string(path)
210            .with_context(|| format!("failed to read config {}", path.display()))?;
211        let cfg: Self = serde_json::from_str(&content)
212            .with_context(|| format!("failed to parse {}", path.display()))?;
213        Ok(cfg)
214    }
215
216    pub fn from_str(s: &str) -> Result<Self> {
217        Ok(serde_json::from_str(s)?)
218    }
219
220    pub fn to_json_pretty(&self) -> Result<String> {
221        Ok(serde_json::to_string_pretty(self)?)
222    }
223
224    pub fn save(&self, path: &Path) -> Result<()> {
225        let json = self.to_json_pretty()?;
226        std::fs::write(path, json)?;
227        Ok(())
228    }
229
230    pub fn validate(&self) -> Result<()> {
231        // Validate runtime version is semver
232        semver::Version::parse(&self.runtime.version)
233            .with_context(|| format!("invalid runtime version: {}", self.runtime.version))?;
234        if self.runtime.runtime_type != "cpp" && self.runtime.runtime_type != "rust" {
235            anyhow::bail!("runtime.type must be 'cpp' or 'rust', got '{}'", self.runtime.runtime_type);
236        }
237        Ok(())
238    }
239}
240
241/// Version file format for releases
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct VersionFile {
244    pub version: String,
245    pub changelog: String,
246    #[serde(default)]
247    pub breaking: bool,
248}
249
250impl VersionFile {
251    pub fn from_file(path: &Path) -> Result<Self> {
252        let content = std::fs::read_to_string(path)?;
253        Ok(serde_json::from_str(&content)?)
254    }
255
256    pub fn from_str(s: &str) -> Result<Self> {
257        Ok(serde_json::from_str(s)?)
258    }
259}
260
261/// Lock file (morph.lock)
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct MorphLock {
264    pub runtime: LockRuntime,
265    #[serde(default)]
266    pub generated_by: String,
267    #[serde(default)]
268    pub generated_at: String,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct LockRuntime {
273    #[serde(rename = "type")]
274    pub runtime_type: String,
275    pub version: String,
276    pub sha256: String,
277    pub downloaded_at: String,
278}
279
280impl MorphLock {
281    pub fn from_file(path: &Path) -> Result<Self> {
282        let content = std::fs::read_to_string(path)?;
283        Ok(serde_json::from_str(&content)?)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn parse_minimal_config() {
293        let json = r#"{"name":"test-app"}"#;
294        let cfg = MorphConfig::from_str(json).unwrap();
295        assert_eq!(cfg.name, "test-app");
296        assert_eq!(cfg.entry, "src/App.mx");
297        assert_eq!(cfg.runtime.runtime_type, "cpp");
298        assert_eq!(cfg.type_mode, "infer");
299    }
300
301    #[test]
302    fn parse_full_config() {
303        let json = r#"{
304            "name": "my-app",
305            "entry": "src/App.mx",
306            "runtime": {"type": "cpp", "version": "0.2.0"},
307            "window": {"width": 1024, "height": 768, "title": "Hello"}
308        }"#;
309        let cfg = MorphConfig::from_str(json).unwrap();
310        assert_eq!(cfg.runtime.version, "0.2.0");
311        assert_eq!(cfg.window.width, 1024);
312    }
313
314    #[test]
315    fn parse_types_mode() {
316        let cfg = MorphConfig::from_str(r#"{"types":"strict"}"#).unwrap();
317        assert_eq!(cfg.type_mode, "strict");
318        let roundtrip: MorphConfig =
319            serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
320        assert_eq!(roundtrip.type_mode, "strict");
321    }
322}