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