Skip to main content

lovely/
config.rs

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}