1use crate::fsutil;
2use crate::{LovelyError, Result};
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6pub const CONFIG_FILE: &str = "lovely.toml";
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Config {
10 pub game: GameConfig,
11 pub paths: PathConfig,
12 pub targets: TargetsConfig,
13 pub itch: ItchConfig,
14 pub steam: SteamConfig,
15 pub compatibility: CompatibilityConfig,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GameConfig {
20 pub id: String,
21 pub name: String,
22 pub version: String,
23 pub author: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PathConfig {
28 pub source: PathBuf,
29 pub output: PathBuf,
30 pub icon: Option<PathBuf>,
31 pub includes: Vec<String>,
32 pub excludes: Vec<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct TargetsConfig {
37 pub web: WebTargetConfig,
38 pub windows: DesktopTargetConfig,
39 pub macos: DesktopTargetConfig,
40 pub linux: DesktopTargetConfig,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct WebTargetConfig {
45 pub enabled: bool,
46 pub variant: String,
47 pub html_template: Option<PathBuf>,
48 pub html_assets: Vec<PathBuf>,
49 pub runtime_path: Option<PathBuf>,
50 pub memory_bytes: u64,
51 pub arguments: Vec<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct DesktopTargetConfig {
56 pub enabled: bool,
57 pub runtime_archive: Option<PathBuf>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ItchConfig {
62 pub project: Option<String>,
63 pub prerelease_channel: String,
64 pub release_channel: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct SteamConfig {
69 pub app_id: Option<String>,
70 pub windows_depot_id: Option<String>,
71 pub macos_depot_id: Option<String>,
72 pub linux_depot_id: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct CompatibilityConfig {
77 pub allow_warnings: Vec<String>,
78}
79
80impl Config {
81 pub fn default_for_dir(dir: &Path) -> Self {
82 let id = dir
83 .file_name()
84 .map(|name| slugify(&name.to_string_lossy()))
85 .filter(|name| !name.is_empty())
86 .unwrap_or_else(|| "my-love-game".to_string());
87
88 let name = id
89 .split('-')
90 .map(|part| {
91 let mut chars = part.chars();
92 match chars.next() {
93 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
94 None => String::new(),
95 }
96 })
97 .collect::<Vec<_>>()
98 .join(" ");
99
100 Self {
101 game: GameConfig {
102 id,
103 name,
104 version: "0.1.0".to_string(),
105 author: "Unknown".to_string(),
106 },
107 paths: PathConfig {
108 source: PathBuf::from("."),
109 output: PathBuf::from("dist"),
110 icon: Some(PathBuf::from("assets/icon.png")),
111 includes: vec!["**/*".to_string()],
112 excludes: vec!["node_modules/**".to_string()],
113 },
114 targets: TargetsConfig::default(),
115 itch: ItchConfig {
116 project: None,
117 prerelease_channel: "web-prerelease".to_string(),
118 release_channel: "web".to_string(),
119 },
120 steam: SteamConfig {
121 app_id: None,
122 windows_depot_id: None,
123 macos_depot_id: None,
124 linux_depot_id: None,
125 },
126 compatibility: CompatibilityConfig {
127 allow_warnings: Vec::new(),
128 },
129 }
130 }
131
132 pub fn load_from(path: &Path) -> Result<Self> {
133 let text = fsutil::read_to_string(path)?;
134 Self::parse(&text)
135 }
136
137 pub fn parse(text: &str) -> Result<Self> {
138 let mut table: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
139 let mut section = String::new();
140
141 for (index, raw_line) in text.lines().enumerate() {
142 let line = strip_comment(raw_line).trim();
143 if line.is_empty() {
144 continue;
145 }
146 if line.starts_with('[') && line.ends_with(']') {
147 section = line[1..line.len() - 1].trim().to_string();
148 continue;
149 }
150
151 let Some((key, value)) = line.split_once('=') else {
152 return Err(LovelyError::Config(format!(
153 "line {} is not a key/value pair",
154 index + 1
155 )));
156 };
157 table
158 .entry(section.clone())
159 .or_default()
160 .insert(key.trim().to_string(), value.trim().to_string());
161 }
162
163 let mut config = Self::default_for_dir(Path::new("."));
164 if let Some(game) = table.get("game") {
165 config.game.id = get_string(game, "id").unwrap_or(config.game.id);
166 config.game.name = get_string(game, "name").unwrap_or(config.game.name);
167 config.game.version = get_string(game, "version").unwrap_or(config.game.version);
168 config.game.author = get_string(game, "author").unwrap_or(config.game.author);
169 }
170 if let Some(paths) = table.get("paths") {
171 if let Some(source) = get_string(paths, "source") {
172 config.paths.source = PathBuf::from(source);
173 }
174 if let Some(output) = get_string(paths, "output") {
175 config.paths.output = PathBuf::from(output);
176 }
177 config.paths.icon = get_optional_path(paths, "icon", config.paths.icon);
178 if let Some(includes) = get_string_array(paths, "includes")? {
179 config.paths.includes = includes;
180 }
181 if let Some(excludes) = get_string_array(paths, "excludes")? {
182 config.paths.excludes = excludes;
183 }
184 }
185
186 apply_web(&mut config, table.get("targets.web"))?;
187 apply_desktop(&mut config.targets.windows, table.get("targets.windows"));
188 apply_desktop(&mut config.targets.macos, table.get("targets.macos"));
189 apply_desktop(&mut config.targets.linux, table.get("targets.linux"));
190
191 if let Some(itch) = table.get("itch") {
192 config.itch.project = get_optional_string(itch, "project", config.itch.project);
193 config.itch.prerelease_channel =
194 get_string(itch, "prerelease_channel").unwrap_or(config.itch.prerelease_channel);
195 config.itch.release_channel =
196 get_string(itch, "release_channel").unwrap_or(config.itch.release_channel);
197 }
198 if let Some(steam) = table.get("steam") {
199 config.steam.app_id = get_optional_string(steam, "app_id", config.steam.app_id);
200 config.steam.windows_depot_id =
201 get_optional_string(steam, "windows_depot_id", config.steam.windows_depot_id);
202 config.steam.macos_depot_id =
203 get_optional_string(steam, "macos_depot_id", config.steam.macos_depot_id);
204 config.steam.linux_depot_id =
205 get_optional_string(steam, "linux_depot_id", config.steam.linux_depot_id);
206 }
207 if let Some(compatibility) = table.get("compatibility")
208 && let Some(warnings) = get_string_array(compatibility, "allow_warnings")?
209 {
210 config.compatibility.allow_warnings = warnings;
211 }
212
213 config.validate()?;
214 Ok(config)
215 }
216
217 pub fn validate(&self) -> Result<()> {
218 if self.game.id.trim().is_empty() {
219 return Err(LovelyError::Config("game.id must not be empty".to_string()));
220 }
221 if self.game.name.trim().is_empty() {
222 return Err(LovelyError::Config(
223 "game.name must not be empty".to_string(),
224 ));
225 }
226 if !matches!(
227 self.targets.web.variant.as_str(),
228 "web-compat" | "web-threaded"
229 ) {
230 return Err(LovelyError::Config(
231 "targets.web.variant must be web-compat or web-threaded".to_string(),
232 ));
233 }
234 Ok(())
235 }
236
237 pub fn to_toml(&self) -> String {
238 format!(
239 r#"[game]
240id = "{id}"
241name = "{name}"
242version = "{version}"
243author = "{author}"
244
245[paths]
246source = "{source}"
247output = "{output}"
248icon = {icon}
249includes = {includes}
250excludes = {excludes}
251
252[targets.web]
253enabled = true
254variant = "web-compat"
255memory_bytes = 67108864
256html_template = ""
257html_assets = {html_assets}
258runtime_path = {runtime_path}
259arguments = {arguments}
260
261[targets.windows]
262enabled = true
263runtime_archive = ""
264
265[targets.macos]
266enabled = true
267runtime_archive = ""
268
269[targets.linux]
270enabled = true
271runtime_archive = ""
272
273[itch]
274project = ""
275prerelease_channel = "web-prerelease"
276release_channel = "web"
277
278[steam]
279app_id = ""
280windows_depot_id = ""
281macos_depot_id = ""
282linux_depot_id = ""
283
284[compatibility]
285allow_warnings = []
286"#,
287 id = escape(&self.game.id),
288 name = escape(&self.game.name),
289 version = escape(&self.game.version),
290 author = escape(&self.game.author),
291 source = escape(&fsutil::normalize_slashes(&self.paths.source)),
292 output = escape(&fsutil::normalize_slashes(&self.paths.output)),
293 icon = self
294 .paths
295 .icon
296 .as_ref()
297 .map(|path| format!("\"{}\"", escape(&fsutil::normalize_slashes(path))))
298 .unwrap_or_else(|| "\"\"".to_string()),
299 runtime_path = self
300 .targets
301 .web
302 .runtime_path
303 .as_ref()
304 .map(|path| format!("\"{}\"", escape(&fsutil::normalize_slashes(path))))
305 .unwrap_or_else(|| "\"\"".to_string()),
306 html_assets = format_path_array(&self.targets.web.html_assets),
307 includes = format_string_array(&self.paths.includes),
308 excludes = format_string_array(&self.paths.excludes),
309 arguments = format_string_array(&self.targets.web.arguments)
310 )
311 }
312}
313
314impl Default for TargetsConfig {
315 fn default() -> Self {
316 Self {
317 web: WebTargetConfig {
318 enabled: true,
319 variant: "web-compat".to_string(),
320 html_template: None,
321 html_assets: Vec::new(),
322 runtime_path: None,
323 memory_bytes: 67_108_864,
324 arguments: Vec::new(),
325 },
326 windows: DesktopTargetConfig {
327 enabled: true,
328 runtime_archive: None,
329 },
330 macos: DesktopTargetConfig {
331 enabled: true,
332 runtime_archive: None,
333 },
334 linux: DesktopTargetConfig {
335 enabled: true,
336 runtime_archive: None,
337 },
338 }
339 }
340}
341
342fn apply_web(config: &mut Config, values: Option<&BTreeMap<String, String>>) -> Result<()> {
343 let Some(values) = values else {
344 return Ok(());
345 };
346 config.targets.web.enabled = get_bool(values, "enabled").unwrap_or(config.targets.web.enabled);
347 config.targets.web.variant =
348 get_string(values, "variant").unwrap_or(config.targets.web.variant.clone());
349 config.targets.web.html_template = get_optional_path(
350 values,
351 "html_template",
352 config.targets.web.html_template.clone(),
353 );
354 if let Some(assets) = get_string_array(values, "html_assets")? {
355 config.targets.web.html_assets = assets.into_iter().map(PathBuf::from).collect();
356 }
357 config.targets.web.runtime_path = get_optional_path(
358 values,
359 "runtime_path",
360 config.targets.web.runtime_path.clone(),
361 );
362 if let Some(memory) = values.get("memory_bytes") {
363 config.targets.web.memory_bytes = parse_integer(memory, "targets.web.memory_bytes")?;
364 }
365 if let Some(arguments) = get_string_array(values, "arguments")? {
366 config.targets.web.arguments = arguments;
367 }
368 Ok(())
369}
370
371fn apply_desktop(config: &mut DesktopTargetConfig, values: Option<&BTreeMap<String, String>>) {
372 let Some(values) = values else {
373 return;
374 };
375 config.enabled = get_bool(values, "enabled").unwrap_or(config.enabled);
376 config.runtime_archive =
377 get_optional_path(values, "runtime_archive", config.runtime_archive.clone());
378}
379
380fn get_string(values: &BTreeMap<String, String>, key: &str) -> Option<String> {
381 let value = values.get(key)?;
382 parse_string(value).filter(|value| !value.is_empty())
383}
384
385fn get_optional_string(
386 values: &BTreeMap<String, String>,
387 key: &str,
388 previous: Option<String>,
389) -> Option<String> {
390 match values.get(key).and_then(|value| parse_string(value)) {
391 Some(value) if value.is_empty() => None,
392 Some(value) => Some(value),
393 None => previous,
394 }
395}
396
397fn get_optional_path(
398 values: &BTreeMap<String, String>,
399 key: &str,
400 previous: Option<PathBuf>,
401) -> Option<PathBuf> {
402 match values.get(key).and_then(|value| parse_string(value)) {
403 Some(value) if value.is_empty() => None,
404 Some(value) => Some(PathBuf::from(value)),
405 None => previous,
406 }
407}
408
409fn get_bool(values: &BTreeMap<String, String>, key: &str) -> Option<bool> {
410 match values.get(key)?.as_str() {
411 "true" => Some(true),
412 "false" => Some(false),
413 _ => None,
414 }
415}
416
417fn get_string_array(values: &BTreeMap<String, String>, key: &str) -> Result<Option<Vec<String>>> {
418 let Some(value) = values.get(key) else {
419 return Ok(None);
420 };
421 parse_string_array(value).map(Some)
422}
423
424fn parse_string(value: &str) -> Option<String> {
425 let value = value.trim();
426 if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
427 Some(value[1..value.len() - 1].replace("\\\"", "\""))
428 } else {
429 None
430 }
431}
432
433fn parse_integer(value: &str, field: &str) -> Result<u64> {
434 value
435 .trim()
436 .parse::<u64>()
437 .map_err(|_| LovelyError::Config(format!("{field} must be an integer")))
438}
439
440fn parse_string_array(value: &str) -> Result<Vec<String>> {
441 let value = value.trim();
442 if !(value.starts_with('[') && value.ends_with(']')) {
443 return Err(LovelyError::Config(
444 "expected an array of strings".to_string(),
445 ));
446 }
447 let inner = value[1..value.len() - 1].trim();
448 if inner.is_empty() {
449 return Ok(Vec::new());
450 }
451
452 inner
453 .split(',')
454 .map(|part| {
455 parse_string(part.trim()).ok_or_else(|| {
456 LovelyError::Config(format!("array item {part:?} is not a quoted string"))
457 })
458 })
459 .collect()
460}
461
462fn strip_comment(line: &str) -> &str {
463 let mut in_string = false;
464 for (index, ch) in line.char_indices() {
465 match ch {
466 '"' => in_string = !in_string,
467 '#' if !in_string => return &line[..index],
468 _ => {}
469 }
470 }
471 line
472}
473
474fn slugify(input: &str) -> String {
475 let mut slug = String::new();
476 let mut last_dash = false;
477 for ch in input.chars().flat_map(char::to_lowercase) {
478 if ch.is_ascii_alphanumeric() {
479 slug.push(ch);
480 last_dash = false;
481 } else if !last_dash {
482 slug.push('-');
483 last_dash = true;
484 }
485 }
486 slug.trim_matches('-').to_string()
487}
488
489fn escape(input: &str) -> String {
490 input.replace('\\', "\\\\").replace('"', "\\\"")
491}
492
493fn format_string_array(values: &[String]) -> String {
494 let values = values
495 .iter()
496 .map(|value| format!("\"{}\"", escape(value)))
497 .collect::<Vec<_>>()
498 .join(", ");
499 format!("[{values}]")
500}
501
502fn format_path_array(values: &[PathBuf]) -> String {
503 let values = values
504 .iter()
505 .map(|value| format!("\"{}\"", escape(&fsutil::normalize_slashes(value))))
506 .collect::<Vec<_>>()
507 .join(", ");
508 format!("[{values}]")
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn parses_minimal_config() {
517 let config = Config::parse(
518 r#"[game]
519id = "sailman"
520name = "Sailman"
521version = "1.2.3"
522author = "Team"
523
524[targets.web]
525variant = "web-threaded"
526runtime_path = "runtimes/web"
527html_assets = ["src/templates/logo.png"]
528arguments = ["--demo-capture"]
529
530[compatibility]
531allow_warnings = ["web.native"]
532"#,
533 )
534 .unwrap();
535
536 assert_eq!(config.game.id, "sailman");
537 assert_eq!(config.targets.web.variant, "web-threaded");
538 assert_eq!(
539 config.targets.web.runtime_path,
540 Some(PathBuf::from("runtimes/web"))
541 );
542 assert_eq!(
543 config.targets.web.html_assets,
544 vec![PathBuf::from("src/templates/logo.png")]
545 );
546 assert_eq!(config.targets.web.arguments, vec!["--demo-capture"]);
547 assert_eq!(config.compatibility.allow_warnings, vec!["web.native"]);
548 }
549
550 #[test]
551 fn parses_path_excludes() {
552 let config = Config::parse(
553 r#"[paths]
554includes = ["main.lua", "src/**"]
555excludes = ["src/dev/**"]
556"#,
557 )
558 .unwrap();
559
560 assert_eq!(config.paths.includes, vec!["main.lua", "src/**"]);
561 assert_eq!(config.paths.excludes, vec!["src/dev/**"]);
562 }
563}