1use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::Write;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Result, anyhow};
11use clap::{Parser, Subcommand, ValueHint};
12use colored::Colorize;
13use serde_json::Value;
14
15#[derive(Parser, Debug)]
17pub struct MigrateCommand {
18 #[command(subcommand)]
20 command: Commands
21}
22
23#[derive(Subcommand, Debug)]
25enum Commands {
26 Scoop(ScoopCommand)
28}
29
30#[derive(Parser, Debug)]
32pub struct ScoopCommand {
33 #[arg(required = true, value_hint = ValueHint::FilePath)]
35 input: PathBuf,
36 #[arg(long, short = 'o', value_hint = ValueHint::FilePath)]
38 output: Option<PathBuf>,
39 #[arg(long, default_value = "community")]
41 repo: String,
42 #[arg(long)]
44 name: Option<String>,
45 #[arg(long)]
47 version: Option<String>,
48 #[arg(long, default_value = "Scoop Migration")]
50 maintainer_name: String,
51 #[arg(long, default_value = "noreply@example.com")]
53 maintainer_email: String,
54 #[arg(long)]
56 dry_run: bool
57}
58
59#[derive(Debug, Clone)]
61struct BinMapping {
62 source: String,
64 target: String,
66 args: Vec<String>
68}
69
70#[derive(Debug, Clone)]
72struct DownloadSpec {
73 arch_key: String,
75 urls: Vec<String>,
77 hashes: Vec<String>,
79 extract_dir: Option<String>,
81 extract_to: Option<String>
83}
84
85#[derive(Debug, Clone, Default)]
87struct HookScripts {
88 pre_install: Vec<String>,
90 post_install: Vec<String>,
92 pre_remove: Vec<String>,
94 post_remove: Vec<String>
96}
97
98#[derive(Debug, Clone)]
100struct Generated {
101 name: String,
103 repo: String,
105 version: String,
107 description: String,
109 website: String,
111 license: String,
113 maintainer_name: String,
115 maintainer_email: String,
117 bins: Vec<BinMapping>,
119 runtime_deps: Vec<String>,
121 backups: Vec<String>,
123 downloads: Vec<DownloadSpec>,
125 hooks: HookScripts,
127 notes: Vec<String>,
129 manifest: Value
131}
132
133pub fn run(args: MigrateCommand) -> Result<()> {
140 match args.command {
141 Commands::Scoop(cmd) => run_scoop(&cmd)
142 }
143}
144
145fn run_scoop(args: &ScoopCommand) -> Result<()> {
147 let content = std::fs::read_to_string(&args.input).map_err(|e| {
148 anyhow!("Failed to read '{}': {}", args.input.display(), e)
149 })?;
150 let manifest: Value = json5::from_str(&content).map_err(|e| {
151 anyhow!(
152 "Failed to parse Scoop manifest '{}': {}",
153 args.input.display(),
154 e
155 )
156 })?;
157 let manifest_obj = manifest
158 .as_object()
159 .ok_or_else(|| anyhow!("Scoop manifest root must be a JSON object."))?;
160
161 let default_name = derive_name_from_path(&args.input)?;
162 let name = normalize_name(args.name.as_deref().unwrap_or(&default_name));
163 if name.is_empty() {
164 return Err(anyhow!("Resolved package name is empty."));
165 }
166
167 let version = args
168 .version
169 .clone()
170 .or_else(|| get_string_field(manifest_obj, "version"))
171 .unwrap_or_else(|| "0.0.0".to_string());
172 let description = get_string_field(manifest_obj, "description")
173 .unwrap_or_else(|| format!("Migrated from Scoop: {name}"));
174 let website = get_string_field(manifest_obj, "homepage")
175 .unwrap_or_else(|| "https://scoop.sh/".to_string());
176 let license = get_license_field(manifest_obj)
177 .unwrap_or_else(|| "NOASSERTION".to_string());
178
179 let runtime_deps = parse_depends(manifest_obj.get("depends"));
180 let bins = collect_bins(manifest_obj);
181 let backups = parse_persist(manifest_obj.get("persist"));
182 let downloads = collect_download_specs(manifest_obj)?;
183 let hooks = collect_hook_scripts(manifest_obj);
184 let notes = collect_migration_notes(manifest_obj, &bins, &downloads);
185
186 let generated = Generated {
187 name: name.clone(),
188 repo: args.repo.clone(),
189 version,
190 description,
191 website,
192 license,
193 maintainer_name: args.maintainer_name.clone(),
194 maintainer_email: args.maintainer_email.clone(),
195 bins,
196 runtime_deps,
197 backups,
198 downloads,
199 hooks,
200 notes,
201 manifest: manifest.clone()
202 };
203
204 let lua = render_pkg_lua(&generated);
205 if args.dry_run {
206 println!("{lua}");
207 return Ok(());
208 }
209
210 let output = args
211 .output
212 .clone()
213 .unwrap_or_else(|| default_output_path(&args.input, &name));
214 std::fs::write(&output, lua).map_err(|e| {
215 anyhow!("Failed to write '{}': {}", output.display(), e)
216 })?;
217
218 println!(
219 "{} Generated {} from {}.",
220 "::".bold().green(),
221 output.display().to_string().cyan(),
222 args.input.display().to_string().cyan()
223 );
224
225 Ok(())
226}
227
228fn derive_name_from_path(path: &Path) -> Result<String> {
230 let stem = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
231 anyhow!("Failed to derive package name from '{}'.", path.display())
232 })?;
233 Ok(stem.to_string())
234}
235
236fn default_output_path(input: &Path, name: &str) -> PathBuf {
238 match input.parent() {
239 Some(parent) => parent.join(format!("{name}.pkg.lua")),
240 None => PathBuf::from(format!("{name}.pkg.lua"))
241 }
242}
243
244fn normalize_name(name: &str) -> String {
247 name.trim().to_lowercase().replace(' ', "-")
248}
249
250fn get_string_field(
252 map: &serde_json::Map<String, Value>,
253 key: &str
254) -> Option<String> {
255 map.get(key)
256 .and_then(Value::as_str)
257 .map(|s| s.trim().to_string())
258 .filter(|s| !s.is_empty())
259}
260
261fn get_license_field(map: &serde_json::Map<String, Value>) -> Option<String> {
264 let value = map.get("license")?;
265 if let Some(s) = value.as_str() {
266 let trimmed = s.trim();
267 if !trimmed.is_empty() {
268 return Some(trimmed.to_string());
269 }
270 }
271 if let Some(obj) = value.as_object() {
272 for key in ["identifier", "id", "name"] {
273 if let Some(v) = obj.get(key).and_then(Value::as_str) {
274 let trimmed = v.trim();
275 if !trimmed.is_empty() {
276 return Some(trimmed.to_string());
277 }
278 }
279 }
280 }
281 None
282}
283
284fn value_to_string_list(value: Option<&Value>) -> Vec<String> {
286 let Some(v) = value else {
287 return Vec::new();
288 };
289
290 match v {
291 Value::String(s) => {
292 let t = s.trim();
293 if t.is_empty() {
294 Vec::new()
295 } else {
296 vec![t.to_string()]
297 }
298 }
299 Value::Array(items) => items
300 .iter()
301 .filter_map(|item| match item {
302 Value::String(s) => Some(s.trim().to_string()),
303 Value::Number(n) => Some(n.to_string()),
304 Value::Bool(b) => Some(b.to_string()),
305 _ => None
306 })
307 .filter(|s| !s.is_empty())
308 .collect(),
309 _ => Vec::new()
310 }
311}
312
313fn parse_depends(depends: Option<&Value>) -> Vec<String> {
315 value_to_string_list(depends)
316 .into_iter()
317 .map(|dep| {
318 if dep.contains(':') {
319 dep
320 } else {
321 format!("scoop:{dep}")
322 }
323 })
324 .collect()
325}
326
327fn parse_persist(persist: Option<&Value>) -> Vec<String> {
329 let Some(value) = persist else {
330 return Vec::new();
331 };
332 let mut result = Vec::new();
333
334 match value {
335 Value::String(s) => {
336 let t = s.trim();
337 if !t.is_empty() {
338 result.push(t.to_string());
339 }
340 }
341 Value::Array(items) => {
342 for item in items {
343 match item {
344 Value::String(s) => {
345 let t = s.trim();
346 if !t.is_empty() {
347 result.push(t.to_string());
348 }
349 }
350 Value::Array(arr) => {
351 if let Some(first) = arr.first().and_then(Value::as_str)
352 {
353 let t = first.trim();
354 if !t.is_empty() {
355 result.push(t.to_string());
356 }
357 }
358 }
359 _ => {}
360 }
361 }
362 }
363 _ => {}
364 }
365
366 result
367}
368
369fn collect_bins(manifest: &serde_json::Map<String, Value>) -> Vec<BinMapping> {
371 let mut all = Vec::new();
372 all.extend(parse_bin_value(manifest.get("bin")));
373
374 if let Some(arch_obj) =
375 manifest.get("architecture").and_then(Value::as_object)
376 {
377 let mut keys: Vec<_> = arch_obj.keys().cloned().collect();
378 keys.sort();
379 for key in keys {
380 if let Some(bin_val) = arch_obj
381 .get(&key)
382 .and_then(Value::as_object)
383 .and_then(|o| o.get("bin"))
384 {
385 all.extend(parse_bin_value(Some(bin_val)));
386 }
387 }
388 }
389
390 let mut seen = BTreeSet::new();
391 all.into_iter()
392 .filter(|bin| seen.insert((bin.source.clone(), bin.target.clone())))
393 .collect()
394}
395
396fn parse_bin_value(value: Option<&Value>) -> Vec<BinMapping> {
398 let Some(value) = value else {
399 return Vec::new();
400 };
401
402 let mut mappings = Vec::new();
403 match value {
404 Value::String(path) => {
405 let src = path.trim();
406 if !src.is_empty() {
407 let target = file_stem_or_basename(src);
408 mappings.push(BinMapping {
409 source: src.to_string(),
410 target,
411 args: Vec::new()
412 });
413 }
414 }
415 Value::Array(items) => {
416 let all_strings = items.iter().all(serde_json::Value::is_string);
417 if all_strings && items.len() >= 2 {
418 let src = items
419 .first()
420 .and_then(Value::as_str)
421 .unwrap_or_default()
422 .trim()
423 .to_string();
424 if !src.is_empty() {
425 let alias = items
426 .get(1)
427 .and_then(Value::as_str)
428 .map(|s| s.trim().to_string())
429 .filter(|s| !s.is_empty())
430 .unwrap_or_else(|| file_stem_or_basename(&src));
431 let args = items
432 .iter()
433 .skip(2)
434 .filter_map(Value::as_str)
435 .map(|s| s.trim().to_string())
436 .filter(|s| !s.is_empty())
437 .collect();
438 mappings.push(BinMapping {
439 source: src,
440 target: alias,
441 args
442 });
443 }
444 return mappings;
445 }
446
447 for item in items {
448 mappings.extend(parse_bin_value(Some(item)));
449 }
450 }
451 _ => {}
452 }
453
454 mappings
455}
456
457fn file_stem_or_basename(path: &str) -> String {
460 let normalized = path.replace('\\', "/");
461 let base = normalized.rsplit('/').next().unwrap_or(path).trim();
462 let lower = base.to_lowercase();
463
464 for ext in [".exe", ".cmd", ".bat", ".ps1"] {
465 if lower.ends_with(ext) {
466 return base[..base.len() - ext.len()].to_string();
467 }
468 }
469 if let Some((stem, _)) = base.rsplit_once('.') {
470 return stem.to_string();
471 }
472 base.to_string()
473}
474
475fn collect_download_specs(
477 map: &serde_json::Map<String, Value>
478) -> Result<Vec<DownloadSpec>> {
479 let root_urls = value_to_string_list(map.get("url"));
480 let root_hashes = value_to_string_list(map.get("hash"));
481 let root_extract_dir = get_string_field(map, "extract_dir");
482 let root_extract_to = get_string_field(map, "extract_to");
483
484 let mut specs = Vec::new();
485
486 if !root_urls.is_empty() {
487 specs.push(DownloadSpec {
488 arch_key: "default".to_string(),
489 urls: root_urls.clone(),
490 hashes: root_hashes.clone(),
491 extract_dir: root_extract_dir.clone(),
492 extract_to: root_extract_to.clone()
493 });
494 }
495
496 if let Some(arch) = map.get("architecture").and_then(Value::as_object) {
497 let mut keys: Vec<_> = arch.keys().cloned().collect();
498 keys.sort();
499
500 for key in keys {
501 let arch_data =
502 arch.get(&key).and_then(Value::as_object).ok_or_else(|| {
503 anyhow!("architecture.{key} must be an object.")
504 })?;
505
506 let urls = value_to_string_list(arch_data.get("url"));
507 let hashes = value_to_string_list(arch_data.get("hash"));
508 let extract_dir = get_string_field(arch_data, "extract_dir");
509 let extract_to = get_string_field(arch_data, "extract_to");
510
511 let merged_urls = if urls.is_empty() {
512 root_urls.clone()
513 } else {
514 urls
515 };
516 let merged_hashes = if hashes.is_empty() {
517 root_hashes.clone()
518 } else {
519 hashes
520 };
521 let merged_extract_dir = extract_dir.or(root_extract_dir.clone());
522 let merged_extract_to = extract_to.or(root_extract_to.clone());
523
524 if !merged_urls.is_empty() {
525 specs.push(DownloadSpec {
526 arch_key: key,
527 urls: merged_urls,
528 hashes: merged_hashes,
529 extract_dir: merged_extract_dir,
530 extract_to: merged_extract_to
531 });
532 }
533 }
534 }
535
536 if specs.is_empty() {
537 return Err(anyhow!(
538 "Scoop manifest is missing download URL fields (`url` or \
539 `architecture.<arch>.url`)."
540 ));
541 }
542
543 let mut by_key = BTreeMap::<String, DownloadSpec>::new();
544 for spec in specs {
545 by_key.insert(spec.arch_key.clone(), spec);
546 }
547
548 Ok(by_key.into_values().collect())
549}
550
551fn collect_hook_scripts(map: &serde_json::Map<String, Value>) -> HookScripts {
553 let mut hooks = HookScripts::default();
554
555 hooks
556 .pre_install
557 .extend(value_to_string_list(map.get("pre_install")));
558 hooks
559 .post_install
560 .extend(value_to_string_list(map.get("post_install")));
561 hooks
562 .pre_remove
563 .extend(value_to_string_list(map.get("pre_uninstall")));
564 hooks
565 .post_remove
566 .extend(value_to_string_list(map.get("post_uninstall")));
567
568 if let Some(installer_obj) = map.get("installer").and_then(Value::as_object)
569 {
570 hooks
571 .post_install
572 .extend(value_to_string_list(installer_obj.get("script")));
573 }
574 if let Some(uninstaller_obj) =
575 map.get("uninstaller").and_then(Value::as_object)
576 {
577 hooks
578 .pre_remove
579 .extend(value_to_string_list(uninstaller_obj.get("script")));
580 }
581
582 if let Some(arch) = map.get("architecture").and_then(Value::as_object) {
583 for arch_data in arch.values().filter_map(Value::as_object) {
584 hooks
585 .pre_install
586 .extend(value_to_string_list(arch_data.get("pre_install")));
587 hooks
588 .post_install
589 .extend(value_to_string_list(arch_data.get("post_install")));
590 hooks
591 .pre_remove
592 .extend(value_to_string_list(arch_data.get("pre_uninstall")));
593 hooks
594 .post_remove
595 .extend(value_to_string_list(arch_data.get("post_uninstall")));
596
597 if let Some(installer_obj) =
598 arch_data.get("installer").and_then(Value::as_object)
599 {
600 hooks
601 .post_install
602 .extend(value_to_string_list(installer_obj.get("script")));
603 }
604 if let Some(uninstaller_obj) =
605 arch_data.get("uninstaller").and_then(Value::as_object)
606 {
607 hooks.pre_remove.extend(value_to_string_list(
608 uninstaller_obj.get("script")
609 ));
610 }
611 }
612 }
613
614 dedupe_strings(&mut hooks.pre_install);
615 dedupe_strings(&mut hooks.post_install);
616 dedupe_strings(&mut hooks.pre_remove);
617 dedupe_strings(&mut hooks.post_remove);
618 hooks
619}
620
621fn dedupe_strings(values: &mut Vec<String>) {
623 let mut seen = BTreeSet::new();
624 values.retain(|v| seen.insert(v.clone()));
625}
626
627fn collect_migration_notes(
629 manifest: &serde_json::Map<String, Value>,
630 bins: &[BinMapping],
631 downloads: &[DownloadSpec]
632) -> Vec<String> {
633 let mut notes = Vec::new();
634
635 if manifest.get("checkver").is_some() {
636 notes.push(
637 "`checkver` is preserved in `SCOOP_MANIFEST` for maintainer \
638 automation, but has no direct runtime equivalent in Zoi."
639 .to_string()
640 );
641 }
642 if manifest.get("autoupdate").is_some() {
643 notes.push(
644 "`autoupdate` is preserved in `SCOOP_MANIFEST`; update automation \
645 remains a maintainer workflow."
646 .to_string()
647 );
648 }
649 if manifest.get("shortcuts").is_some() {
650 notes.push(
651 "`shortcuts` are preserved in `SCOOP_MANIFEST`; shortcut creation \
652 is not auto-generated in this migration."
653 .to_string()
654 );
655 }
656 if manifest.get("env_set").is_some()
657 || manifest.get("env_add_path").is_some()
658 {
659 notes.push(
660 "Environment modifications (`env_set` / `env_add_path`) are \
661 preserved in `SCOOP_MANIFEST` and should be reviewed manually."
662 .to_string()
663 );
664 }
665 if manifest.get("suggest").is_some() {
666 notes.push(
667 "`suggest` is preserved in `SCOOP_MANIFEST` for manual review; it \
668 is not mapped to hard dependencies."
669 .to_string()
670 );
671 }
672 if manifest.get("psmodule").is_some() {
673 notes.push(
674 "`psmodule` is preserved in `SCOOP_MANIFEST`; validate module \
675 installation behavior manually."
676 .to_string()
677 );
678 }
679 if manifest.get("innosetup").is_some() || manifest.get("msi").is_some() {
680 notes.push(
681 "Installer metadata (`innosetup` / `msi`) is preserved in \
682 `SCOOP_MANIFEST`; verify installer flow manually."
683 .to_string()
684 );
685 }
686 if let Some(installer) =
687 manifest.get("installer").and_then(Value::as_object)
688 && (installer.get("file").is_some() || installer.get("args").is_some())
689 {
690 notes.push(
691 "`installer.file` / `installer.args` are preserved in \
692 `SCOOP_MANIFEST`; they are not auto-translated into package() \
693 steps."
694 .to_string()
695 );
696 }
697 if let Some(uninstaller) =
698 manifest.get("uninstaller").and_then(Value::as_object)
699 && (uninstaller.get("file").is_some()
700 || uninstaller.get("args").is_some())
701 {
702 notes.push(
703 "`uninstaller.file` / `uninstaller.args` are preserved in \
704 `SCOOP_MANIFEST`; they are not auto-translated into uninstall \
705 hooks."
706 .to_string()
707 );
708 }
709 if bins.iter().any(|b| !b.args.is_empty()) {
710 notes.push(
711 "Bin shim extra args are preserved in BIN_MAP but not \
712 automatically applied in generated `bins` metadata."
713 .to_string()
714 );
715 }
716 if downloads.len() > 1 {
717 notes.push(
718 "Multiple architecture/source entries detected. Review generated \
719 arch selection and extraction paths."
720 .to_string()
721 );
722 }
723 if manifest.get("cookie").is_some() {
724 notes.push(
725 "`cookie` is preserved in `SCOOP_MANIFEST`; authenticated \
726 download behavior may require manual adaptation."
727 .to_string()
728 );
729 }
730 if notes.is_empty() {
731 notes.push(
732 "No major migration warnings detected. Review generated paths and \
733 hooks before publishing."
734 .to_string()
735 );
736 }
737
738 notes
739}
740
741fn render_pkg_lua(g: &Generated) -> String {
743 use std::fmt::Write;
744 let mut out = String::new();
745
746 out.push_str("-- Generated by `zoi migrate scoop`\n");
747 out.push_str("-- This attempts broad Scoop manifest coverage.\n");
748 out.push_str("-- Review generated behavior before publishing.\n");
749 out.push_str("-- Migration notes:\n");
750 for note in &g.notes {
751 let _ = writeln!(out, "-- - {note}");
752 }
753 out.push('\n');
754
755 out.push_str("local SCOOP_MANIFEST = ");
756 out.push_str(&json_to_lua(&g.manifest, 0));
757 out.push_str("\n\n");
758
759 out.push_str("local DOWNLOADS = {\n");
760 for spec in &g.downloads {
761 let _ = writeln!(out, " [{}] = {{", lua_quote(&spec.arch_key));
762 out.push_str(" urls = {");
763 for (idx, url) in spec.urls.iter().enumerate() {
764 if idx > 0 {
765 out.push_str(", ");
766 }
767 out.push_str(&lua_quote(url));
768 }
769 out.push_str("},\n");
770
771 out.push_str(" hashes = {");
772 for (idx, hash) in spec.hashes.iter().enumerate() {
773 if idx > 0 {
774 out.push_str(", ");
775 }
776 out.push_str(&lua_quote(hash));
777 }
778 out.push_str("},\n");
779
780 let _ = writeln!(
781 out,
782 " extract_dir = {},",
783 lua_optional(spec.extract_dir.as_deref())
784 );
785 let _ = writeln!(
786 out,
787 " extract_to = {},",
788 lua_optional(spec.extract_to.as_deref())
789 );
790 out.push_str(" },\n");
791 }
792 out.push_str("}\n\n");
793
794 out.push_str("local BIN_MAP = {\n");
795 for bin in &g.bins {
796 out.push_str(" {\n");
797 let _ = writeln!(out, " source = {},", lua_quote(&bin.source));
798 let _ = writeln!(out, " target = {},", lua_quote(&bin.target));
799 out.push_str(" args = {");
800 for (idx, arg) in bin.args.iter().enumerate() {
801 if idx > 0 {
802 out.push_str(", ");
803 }
804 out.push_str(&lua_quote(arg));
805 }
806 out.push_str("},\n");
807 out.push_str(" },\n");
808 }
809 out.push_str("}\n\n");
810
811 out.push_str("local RUNTIME_DEPS = {");
812 for (idx, dep) in g.runtime_deps.iter().enumerate() {
813 if idx > 0 {
814 out.push_str(", ");
815 }
816 out.push_str(&lua_quote(dep));
817 }
818 out.push_str("}\n\n");
819
820 out.push_str("local HOOK_SCRIPTS = {\n");
821 out.push_str(" pre_install = {");
822 for (idx, line) in g.hooks.pre_install.iter().enumerate() {
823 if idx > 0 {
824 out.push_str(", ");
825 }
826 out.push_str(&lua_quote(line));
827 }
828 out.push_str("},\n");
829 out.push_str(" post_install = {");
830 for (idx, line) in g.hooks.post_install.iter().enumerate() {
831 if idx > 0 {
832 out.push_str(", ");
833 }
834 out.push_str(&lua_quote(line));
835 }
836 out.push_str("},\n");
837 out.push_str(" pre_remove = {");
838 for (idx, line) in g.hooks.pre_remove.iter().enumerate() {
839 if idx > 0 {
840 out.push_str(", ");
841 }
842 out.push_str(&lua_quote(line));
843 }
844 out.push_str("},\n");
845 out.push_str(" post_remove = {");
846 for (idx, line) in g.hooks.post_remove.iter().enumerate() {
847 if idx > 0 {
848 out.push_str(", ");
849 }
850 out.push_str(&lua_quote(line));
851 }
852 out.push_str("},\n");
853 out.push_str("}\n\n");
854
855 out.push_str("local function scoop_arch_key()\n");
856 out.push_str(" if SYSTEM.ARCH == \"amd64\" then return \"64bit\" end\n");
857 out.push_str(" if SYSTEM.ARCH == \"arm64\" then return \"arm64\" end\n");
858 out.push_str(
859 " if SYSTEM.ARCH == \"386\" or SYSTEM.ARCH == \"i386\" then return \
860 \"32bit\" end\n"
861 );
862 out.push_str(" return SYSTEM.ARCH\n");
863 out.push_str("end\n\n");
864
865 out.push_str("local function active_download()\n");
866 out.push_str(" local arch = scoop_arch_key()\n");
867 out.push_str(" if DOWNLOADS[arch] then return DOWNLOADS[arch] end\n");
868 out.push_str(" if DOWNLOADS.default then return DOWNLOADS.default end\n");
869 out.push_str(
870 " for _, key in ipairs({ \"64bit\", \"32bit\", \"arm64\" }) do\n"
871 );
872 out.push_str(" if DOWNLOADS[key] then return DOWNLOADS[key] end\n");
873 out.push_str(" end\n");
874 out.push_str(" for _, value in pairs(DOWNLOADS) do\n");
875 out.push_str(" return value\n");
876 out.push_str(" end\n");
877 out.push_str(" return nil\n");
878 out.push_str("end\n\n");
879
880 out.push_str("local function powershell_cmd(line)\n");
881 out.push_str(
882 " return \"powershell -NoProfile -ExecutionPolicy Bypass -Command \" \
883 .. string.format(\"%q\", line)\n"
884 );
885 out.push_str("end\n\n");
886
887 out.push_str("local function register_hooks()\n");
888 out.push_str(" local generated = {}\n");
889 out.push_str(" local function add(name, lines)\n");
890 out.push_str(" if not lines or #lines == 0 then return end\n");
891 out.push_str(" generated[name] = generated[name] or {}\n");
892 out.push_str(
893 " generated[name].windows = generated[name].windows or {}\n"
894 );
895 out.push_str(" for _, line in ipairs(lines) do\n");
896 out.push_str(
897 " table.insert(generated[name].windows, powershell_cmd(line))\n"
898 );
899 out.push_str(" end\n");
900 out.push_str(" end\n");
901 out.push_str(" add(\"pre_install\", HOOK_SCRIPTS.pre_install)\n");
902 out.push_str(" add(\"post_install\", HOOK_SCRIPTS.post_install)\n");
903 out.push_str(" add(\"pre_remove\", HOOK_SCRIPTS.pre_remove)\n");
904 out.push_str(" add(\"post_remove\", HOOK_SCRIPTS.post_remove)\n");
905 out.push_str(" if next(generated) then hooks(generated) end\n");
906 out.push_str("end\n\n");
907 out.push_str("register_hooks()\n\n");
908
909 out.push_str("local function split_hash(hash)\n");
910 out.push_str(" if not hash or hash == \"\" then return nil, nil end\n");
911 out.push_str(
912 " local algo, digest = \
913 hash:match(\"^(sha512|sha256|sha1)[:%-](.+)$\")\n"
914 );
915 out.push_str(" if algo and digest then return algo, digest end\n");
916 out.push_str(" return \"sha256\", hash\n");
917 out.push_str("end\n\n");
918
919 out.push_str("local function sanitize_url_file_name(url)\n");
920 out.push_str(" if not url then return nil end\n");
921 out.push_str(" local cleaned = url:gsub(\"#.*$\", \"\")\n");
922 out.push_str(" return cleaned:match(\"([^/]+)$\")\n");
923 out.push_str("end\n\n");
924
925 out.push_str("local function source_roots_for_download(dl)\n");
926 out.push_str(" local roots = {}\n");
927 out.push_str(" local base_roots = {}\n");
928 out.push_str(" if dl.extract_to and dl.extract_to ~= \"\" then\n");
929 out.push_str(" table.insert(base_roots, dl.extract_to)\n");
930 out.push_str(" elseif #dl.urls <= 1 then\n");
931 out.push_str(" table.insert(base_roots, \"source\")\n");
932 out.push_str(" else\n");
933 out.push_str(
934 " for i = 1, #dl.urls do table.insert(base_roots, \"source_\" .. \
935 i) end\n"
936 );
937 out.push_str(" end\n");
938 out.push_str(
939 " for _, root in ipairs(base_roots) do table.insert(roots, root) \
940 end\n"
941 );
942 out.push_str(" if dl.extract_dir and dl.extract_dir ~= \"\" then\n");
943 out.push_str(
944 " for _, root in ipairs(base_roots) do table.insert(roots, root .. \
945 \"/\" .. dl.extract_dir) end\n"
946 );
947 out.push_str(" end\n");
948 out.push_str(" return roots\n");
949 out.push_str("end\n\n");
950
951 out.push_str("local function resolve_bin_source(bin_source, roots)\n");
952 out.push_str(" for _, root in ipairs(roots) do\n");
953 out.push_str(" local candidate = root .. \"/\" .. bin_source\n");
954 out.push_str(
955 " if UTILS.FS.exists(candidate) then return candidate end\n"
956 );
957 out.push_str(" local file_name = bin_source:match(\"([^/\\\\]+)$\")\n");
958 out.push_str(" if file_name then\n");
959 out.push_str(" local found = UTILS.FIND.file(root, file_name)\n");
960 out.push_str(
961 " if found and UTILS.FS.exists(found) then return found end\n"
962 );
963 out.push_str(" end\n");
964 out.push_str(" end\n");
965 out.push_str(" return nil\n");
966 out.push_str("end\n\n");
967
968 out.push_str("metadata({\n");
969 let _ = writeln!(out, " name = {},", lua_quote(&g.name));
970 let _ = writeln!(out, " repo = {},", lua_quote(&g.repo));
971 let _ = writeln!(out, " version = {},", lua_quote(&g.version));
972 let _ = writeln!(out, " description = {},", lua_quote(&g.description));
973 let _ = writeln!(out, " website = {},", lua_quote(&g.website));
974 let _ = writeln!(out, " license = {},", lua_quote(&g.license));
975 let _ = writeln!(
976 out,
977 " maintainer = {{ name = {}, email = {} }},",
978 lua_quote(&g.maintainer_name),
979 lua_quote(&g.maintainer_email)
980 );
981 out.push_str(" types = { \"pre-compiled\" },\n");
982 out.push_str(" bins = {");
983 for (idx, bin) in g.bins.iter().enumerate() {
984 if idx > 0 {
985 out.push_str(", ");
986 }
987 out.push_str(&lua_quote(&bin.target));
988 }
989 out.push_str(" },\n");
990 if !g.backups.is_empty() {
991 out.push_str(" backup = {");
992 for (idx, path) in g.backups.iter().enumerate() {
993 if idx > 0 {
994 out.push_str(", ");
995 }
996 out.push_str(&lua_quote(path));
997 }
998 out.push_str(" },\n");
999 }
1000 out.push_str("})\n\n");
1001
1002 if !g.runtime_deps.is_empty() {
1003 out.push_str("dependencies({\n");
1004 out.push_str(" runtime = {\n");
1005 out.push_str(" required = RUNTIME_DEPS,\n");
1006 out.push_str(" }\n");
1007 out.push_str("})\n\n");
1008 }
1009
1010 out.push_str("function prepare()\n");
1011 out.push_str(" local dl = active_download()\n");
1012 out.push_str(
1013 " if not dl then error(\"No download source matched current \
1014 architecture\") end\n"
1015 );
1016 out.push_str(" for i, url in ipairs(dl.urls) do\n");
1017 out.push_str(" local out_dir\n");
1018 out.push_str(" if dl.extract_to and dl.extract_to ~= \"\" then\n");
1019 out.push_str(" out_dir = dl.extract_to\n");
1020 out.push_str(" elseif #dl.urls <= 1 then\n");
1021 out.push_str(" out_dir = \"source\"\n");
1022 out.push_str(" else\n");
1023 out.push_str(" out_dir = \"source_\" .. i\n");
1024 out.push_str(" end\n");
1025 out.push_str(" UTILS.EXTRACT(url, out_dir)\n");
1026 out.push_str(" end\n");
1027 out.push_str("end\n\n");
1028
1029 out.push_str("function package()\n");
1030 out.push_str(" local dl = active_download()\n");
1031 out.push_str(
1032 " if not dl then error(\"No download source matched current \
1033 architecture\") end\n"
1034 );
1035 out.push_str(" local roots = source_roots_for_download(dl)\n");
1036 out.push_str(" for _, bin in ipairs(BIN_MAP) do\n");
1037 out.push_str(
1038 " local source_path = resolve_bin_source(bin.source, roots)\n"
1039 );
1040 out.push_str(" if not source_path then\n");
1041 out.push_str(
1042 " error(\"Could not locate bin source in extracted files: \" .. \
1043 bin.source)\n"
1044 );
1045 out.push_str(" end\n");
1046 out.push_str(" zcp(source_path, \"${pkgstore}/bin/\" .. bin.target)\n");
1047 out.push_str(" end\n");
1048 out.push_str("end\n\n");
1049
1050 out.push_str("function verify()\n");
1051 out.push_str(" local dl = active_download()\n");
1052 out.push_str(" if not dl then return true end\n");
1053 out.push_str(" local roots = source_roots_for_download(dl)\n");
1054 out.push_str(" for i, hash in ipairs(dl.hashes or {}) do\n");
1055 out.push_str(" local algo, digest = split_hash(hash)\n");
1056 out.push_str(" if algo and digest then\n");
1057 out.push_str(" local url = dl.urls[i] or dl.urls[1]\n");
1058 out.push_str(" local file_name = sanitize_url_file_name(url)\n");
1059 out.push_str(" local file_path = nil\n");
1060 out.push_str(" if file_name then\n");
1061 out.push_str(" for _, root in ipairs(roots) do\n");
1062 out.push_str(" local candidate = root .. \"/\" .. file_name\n");
1063 out.push_str(" if UTILS.FS.exists(candidate) then\n");
1064 out.push_str(" file_path = candidate\n");
1065 out.push_str(" break\n");
1066 out.push_str(" end\n");
1067 out.push_str(" local found = UTILS.FIND.file(root, file_name)\n");
1068 out.push_str(" if found and UTILS.FS.exists(found) then\n");
1069 out.push_str(" file_path = found\n");
1070 out.push_str(" break\n");
1071 out.push_str(" end\n");
1072 out.push_str(" end\n");
1073 out.push_str(" end\n");
1074 out.push_str(" if file_path then\n");
1075 out.push_str(" verifyHash(file_path, algo .. \"-\" .. digest)\n");
1076 out.push_str(" end\n");
1077 out.push_str(" end\n");
1078 out.push_str(" end\n");
1079 out.push_str(" return true\n");
1080 out.push_str("end\n");
1081
1082 out
1083}
1084
1085fn lua_quote(value: &str) -> String {
1087 let escaped = value
1088 .replace('\\', "\\\\")
1089 .replace('"', "\\\"")
1090 .replace('\n', "\\n");
1091 format!("\"{escaped}\"")
1092}
1093
1094fn lua_optional(value: Option<&str>) -> String {
1096 match value {
1097 Some(v) if !v.trim().is_empty() => lua_quote(v),
1098 _ => "nil".to_string()
1099 }
1100}
1101
1102fn json_to_lua(value: &Value, indent: usize) -> String {
1104 match value {
1105 Value::Null => "nil".to_string(),
1106 Value::Bool(b) => {
1107 if *b {
1108 "true".to_string()
1109 } else {
1110 "false".to_string()
1111 }
1112 }
1113 Value::Number(n) => n.to_string(),
1114 Value::String(s) => lua_quote(s),
1115 Value::Array(arr) => {
1116 if arr.is_empty() {
1117 return "{}".to_string();
1118 }
1119 let mut out = String::new();
1120 out.push_str("{\n");
1121 let next_indent = indent + 2;
1122 for item in arr {
1123 out.push_str(&" ".repeat(next_indent));
1124 out.push_str(&json_to_lua(item, next_indent));
1125 out.push_str(",\n");
1126 }
1127 out.push_str(&" ".repeat(indent));
1128 out.push('}');
1129 out
1130 }
1131 Value::Object(map) => {
1132 if map.is_empty() {
1133 return "{}".to_string();
1134 }
1135 let mut keys: Vec<_> = map.keys().cloned().collect();
1136 keys.sort();
1137
1138 let mut out = String::new();
1139 out.push_str("{\n");
1140 let next_indent = indent + 2;
1141 for key in keys {
1142 if let Some(v) = map.get(&key) {
1143 out.push_str(&" ".repeat(next_indent));
1144 let _ = write!(out, "[{}] = ", lua_quote(&key));
1145 out.push_str(&json_to_lua(v, next_indent));
1146 out.push_str(",\n");
1147 }
1148 }
1149 out.push_str(&" ".repeat(indent));
1150 out.push('}');
1151 out
1152 }
1153 }
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use serde_json::json;
1159
1160 use super::*;
1161
1162 #[test]
1163 fn parse_bin_string_and_array() {
1164 let bins = parse_bin_value(Some(&json!([
1165 "pwsh.exe",
1166 ["tools/foo.exe", "foo", "--x"]
1167 ])));
1168 assert_eq!(bins.len(), 2);
1169 assert_eq!(
1170 bins.first()
1171 .expect("should have at least one binary")
1172 .target,
1173 "pwsh"
1174 );
1175 assert_eq!(
1176 bins.get(1).expect("should have a second binary").target,
1177 "foo"
1178 );
1179 assert_eq!(
1180 bins.get(1).expect("should have a second binary").args,
1181 vec!["--x".to_string()]
1182 );
1183 }
1184
1185 #[test]
1186 fn collect_download_specs_arch_fallback() {
1187 let manifest = json!({
1188 "url": "https://example.com/default.zip",
1189 "hash": "abc",
1190 "architecture": {
1191 "64bit": {
1192 "url": "https://example.com/x64.zip",
1193 "hash": "def"
1194 },
1195 "arm64": {}
1196 }
1197 });
1198 let specs =
1199 collect_download_specs(manifest.as_object().expect("object"))
1200 .expect("download specs should parse");
1201 assert!(specs.iter().any(|s| s.arch_key == "64bit"));
1202 assert!(specs.iter().any(|s| s.arch_key == "arm64"));
1203 assert!(specs.iter().any(|s| s.arch_key == "default"));
1204 }
1205}