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 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
153pub fn is_supported_source_ext(ext: &str) -> bool {
155 matches!(ext, "mx" | "ts" | "tsx")
156}
157
158pub fn is_disallowed_js_ext(ext: &str) -> bool {
161 matches!(ext, "js" | "jsx" | "mjs" | "cjs")
162}
163
164pub 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 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#[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#[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}