Skip to main content

lovely/
cli.rs

1use crate::butler::Butler;
2use crate::check;
3use crate::config::{CONFIG_FILE, Config};
4use crate::fsutil;
5use crate::lockfile::{LOCK_FILE, LockFile};
6use crate::runtime::{DEFAULT_CHANNEL, RuntimeRegistry};
7use crate::targets;
8use crate::{LovelyError, Result};
9use std::env;
10use std::path::Path;
11
12pub fn run() -> Result<()> {
13    let mut args = env::args().skip(1).collect::<Vec<_>>();
14    if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
15        print_help();
16        return Ok(());
17    }
18
19    let command = args.remove(0);
20    let root = env::current_dir().map_err(LovelyError::plain_io)?;
21    match command.as_str() {
22        "init" => init(&root),
23        "lock" => lock(&root),
24        "doctor" => doctor(&root, args.first().map(String::as_str)),
25        "check" => check_command(&root, &args),
26        "build" => build(&root, args.first().map(String::as_str).unwrap_or("all")),
27        "runtime" => runtime_command(&args),
28        "publish" => publish(&root, &args),
29        "ci" => ci(&root, args.first().map(String::as_str).unwrap_or("github")),
30        "help" => {
31            print_help();
32            Ok(())
33        }
34        other => Err(LovelyError::Command(format!(
35            "unknown command {other:?}; run lovely --help"
36        ))),
37    }
38}
39
40fn runtime_command(args: &[String]) -> Result<()> {
41    let Some(command) = args.first().map(String::as_str) else {
42        print_runtime_help();
43        return Ok(());
44    };
45    match command {
46        "fetch" => runtime_fetch(&args[1..]),
47        "doctor" => runtime_doctor(args.get(1).map(String::as_str)),
48        "list" => runtime_list(),
49        "cache-dir" => {
50            println!("{}", RuntimeRegistry::new().root().display());
51            Ok(())
52        }
53        "help" | "--help" | "-h" => {
54            print_runtime_help();
55            Ok(())
56        }
57        other => Err(LovelyError::Command(format!(
58            "unknown runtime command {other:?}; run lovely runtime help"
59        ))),
60    }
61}
62
63fn runtime_fetch(args: &[String]) -> Result<()> {
64    if args.len() < 2 {
65        return Err(LovelyError::Command(
66            "usage: lovely runtime fetch <target> <local-path> [--channel love-11-plus] [--sha256 <hex>]".to_string(),
67        ));
68    }
69
70    let target = &args[0];
71    let source = Path::new(&args[1]);
72    let mut channel = DEFAULT_CHANNEL.to_string();
73    let mut expected_sha256 = None::<String>;
74    let mut index = 2;
75    while index < args.len() {
76        match args[index].as_str() {
77            "--channel" => {
78                let Some(value) = args.get(index + 1) else {
79                    return Err(LovelyError::Command(
80                        "--channel requires a value".to_string(),
81                    ));
82                };
83                channel = value.clone();
84                index += 2;
85            }
86            "--sha256" => {
87                let Some(value) = args.get(index + 1) else {
88                    return Err(LovelyError::Command(
89                        "--sha256 requires a value".to_string(),
90                    ));
91                };
92                expected_sha256 = Some(value.clone());
93                index += 2;
94            }
95            other => {
96                return Err(LovelyError::Command(format!(
97                    "unknown runtime fetch option {other:?}"
98                )));
99            }
100        }
101    }
102
103    let registry = RuntimeRegistry::new();
104    let manifest = registry.install_local(target, &channel, source, expected_sha256.as_deref())?;
105    println!(
106        "Installed {} runtime for channel {}",
107        manifest.target, manifest.channel
108    );
109    println!("  sha256 {}", manifest.sha256);
110    println!(
111        "  path {}",
112        registry
113            .root()
114            .join(&manifest.channel)
115            .join(&manifest.target)
116            .join(&manifest.path)
117            .display()
118    );
119    Ok(())
120}
121
122fn runtime_doctor(target: Option<&str>) -> Result<()> {
123    let registry = RuntimeRegistry::new();
124    let targets = match target {
125        Some("all") | None => vec!["web", "windows", "macos", "linux"],
126        Some(target) => vec![target],
127    };
128
129    let mut missing = false;
130    for target in targets {
131        crate::runtime::validate_target(target)?;
132        match registry.find(target, DEFAULT_CHANNEL)? {
133            Some(runtime) if runtime.path.exists() => {
134                println!(
135                    "ok[{target}] {} {} {}",
136                    runtime.manifest.channel,
137                    runtime.manifest.sha256,
138                    runtime.path.display()
139                );
140            }
141            Some(runtime) => {
142                missing = true;
143                println!(
144                    "missing[{target}] manifest exists but artifact is absent: {}",
145                    runtime.path.display()
146                );
147            }
148            None => {
149                missing = true;
150                println!("missing[{target}] no {DEFAULT_CHANNEL} runtime installed");
151            }
152        }
153    }
154
155    if missing {
156        return Err(LovelyError::Command(
157            "one or more runtimes are missing".to_string(),
158        ));
159    }
160    Ok(())
161}
162
163fn runtime_list() -> Result<()> {
164    let registry = RuntimeRegistry::new();
165    let runtimes = registry.list()?;
166    if runtimes.is_empty() {
167        println!(
168            "No Lovely runtimes installed in {}",
169            registry.root().display()
170        );
171        return Ok(());
172    }
173
174    for runtime in runtimes {
175        println!(
176            "{} {} {:?} {} {}",
177            runtime.manifest.channel,
178            runtime.manifest.target,
179            runtime.manifest.kind,
180            runtime.manifest.sha256,
181            runtime.path.display()
182        );
183    }
184    Ok(())
185}
186
187fn init(root: &Path) -> Result<()> {
188    let config_path = root.join(CONFIG_FILE);
189    if config_path.exists() {
190        return Err(LovelyError::Command(format!(
191            "{} already exists",
192            config_path.display()
193        )));
194    }
195
196    let config = Config::default_for_dir(root);
197    fsutil::write_string(&config_path, &config.to_toml())?;
198    ensure_lock(root)?;
199    println!("Created {}", config_path.display());
200    println!("Created {}", root.join(LOCK_FILE).display());
201    Ok(())
202}
203
204fn lock(root: &Path) -> Result<()> {
205    let path = root.join(LOCK_FILE);
206    let lock = if path.exists() {
207        LockFile::load_from(&path)?
208    } else {
209        LockFile::preview_default()
210    };
211    fsutil::write_string(&path, &lock.to_text())?;
212    println!("Wrote {}", path.display());
213    if lock.has_unresolved_checksums() {
214        println!(
215            "Note: runtime checksums are unresolved until upstream runtime artifacts are installed or resolved."
216        );
217    }
218    Ok(())
219}
220
221fn doctor(root: &Path, target: Option<&str>) -> Result<()> {
222    let config = load_config(root)?;
223    let lock = load_lock(root)?;
224    let target = target.unwrap_or("all");
225    let mut report = check::DiagnosticReport::default();
226
227    for name in targets::expand_targets(target) {
228        let adapter = targets::adapter_for(name).ok_or_else(|| unknown_target(name))?;
229        report.extend(adapter.doctor(root, &config, &lock)?);
230    }
231
232    print!("{}", report.render());
233    if report.has_errors() {
234        return Err(LovelyError::Command(
235            "doctor found blocking issues".to_string(),
236        ));
237    }
238    Ok(())
239}
240
241fn check_command(root: &Path, args: &[String]) -> Result<()> {
242    let config = load_config(root)?;
243    let targets = if args.is_empty() {
244        Vec::new()
245    } else {
246        args.to_vec()
247    };
248    let report = check::check_project(root, &config, &targets)?;
249    print!("{}", report.render());
250    if report.has_errors() {
251        return Err(LovelyError::Command(
252            "compatibility check failed".to_string(),
253        ));
254    }
255    Ok(())
256}
257
258fn build(root: &Path, target: &str) -> Result<()> {
259    let config = load_config(root)?;
260    let lock = load_lock(root)?;
261    let expanded = targets::expand_targets(target);
262    if expanded.is_empty() {
263        return Err(unknown_target(target));
264    }
265
266    for name in expanded {
267        let adapter = targets::adapter_for(name).ok_or_else(|| unknown_target(name))?;
268        let output = adapter.build(root, &config, &lock)?;
269        println!("Built {}:", output.target);
270        for artifact in output.artifacts {
271            println!("  {}", artifact.display());
272        }
273    }
274    Ok(())
275}
276
277fn publish(root: &Path, args: &[String]) -> Result<()> {
278    let Some(provider) = args.first().map(String::as_str) else {
279        return Err(LovelyError::Command(
280            "publish requires a provider; currently supported: itch [staging|release]".to_string(),
281        ));
282    };
283    if provider != "itch" {
284        return Err(LovelyError::Command(format!(
285            "unsupported publish provider {provider:?}; currently supported: itch"
286        )));
287    }
288    if args.len() > 2 {
289        return Err(LovelyError::Command(
290            "usage: lovely publish itch [staging|release]".to_string(),
291        ));
292    }
293
294    let publish_target = PublishTarget::parse(args.get(1).map(String::as_str))?;
295    let config = load_config(root)?;
296    let Some(project) = config.itch.project.as_deref() else {
297        return Err(LovelyError::Command(
298            "itch.project must be configured before publishing".to_string(),
299        ));
300    };
301
302    let artifact = root
303        .join(&config.paths.output)
304        .join(format!("{}-web.zip", config.game.id));
305    if !artifact.is_file() {
306        return Err(LovelyError::Command(format!(
307            "{} does not exist; run lovely build web first",
308            artifact.display()
309        )));
310    }
311
312    let channel = match publish_target {
313        PublishTarget::Staging => &config.itch.prerelease_channel,
314        PublishTarget::Release => &config.itch.release_channel,
315    };
316    let destination = format!("{project}:{channel}");
317    let butler = Butler::resolve()?;
318
319    println!(
320        "Publishing {} to itch.io project {destination} with Butler at {}.",
321        artifact.display(),
322        butler.path().display()
323    );
324    butler.push(&artifact, &destination)?;
325    Ok(())
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329enum PublishTarget {
330    Staging,
331    Release,
332}
333
334impl PublishTarget {
335    fn parse(value: Option<&str>) -> Result<Self> {
336        match value.unwrap_or("staging") {
337            "staging" | "prerelease" => Ok(Self::Staging),
338            "release" | "production" => Ok(Self::Release),
339            other => Err(LovelyError::Command(format!(
340                "unknown itch publish target {other:?}; expected staging or release"
341            ))),
342        }
343    }
344}
345
346fn ci(root: &Path, provider: &str) -> Result<()> {
347    if provider != "github" {
348        return Err(LovelyError::Command(format!(
349            "unsupported CI provider {provider:?}; currently supported: github"
350        )));
351    }
352
353    let path = root.join(".github/workflows/lovely.yml");
354    fsutil::write_string(&path, github_actions())?;
355    println!("Wrote {}", path.display());
356    Ok(())
357}
358
359fn ensure_lock(root: &Path) -> Result<()> {
360    let path = root.join(LOCK_FILE);
361    if !path.exists() {
362        fsutil::write_string(&path, &LockFile::preview_default().to_text())?;
363    }
364    Ok(())
365}
366
367fn load_config(root: &Path) -> Result<Config> {
368    let path = root.join(CONFIG_FILE);
369    if !path.exists() {
370        return Err(LovelyError::Command(format!(
371            "{} not found; run lovely init",
372            path.display()
373        )));
374    }
375    Config::load_from(&path)
376}
377
378fn load_lock(root: &Path) -> Result<LockFile> {
379    let path = root.join(LOCK_FILE);
380    if !path.exists() {
381        return Err(LovelyError::Command(format!(
382            "{} not found; run lovely lock",
383            path.display()
384        )));
385    }
386    LockFile::load_from(&path)
387}
388
389fn unknown_target(target: &str) -> LovelyError {
390    LovelyError::Command(format!(
391        "unknown target {target:?}; expected web, windows, macos, linux, desktop, or all"
392    ))
393}
394
395fn github_actions() -> &'static str {
396    r#"name: Lovely
397
398on:
399  push:
400    tags:
401      - "v*"
402  workflow_dispatch:
403
404jobs:
405  web:
406    runs-on: ubuntu-latest
407    steps:
408      - uses: actions/checkout@v4
409      - uses: dtolnay/rust-toolchain@stable
410      - run: cargo build --release
411      - run: ./target/release/lovely lock
412      - run: ./target/release/lovely check web
413      - run: ./target/release/lovely build web
414      - uses: actions/upload-artifact@v4
415        with:
416          name: lovely-web
417          path: dist/*web.zip
418
419  desktop:
420    strategy:
421      matrix:
422        os: [ubuntu-latest, macos-latest, windows-latest]
423        include:
424          - os: ubuntu-latest
425            target: linux
426          - os: macos-latest
427            target: macos
428          - os: windows-latest
429            target: windows
430    runs-on: ${{ matrix.os }}
431    steps:
432      - uses: actions/checkout@v4
433      - uses: dtolnay/rust-toolchain@stable
434      - run: cargo build --release
435      - run: ./target/release/lovely check ${{ matrix.target }}
436      - run: ./target/release/lovely build ${{ matrix.target }}
437      - uses: actions/upload-artifact@v4
438        with:
439          name: lovely-${{ matrix.target }}
440          path: dist/**
441"#
442}
443
444fn print_help() {
445    println!(
446        r#"Lovely — unified LÖVE >= 11 distribution toolchain
447
448Usage:
449  lovely init
450  lovely lock
451  lovely doctor [target]
452  lovely check [target...]
453  lovely runtime <fetch|doctor|list|cache-dir>
454  lovely build [web|windows|macos|linux|desktop|all]
455  lovely publish itch [staging|release]
456  lovely ci github
457
458Targets:
459  web       Itch.io-ready web package shell using pinned LÖVE runtime metadata
460  windows   Steam-ready Windows artifact skeleton
461  macos     Steam-ready macOS artifact skeleton
462  linux     Steam-ready Linux artifact skeleton
463"#
464    );
465}
466
467fn print_runtime_help() {
468    println!(
469        r#"Lovely runtime registry
470
471Usage:
472  lovely runtime fetch <target> <local-path> [--channel love-11-plus] [--sha256 <hex>]
473  lovely runtime doctor [target|all]
474  lovely runtime list
475  lovely runtime cache-dir
476
477Targets:
478  web windows macos linux
479
480Notes:
481  `fetch` currently installs a local runtime file or directory into the Lovely
482  cache. URL fetching should resolve official upstream or vendor-provided
483  runtime artifacts into this same cache; Lovely should not need to host them.
484"#
485    );
486}