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}