Skip to main content

zoi_cli/cmd/
migrate.rs

1//! Implementation of the `zoi migrate` command.
2//!
3//! This command facilitates migrating packages from other formats (like Scoop)
4//! to Zoi.
5
6use 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/// Arguments for the `migrate` command.
16#[derive(Parser, Debug)]
17pub struct MigrateCommand {
18    /// The migration sub-command to run.
19    #[command(subcommand)]
20    command: Commands
21}
22
23/// Available migration sub-commands.
24#[derive(Subcommand, Debug)]
25enum Commands {
26    /// Convert a Scoop manifest (scoop.json) into a Zoi .pkg.lua package file
27    Scoop(ScoopCommand)
28}
29
30/// Arguments for the Scoop migration sub-command.
31#[derive(Parser, Debug)]
32pub struct ScoopCommand {
33    /// Path to Scoop manifest JSON/JSON5 file
34    #[arg(required = true, value_hint = ValueHint::FilePath)]
35    input: PathBuf,
36    /// Output path for generated .pkg.lua (default: <package-name>.pkg.lua)
37    #[arg(long, short = 'o', value_hint = ValueHint::FilePath)]
38    output: Option<PathBuf>,
39    /// Repository tier to set in metadata.repo
40    #[arg(long, default_value = "community")]
41    repo: String,
42    /// Override package name (default: from filename stem)
43    #[arg(long)]
44    name: Option<String>,
45    /// Override version (default: Scoop manifest version)
46    #[arg(long)]
47    version: Option<String>,
48    /// Maintainer name in generated metadata
49    #[arg(long, default_value = "Scoop Migration")]
50    maintainer_name: String,
51    /// Maintainer email in generated metadata
52    #[arg(long, default_value = "noreply@example.com")]
53    maintainer_email: String,
54    /// Print generated pkg.lua instead of writing a file
55    #[arg(long)]
56    dry_run: bool
57}
58
59/// Represents a binary mapping from source path to target name.
60#[derive(Debug, Clone)]
61struct BinMapping {
62    /// The source path of the binary.
63    source: String,
64    /// The target name of the binary.
65    target: String,
66    /// Arguments to be passed to the binary.
67    args: Vec<String>
68}
69
70/// Represents a download specification for a package.
71#[derive(Debug, Clone)]
72struct DownloadSpec {
73    /// The architecture key (e.g. "64bit", "arm64", "default").
74    arch_key: String,
75    /// The download URLs.
76    urls: Vec<String>,
77    /// The hashes of the downloaded files.
78    hashes: Vec<String>,
79    /// The directory within the archive to extract.
80    extract_dir: Option<String>,
81    /// The directory to extract to.
82    extract_to: Option<String>
83}
84
85/// Represents hook scripts for different stages of the package lifecycle.
86#[derive(Debug, Clone, Default)]
87struct HookScripts {
88    /// Scripts to run before installation.
89    pre_install: Vec<String>,
90    /// Scripts to run after installation.
91    post_install: Vec<String>,
92    /// Scripts to run before removal.
93    pre_remove: Vec<String>,
94    /// Scripts to run after removal.
95    post_remove: Vec<String>
96}
97
98/// Represents the generated package metadata and content.
99#[derive(Debug, Clone)]
100struct Generated {
101    /// Package name.
102    name: String,
103    /// Package repository.
104    repo: String,
105    /// Package version.
106    version: String,
107    /// Package description.
108    description: String,
109    /// Package website.
110    website: String,
111    /// Package license.
112    license: String,
113    /// Maintainer name.
114    maintainer_name: String,
115    /// Maintainer email.
116    maintainer_email: String,
117    /// Binary mappings.
118    bins: Vec<BinMapping>,
119    /// Runtime dependencies.
120    runtime_deps: Vec<String>,
121    /// Backup paths.
122    backups: Vec<String>,
123    /// Download specifications.
124    downloads: Vec<DownloadSpec>,
125    /// Hook scripts.
126    hooks: HookScripts,
127    /// Migration notes.
128    notes: Vec<String>,
129    /// The original manifest.
130    manifest: Value
131}
132
133/// Runs the `migrate` command.
134///
135/// # Errors
136///
137/// Returns an error if:
138/// - The Scoop migration sub-command fails.
139pub fn run(args: MigrateCommand) -> Result<()> {
140    match args.command {
141        Commands::Scoop(cmd) => run_scoop(&cmd)
142    }
143}
144
145/// Runs the Scoop migration sub-command.
146fn 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
228/// Derives a package name from the input file path.
229fn 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
236/// Returns the default output path for the generated package file.
237fn 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
244/// Normalizes a package name by trimming, lowercasing, and replacing spaces
245/// with hyphens.
246fn normalize_name(name: &str) -> String {
247    name.trim().to_lowercase().replace(' ', "-")
248}
249
250/// Extracts a string field from a JSON map.
251fn 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
261/// Extracts the license field from a JSON map, handling both string and object
262/// formats.
263fn 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
284/// Converts a JSON value (string or array) into a list of strings.
285fn 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
313/// Parses the `depends` field from a Scoop manifest.
314fn 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
327/// Parses the `persist` field from a Scoop manifest.
328fn 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
369/// Collects binary mappings from a Scoop manifest.
370fn 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
396/// Parses a `bin` value from a Scoop manifest.
397fn 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
457/// Returns the file stem or basename of a path, stripping common executable
458/// extensions.
459fn 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
475/// Collects download specifications from a Scoop manifest.
476fn 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
551/// Collects hook scripts from a Scoop manifest.
552fn 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
621/// Dedupes a vector of strings while preserving order.
622fn dedupe_strings(values: &mut Vec<String>) {
623    let mut seen = BTreeSet::new();
624    values.retain(|v| seen.insert(v.clone()));
625}
626
627/// Collects migration notes based on the Scoop manifest content.
628fn 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
741/// Renders a generated package into a `.pkg.lua` file.
742fn 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
1085/// Escapes a string for use in Lua.
1086fn lua_quote(value: &str) -> String {
1087    let escaped = value
1088        .replace('\\', "\\\\")
1089        .replace('"', "\\\"")
1090        .replace('\n', "\\n");
1091    format!("\"{escaped}\"")
1092}
1093
1094/// Returns a Lua quoted string or `nil` if the value is empty or `None`.
1095fn 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
1102/// Converts a JSON value into a Lua table string.
1103fn 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}