Skip to main content

zoi_cli/cmd/
migrate.rs

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    /// Convert a Scoop manifest (scoop.json) into a Zoi .pkg.lua package file
17    Scoop(ScoopCommand),
18}
19
20#[derive(Parser, Debug)]
21pub struct ScoopCommand {
22    /// Path to Scoop manifest JSON/JSON5 file
23    #[arg(required = true, value_hint = ValueHint::FilePath)]
24    input: PathBuf,
25    /// Output path for generated .pkg.lua (default: <package-name>.pkg.lua)
26    #[arg(long, short = 'o', value_hint = ValueHint::FilePath)]
27    output: Option<PathBuf>,
28    /// Repository tier to set in metadata.repo
29    #[arg(long, default_value = "community")]
30    repo: String,
31    /// Override package name (default: from filename stem)
32    #[arg(long)]
33    name: Option<String>,
34    /// Override version (default: Scoop manifest version)
35    #[arg(long)]
36    version: Option<String>,
37    /// Maintainer name in generated metadata
38    #[arg(long, default_value = "Scoop Migration")]
39    maintainer_name: String,
40    /// Maintainer email in generated metadata
41    #[arg(long, default_value = "noreply@example.com")]
42    maintainer_email: String,
43    /// Print generated pkg.lua instead of writing a file
44    #[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}