Skip to main content

zlayer_toolchain/
recipe_choco.rs

1//! Chocolatey `.nupkg` → [`InstallPlan`] parser.
2//!
3//! A `.nupkg` is a zip archive. This module parses the root `*.nuspec`
4//! (package `<id>` / `<version>`) plus `tools/chocolateyInstall.ps1` into the
5//! SAME [`InstallPlan`] type the Homebrew recipe parser ([`crate::recipe`])
6//! produces, so one executor runs both.
7//!
8//! The `chocolateyInstall.ps1` scan is line-oriented and deliberately
9//! conservative — PowerShell is a full language, so every construct the
10//! scanner does not fully understand becomes an
11//! [`InstallStep::Unsupported`] carrying the source text (the same contract as
12//! the Homebrew parser: never silently swallow a line). Supported idioms:
13//!
14//! - `$var = 'literal'` / `"interpolated $var"` assignments, including the
15//!   `"$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"` tools-dir
16//!   idiom (maps to the install prefix) and simple `Join-Path a b`.
17//! - Hashtable splats — `$packageArgs = @{ url = '...'; ... }` followed by
18//!   `Install-ChocolateyZipPackage @packageArgs` — the dominant modern idiom.
19//! - `Install-ChocolateyZipPackage` (splat, named `-Url`/`-Url64bit`, or
20//!   positional) → download the zip ([`ResourceSpec`] +
21//!   [`InstallStep::StageResource`]) and extract into the prefix
22//!   ([`InstallStep::PrefixInstall`] from `"."`).
23//! - `Get-ChocolateyWebFile` → download only (resource + stage).
24//! - `Install-BinFile -Name x -Path y` → [`InstallStep::BinInstall`].
25//! - `Install-ChocolateyPackage` / `Install-ChocolateyInstallPackage` (exe/msi
26//!   installers), `Start-ChocolateyProcessAsAdmin`, registry cmdlets,
27//!   `Set-EnvironmentVariable`, and anything else opaque → `Unsupported` (the
28//!   Windows resolver routes those packages to the loud choco container).
29//!
30//! 64-bit `url64`/`checksum64` are preferred when present (we target
31//! amd64/arm64 Windows). A checksum whose type is not sha256 (md5/sha1) keeps
32//! the URL but yields an EMPTY `sha256` plus an `Unsupported` marker
33//! (`checksumType <t>`), so callers know verification is weaker.
34
35use std::collections::HashMap;
36use std::io::Read;
37use std::path::Path;
38
39use crate::error::{Result, ToolchainError};
40use crate::recipe::{InstallPlan, InstallStep, ResourceSpec};
41
42/// A parsed Chocolatey package: nuspec identity plus the executable plan.
43#[derive(Debug, Clone, PartialEq)]
44pub struct ChocoRecipe {
45    /// Package `<id>` from the nuspec (e.g. `terraform`).
46    pub id: String,
47    /// Package `<version>` from the nuspec (e.g. `1.16.0`).
48    pub version: String,
49    /// The install plan parsed from `tools/chocolateyInstall.ps1`.
50    pub plan: InstallPlan,
51}
52
53/// Parse an in-memory `.nupkg` into a [`ChocoRecipe`] against `prefix` (the
54/// toolchain install prefix — Chocolatey's "tools dir" maps to it).
55///
56/// # Errors
57///
58/// Returns [`ToolchainError::RegistryError`] when `bytes` is not a zip, when
59/// the archive has no root `*.nuspec` (or it lacks `<id>`/`<version>`), or
60/// when `tools/chocolateyInstall.ps1` is missing.
61pub fn parse_nupkg(bytes: &[u8], prefix: &Path) -> Result<ChocoRecipe> {
62    let cursor = std::io::Cursor::new(bytes);
63    let mut archive = zip::ZipArchive::new(cursor).map_err(|e| ToolchainError::RegistryError {
64        message: format!("failed to open .nupkg as a zip archive: {e}"),
65    })?;
66    let names: Vec<String> = archive.file_names().map(str::to_string).collect();
67    let nuspec_name = names
68        .iter()
69        .find(|n| is_root_nuspec(n))
70        .cloned()
71        .ok_or_else(|| ToolchainError::RegistryError {
72            message: "no *.nuspec found at the .nupkg archive root".to_string(),
73        })?;
74    let nuspec = read_zip_text(&mut archive, &nuspec_name)?;
75    let (id, version) = parse_nuspec(&nuspec)?;
76    let ps1_name = names
77        .iter()
78        .find(|n| is_install_ps1(n))
79        .cloned()
80        .ok_or_else(|| ToolchainError::RegistryError {
81            message: format!("no tools/chocolateyInstall.ps1 in .nupkg for {id}"),
82        })?;
83    let ps1 = read_zip_text(&mut archive, &ps1_name)?;
84    let plan = plan_from_ps1(&ps1, &id, &version, prefix);
85    Ok(ChocoRecipe { id, version, plan })
86}
87
88/// `true` for a `*.nuspec` entry at the archive root (no directory part).
89fn is_root_nuspec(name: &str) -> bool {
90    !name.contains('/')
91        && !name.contains('\\')
92        && Path::new(&name.to_ascii_lowercase())
93            .extension()
94            .is_some_and(|e| e == "nuspec")
95}
96
97/// `true` for the `tools/chocolateyInstall.ps1` entry (case-insensitive,
98/// either slash direction).
99fn is_install_ps1(name: &str) -> bool {
100    name.to_ascii_lowercase().replace('\\', "/") == "tools/chocolateyinstall.ps1"
101}
102
103/// Read one archive entry as text (lossy UTF-8, BOM stripped).
104fn read_zip_text(
105    archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
106    name: &str,
107) -> Result<String> {
108    let mut file = archive
109        .by_name(name)
110        .map_err(|e| ToolchainError::RegistryError {
111            message: format!("failed to read `{name}` from .nupkg: {e}"),
112        })?;
113    let mut bytes = Vec::new();
114    file.read_to_end(&mut bytes)?;
115    let text = String::from_utf8_lossy(&bytes).into_owned();
116    Ok(text.trim_start_matches('\u{feff}').to_string())
117}
118
119// ---------------------------------------------------------------------------
120// .nuspec scanning (no XML dependency — lightweight tag scan)
121// ---------------------------------------------------------------------------
122
123/// Extract `<id>` and `<version>` from nuspec XML, honoring the `<metadata>`
124/// nesting and tolerating attributes/whitespace on the tags.
125fn parse_nuspec(xml: &str) -> Result<(String, String)> {
126    let meta = xml_region(xml, "metadata").unwrap_or(xml);
127    let id = xml_region(meta, "id")
128        .map(str::trim)
129        .filter(|s| !s.is_empty());
130    let version = xml_region(meta, "version")
131        .map(str::trim)
132        .filter(|s| !s.is_empty());
133    match (id, version) {
134        (Some(id), Some(version)) => Ok((id.to_string(), version.to_string())),
135        _ => Err(ToolchainError::RegistryError {
136            message: "nuspec is missing <id> or <version> inside <metadata>".to_string(),
137        }),
138    }
139}
140
141/// Inner text of the first `<tag ...> ... </tag>` region (case-insensitive,
142/// attribute-tolerant; self-closing tags are skipped).
143fn xml_region<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
144    let lower = xml.to_ascii_lowercase();
145    let open = format!("<{tag}");
146    let close = format!("</{tag}");
147    let mut search = 0;
148    while let Some(rel) = lower[search..].find(&open) {
149        let after = search + rel + open.len();
150        if !matches!(
151            lower.as_bytes().get(after),
152            Some(b'>' | b' ' | b'\t' | b'\r' | b'\n')
153        ) {
154            search = after;
155            continue;
156        }
157        let gt = lower[after..].find('>')? + after;
158        if lower.as_bytes()[gt - 1] == b'/' {
159            search = gt + 1;
160            continue; // self-closing tag
161        }
162        let end = lower[gt + 1..].find(&close)? + gt + 1;
163        return Some(&xml[gt + 1..end]);
164    }
165    None
166}
167
168// ---------------------------------------------------------------------------
169// chocolateyInstall.ps1 scanning
170// ---------------------------------------------------------------------------
171
172/// A download picked from a command's arguments (64-bit preferred).
173struct Download {
174    /// Download URL.
175    url: String,
176    /// sha256 (bare hex) — empty when the checksum is missing or non-sha256.
177    sha256: String,
178    /// Weak-verification marker to surface as `Unsupported`, when applicable.
179    weak: Option<String>,
180}
181
182/// Line-oriented planner state for one `chocolateyInstall.ps1`.
183struct PsPlanner {
184    /// Package id (resource naming + choco's injected `$packageName`).
185    pkg_id: String,
186    /// Install prefix as a display string (the "tools dir").
187    prefix: String,
188    /// Resolved `$var` values, keyed by lowercased name.
189    vars: HashMap<String, String>,
190    /// Parsed `@{...}` hashtables, keyed by lowercased variable name. Values
191    /// are `None` when the entry's RHS could not be resolved.
192    tables: HashMap<String, HashMap<String, Option<String>>>,
193    /// Accumulated plan steps.
194    steps: Vec<InstallStep>,
195    /// Accumulated download resources.
196    resources: Vec<ResourceSpec>,
197}
198
199/// Parse a `chocolateyInstall.ps1` into an [`InstallPlan`]. Pure (no IO);
200/// unknown constructs land as [`InstallStep::Unsupported`].
201fn plan_from_ps1(ps1: &str, id: &str, version: &str, prefix: &Path) -> InstallPlan {
202    let mut planner = PsPlanner::new(id, version, prefix);
203    let lines: Vec<&str> = ps1.lines().collect();
204    let mut i = 0usize;
205    while i < lines.len() {
206        let raw = lines[i].trim_start_matches('\u{feff}').trim();
207        i += 1;
208        if raw.starts_with("<#") {
209            // Block comment: consume through the closing `#>`.
210            let mut cur = raw;
211            while !cur.contains("#>") && i < lines.len() {
212                cur = lines[i];
213                i += 1;
214            }
215            continue;
216        }
217        let line = strip_ps_comment(raw);
218        let line = line.trim();
219        if line.is_empty() {
220            continue;
221        }
222        planner.dispatch_line(line, &lines, &mut i);
223    }
224    planner.finish()
225}
226
227impl PsPlanner {
228    /// Fresh planner seeded with choco's script-scope injected variables
229    /// (`$packageName`, `$packageVersion`, `$packageFolder`).
230    fn new(id: &str, version: &str, prefix: &Path) -> Self {
231        let prefix = prefix.display().to_string();
232        let mut vars = HashMap::new();
233        vars.insert("packagename".to_string(), id.to_string());
234        vars.insert("packageversion".to_string(), version.to_string());
235        vars.insert("packagefolder".to_string(), prefix.clone());
236        Self {
237            pkg_id: id.to_string(),
238            prefix,
239            vars,
240            tables: HashMap::new(),
241            steps: Vec::new(),
242            resources: Vec::new(),
243        }
244    }
245
246    /// Record an unsupported construct.
247    fn unsupported(&mut self, construct: &str) {
248        self.steps.push(InstallStep::Unsupported {
249            construct: construct.to_string(),
250        });
251    }
252
253    /// Record an unsupported line; when it opens a `{` block, consume the
254    /// whole block so the region folds into ONE `Unsupported` step.
255    fn unsupported_block(&mut self, line: &str, lines: &[&str], i: &mut usize) {
256        self.unsupported(line);
257        let mut delta = line_brace_delta(line);
258        while delta > 0 && *i < lines.len() {
259            let next = strip_ps_comment(lines[*i].trim());
260            *i += 1;
261            delta += line_brace_delta(&next);
262        }
263    }
264
265    /// Dispatch one logical line: assignment, inert, or command.
266    fn dispatch_line(&mut self, line: &str, lines: &[&str], i: &mut usize) {
267        if let Some((name, rhs)) = parse_assignment(line) {
268            self.assignment(line, &name, rhs, lines, i);
269            return;
270        }
271        if is_inert(line) {
272            return;
273        }
274        let tokens = tokenize_ps(line);
275        let Some(cmd) = tokens.first().cloned() else {
276            return;
277        };
278        match cmd.to_ascii_lowercase().as_str() {
279            "install-chocolateyzippackage" => self.cmd_zip_package(&tokens[1..], line),
280            "get-chocolateywebfile" => self.cmd_web_file(&tokens[1..], line),
281            "install-chocolateypackage" | "install-chocolateyinstallpackage" => {
282                self.cmd_loud_installer(&cmd, &tokens[1..], line);
283            }
284            "install-binfile" => self.cmd_bin_file(&tokens[1..], line),
285            _ => self.unsupported_block(line, lines, i),
286        }
287    }
288
289    /// Handle a `$name = <rhs>` assignment (hashtable or scalar).
290    fn assignment(&mut self, line: &str, name: &str, rhs: &str, lines: &[&str], i: &mut usize) {
291        if name == "erroractionpreference" {
292            return; // inert
293        }
294        if let Some(after_brace) = rhs.strip_prefix("@{") {
295            match collect_hashtable_body(after_brace, lines, i) {
296                Some(body) => {
297                    let (map, junk) = self.parse_table(&body);
298                    for entry in junk {
299                        self.unsupported(&entry);
300                    }
301                    self.tables.insert(name.to_string(), map);
302                }
303                None => self.unsupported(line),
304            }
305            return;
306        }
307        match self.resolve_rhs(rhs) {
308            Some(value) => {
309                self.vars.insert(name.to_string(), value);
310            }
311            None => self.unsupported_block(line, lines, i),
312        }
313    }
314
315    /// Parse a hashtable body into (entries, junk-lines). Entry values that do
316    /// not resolve are recorded as `None` (the consuming command decides).
317    fn parse_table(&self, body: &str) -> (HashMap<String, Option<String>>, Vec<String>) {
318        let mut map = HashMap::new();
319        let mut junk = Vec::new();
320        for line in body.lines() {
321            for entry in split_semis(line) {
322                let entry = entry.trim();
323                if entry.is_empty() {
324                    continue;
325                }
326                match split_kv(entry) {
327                    Some((key, value)) => {
328                        map.insert(normalize_key(key), self.resolve_rhs(value));
329                    }
330                    None => junk.push(entry.to_string()),
331                }
332            }
333        }
334        (map, junk)
335    }
336
337    // -- commands -------------------------------------------------------------
338
339    /// `Install-ChocolateyZipPackage` → download the zip + extract into the
340    /// prefix (`StageResource` + `PrefixInstall { from: ".", to: None }`).
341    fn cmd_zip_package(&mut self, args: &[String], line: &str) {
342        let args =
343            self.gather_call_args(args, &["packagename", "url", "unziplocation", "url64bit"]);
344        match pick_download(&args) {
345            Some(dl) => self.push_download(dl, true),
346            None => self.unsupported(line),
347        }
348    }
349
350    /// `Get-ChocolateyWebFile` → download only (the file lands in the tools
351    /// dir, i.e. the prefix).
352    fn cmd_web_file(&mut self, args: &[String], line: &str) {
353        let args = self.gather_call_args(args, &["packagename", "filefullpath", "url", "url64bit"]);
354        match pick_download(&args) {
355            Some(dl) => self.push_download(dl, false),
356            None => self.unsupported(line),
357        }
358    }
359
360    /// `Install-ChocolateyPackage` / `Install-ChocolateyInstallPackage` —
361    /// exe/msi installers cannot run outside HCS-with-net; surface as
362    /// `Unsupported` (the resolver routes these to the loud choco container).
363    fn cmd_loud_installer(&mut self, cmd: &str, args: &[String], line: &str) {
364        let args = self.gather_call_args(args, &["packagename", "filetype", "silentargs", "url"]);
365        let url = args
366            .get("url64bit")
367            .or_else(|| args.get("url"))
368            .and_then(Clone::clone);
369        let construct = url.map_or_else(|| line.to_string(), |u| format!("{cmd} {u}"));
370        self.unsupported(&construct);
371    }
372
373    /// `Install-BinFile -Name x -Path y` → [`InstallStep::BinInstall`].
374    fn cmd_bin_file(&mut self, args: &[String], line: &str) {
375        let args = self.gather_call_args(args, &["name", "path"]);
376        let name = args.get("name").and_then(Clone::clone);
377        let path = args.get("path").and_then(Clone::clone);
378        match (name, path) {
379            (Some(name), Some(path)) => {
380                let from = self.relativize(&path);
381                self.steps.push(InstallStep::BinInstall {
382                    from,
383                    to: Some(name),
384                });
385            }
386            _ => self.unsupported(line),
387        }
388    }
389
390    /// Push a picked download: resource + stage (+ extract-into-prefix for zip
391    /// packages) (+ weak-checksum marker when verification is degraded).
392    fn push_download(&mut self, dl: Download, extract_to_prefix: bool) {
393        let name = self.unique_resource_name();
394        self.resources.push(ResourceSpec {
395            name: name.clone(),
396            url: dl.url,
397            sha256: dl.sha256,
398            stage_to: None,
399        });
400        self.steps
401            .push(InstallStep::StageResource { name, dir: None });
402        if extract_to_prefix {
403            self.steps.push(InstallStep::PrefixInstall {
404                from: ".".to_string(),
405                to: None,
406            });
407        }
408        if let Some(weak) = dl.weak {
409            self.unsupported(&weak);
410        }
411    }
412
413    /// First free resource name: the package id, then `<id>-2`, `<id>-3`, ...
414    fn unique_resource_name(&self) -> String {
415        let taken = |candidate: &str| self.resources.iter().any(|r| r.name == candidate);
416        if !taken(&self.pkg_id) {
417            return self.pkg_id.clone();
418        }
419        let mut n = 2usize;
420        loop {
421            let candidate = format!("{}-{n}", self.pkg_id);
422            if !taken(&candidate) {
423                return candidate;
424            }
425            n += 1;
426        }
427    }
428
429    /// Merge splatted tables, `-Named` parameters, and positional arguments
430    /// into one lowercased-key argument map.
431    fn gather_call_args(
432        &self,
433        tokens: &[String],
434        positional: &[&str],
435    ) -> HashMap<String, Option<String>> {
436        let mut out = HashMap::new();
437        let mut pos_idx = 0usize;
438        let mut k = 0usize;
439        while k < tokens.len() {
440            let tok = &tokens[k];
441            k += 1;
442            if let Some(splat) = tok.strip_prefix('@') {
443                if let Some(table) = self.tables.get(&splat.to_ascii_lowercase()) {
444                    for (key, value) in table {
445                        out.insert(key.clone(), value.clone());
446                    }
447                }
448                continue;
449            }
450            if let Some(param) = tok.strip_prefix('-') {
451                let value = if k < tokens.len() && !tokens[k].starts_with('-') {
452                    let v = self.resolve_rhs(&tokens[k]);
453                    k += 1;
454                    v
455                } else {
456                    Some("true".to_string()) // bare switch
457                };
458                out.insert(normalize_key(param), value);
459                continue;
460            }
461            if pos_idx < positional.len() {
462                out.insert(positional[pos_idx].to_string(), self.resolve_rhs(tok));
463                pos_idx += 1;
464            }
465        }
466        out
467    }
468
469    // -- value resolution -------------------------------------------------------
470
471    /// Resolve a value expression: quoted literals (with interpolation), known
472    /// `$vars`, the `Split-Path ... $MyInvocation...` tools-dir idiom, simple
473    /// `Join-Path a b`, and bare single-word literals. `None` = unresolvable.
474    fn resolve_rhs(&self, rhs: &str) -> Option<String> {
475        let rhs = rhs.trim();
476        if let Some(v) = full_single_quoted(rhs) {
477            return Some(v);
478        }
479        if let Some(body) = full_double_quoted(rhs) {
480            return self.interp_dquote(body);
481        }
482        if let Some(v) = self.split_path_value(rhs) {
483            return Some(v);
484        }
485        let lower = rhs.to_ascii_lowercase();
486        if lower.starts_with("join-path ") {
487            let toks = tokenize_ps(&rhs["join-path ".len()..]);
488            if let [a, b] = toks.as_slice() {
489                let a = self.resolve_rhs(a)?;
490                let b = self.resolve_rhs(b)?;
491                return Some(format!("{}/{b}", a.trim_end_matches(['/', '\\'])));
492            }
493            return None;
494        }
495        if let Some(inner) = rhs.strip_prefix("$(").and_then(|r| r.strip_suffix(')')) {
496            return self.resolve_subexpr(inner);
497        }
498        if let Some(name) = rhs.strip_prefix('$') {
499            if is_ident(name) {
500                return self.lookup(name);
501            }
502            return None;
503        }
504        // Bare single-word literal (e.g. an unquoted `sha256`). A word with a
505        // `-` is likely a cmdlet (`Get-PackageParameters`) — reject those.
506        if !rhs.is_empty()
507            && !rhs.contains(char::is_whitespace)
508            && !rhs.contains('-')
509            && !rhs.starts_with('@')
510            && !rhs.starts_with('(')
511        {
512            return Some(rhs.to_string());
513        }
514        None
515    }
516
517    /// Resolve a double-quoted string body: backtick escapes, `$(...)`
518    /// subexpressions, `${name}` and `$name` interpolations.
519    fn interp_dquote(&self, body: &str) -> Option<String> {
520        let mut out = String::new();
521        let bytes = body.as_bytes();
522        let mut i = 0usize;
523        while i < body.len() {
524            let c = body[i..].chars().next()?;
525            if c == '`' {
526                i += 1;
527                if let Some(next) = body[i..].chars().next() {
528                    out.push(next);
529                    i += next.len_utf8();
530                }
531                continue;
532            }
533            if c != '$' {
534                out.push(c);
535                i += c.len_utf8();
536                continue;
537            }
538            i += 1;
539            if bytes.get(i) == Some(&b'(') {
540                let close = find_matching_paren(&body[i..])?;
541                out.push_str(&self.resolve_subexpr(&body[i + 1..i + close])?);
542                i += close + 1;
543            } else if bytes.get(i) == Some(&b'{') {
544                let end = body[i..].find('}')? + i;
545                out.push_str(&self.lookup(&body[i + 1..end])?);
546                i = end + 1;
547            } else {
548                let start = i;
549                while i < body.len() && is_ident_byte(bytes[i]) {
550                    i += 1;
551                }
552                if i == start {
553                    out.push('$'); // lone `$`
554                    continue;
555                }
556                if bytes.get(i) == Some(&b':') {
557                    return None; // scoped/env variable (`$env:X`) — opaque
558                }
559                out.push_str(&self.lookup(&body[start..i])?);
560            }
561        }
562        Some(out)
563    }
564
565    /// Resolve a `$(...)` subexpression: the `Split-Path` tools-dir idiom or a
566    /// plain variable reference.
567    fn resolve_subexpr(&self, inner: &str) -> Option<String> {
568        let inner = inner.trim();
569        if let Some(v) = self.split_path_value(inner) {
570            return Some(v);
571        }
572        inner
573            .strip_prefix('$')
574            .filter(|name| is_ident(name))
575            .and_then(|name| self.lookup(name))
576    }
577
578    /// The `Split-Path [-Parent] $MyInvocation.MyCommand.Definition` idiom →
579    /// the package tools dir → our install prefix.
580    fn split_path_value(&self, expr: &str) -> Option<String> {
581        let lower = expr.to_ascii_lowercase();
582        (lower.starts_with("split-path") && lower.contains("$myinvocation.mycommand.definition"))
583            .then(|| self.prefix.clone())
584    }
585
586    /// Case-insensitive variable lookup.
587    fn lookup(&self, name: &str) -> Option<String> {
588        self.vars.get(&name.to_ascii_lowercase()).cloned()
589    }
590
591    /// Make a path relative to the prefix (tools dir) when it lives under it.
592    fn relativize(&self, path: &str) -> String {
593        path.strip_prefix(&self.prefix).map_or_else(
594            || path.to_string(),
595            |rest| {
596                let rest = rest.trim_start_matches(['/', '\\']);
597                if rest.is_empty() {
598                    ".".to_string()
599                } else {
600                    rest.to_string()
601                }
602            },
603        )
604    }
605
606    /// Build the final plan (no patches/env in the choco path).
607    fn finish(self) -> InstallPlan {
608        InstallPlan {
609            steps: self.steps,
610            resources: self.resources,
611            patches: Vec::new(),
612            env: HashMap::new(),
613            deparallelize: false,
614        }
615    }
616}
617
618// ---------------------------------------------------------------------------
619// Download picking / checksum classification
620// ---------------------------------------------------------------------------
621
622/// Pick the download from gathered args: `url64bit` (with `checksum64`,
623/// falling back to `checksumType` per choco's defaulting) preferred over
624/// `url`. `None` when no URL resolved.
625fn pick_download(args: &HashMap<String, Option<String>>) -> Option<Download> {
626    let get = |key: &str| {
627        args.get(key)
628            .and_then(Clone::clone)
629            .filter(|s| !s.is_empty())
630    };
631    let (url, checksum, ctype) = if let Some(url64) = get("url64bit") {
632        let ctype = get("checksumtype64").or_else(|| get("checksumtype"));
633        (url64, get("checksum64"), ctype)
634    } else {
635        (get("url")?, get("checksum"), get("checksumtype"))
636    };
637    let (sha256, weak) = classify_checksum(checksum, ctype, &url);
638    Some(Download { url, sha256, weak })
639}
640
641/// sha256 checksums pass through; a missing or non-sha256 checksum keeps the
642/// URL but yields an empty sha256 plus a weak-verification marker.
643fn classify_checksum(
644    checksum: Option<String>,
645    ctype: Option<String>,
646    url: &str,
647) -> (String, Option<String>) {
648    let Some(checksum) = checksum else {
649        return (String::new(), Some(format!("no checksum for {url}")));
650    };
651    match ctype.map(|t| t.to_ascii_lowercase()) {
652        Some(t) if t == "sha256" => (checksum, None),
653        Some(t) => (String::new(), Some(format!("checksumType {t}"))),
654        // No explicit type: 64 hex chars can only be sha256 (md5=32, sha1=40).
655        None if is_sha256_hex(&checksum) => (checksum, None),
656        None => (
657            String::new(),
658            Some(format!("checksumType unknown for {url}")),
659        ),
660    }
661}
662
663/// `true` for a 64-char bare-hex string (a sha256 digest).
664fn is_sha256_hex(s: &str) -> bool {
665    s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
666}
667
668// ---------------------------------------------------------------------------
669// PowerShell lexing helpers (line-oriented, quote-aware)
670// ---------------------------------------------------------------------------
671
672/// `true` for identifier bytes in variable names.
673fn is_ident_byte(b: u8) -> bool {
674    b.is_ascii_alphanumeric() || b == b'_'
675}
676
677/// `true` when the whole string is a plain identifier.
678fn is_ident(s: &str) -> bool {
679    !s.is_empty() && s.bytes().all(is_ident_byte)
680}
681
682/// Strip a trailing `#` comment, string-aware (`'...'` / `"..."` with
683/// backtick escapes).
684fn strip_ps_comment(raw: &str) -> String {
685    let mut out = String::new();
686    let mut in_single = false;
687    let mut in_double = false;
688    let mut escaped = false;
689    for c in raw.chars() {
690        if escaped {
691            out.push(c);
692            escaped = false;
693            continue;
694        }
695        match c {
696            '`' if in_double => {
697                out.push(c);
698                escaped = true;
699            }
700            '\'' if !in_double => {
701                in_single = !in_single;
702                out.push(c);
703            }
704            '"' if !in_single => {
705                in_double = !in_double;
706                out.push(c);
707            }
708            '#' if !in_single && !in_double => break,
709            _ => out.push(c),
710        }
711    }
712    out
713}
714
715/// Split a line into whitespace-separated tokens, keeping quoted regions
716/// (with their quotes) intact.
717fn tokenize_ps(line: &str) -> Vec<String> {
718    let mut out = Vec::new();
719    let mut cur = String::new();
720    let mut in_single = false;
721    let mut in_double = false;
722    let mut escaped = false;
723    for c in line.chars() {
724        if escaped {
725            cur.push(c);
726            escaped = false;
727            continue;
728        }
729        match c {
730            '`' if in_double => {
731                cur.push(c);
732                escaped = true;
733            }
734            '\'' if !in_double => {
735                in_single = !in_single;
736                cur.push(c);
737            }
738            '"' if !in_single => {
739                in_double = !in_double;
740                cur.push(c);
741            }
742            c if c.is_whitespace() && !in_single && !in_double => {
743                if !cur.is_empty() {
744                    out.push(std::mem::take(&mut cur));
745                }
746            }
747            _ => cur.push(c),
748        }
749    }
750    if !cur.is_empty() {
751        out.push(cur);
752    }
753    out
754}
755
756/// Net `{`/`}` balance of a line (outside strings).
757fn line_brace_delta(line: &str) -> i32 {
758    let mut delta = 0i32;
759    let mut in_single = false;
760    let mut in_double = false;
761    let mut escaped = false;
762    for c in line.chars() {
763        if escaped {
764            escaped = false;
765            continue;
766        }
767        match c {
768            '`' if in_double => escaped = true,
769            '\'' if !in_double => in_single = !in_single,
770            '"' if !in_single => in_double = !in_double,
771            '{' if !in_single && !in_double => delta += 1,
772            '}' if !in_single && !in_double => delta -= 1,
773            _ => {}
774        }
775    }
776    delta
777}
778
779/// Split `$name = rhs`; returns (lowercased name, trimmed rhs).
780fn parse_assignment(line: &str) -> Option<(String, &str)> {
781    let rest = line.strip_prefix('$')?;
782    let end = rest
783        .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
784        .unwrap_or(rest.len());
785    if end == 0 {
786        return None;
787    }
788    let name = &rest[..end];
789    let rhs = rest[end..].trim_start().strip_prefix('=')?;
790    if rhs.starts_with('=') {
791        return None; // `==` comparison, not an assignment
792    }
793    Some((name.to_ascii_lowercase(), rhs.trim()))
794}
795
796/// Lines that are inert for the install plan and may be silently skipped.
797fn is_inert(line: &str) -> bool {
798    let lower = line.to_ascii_lowercase();
799    if lower.starts_with("param(") || lower == "param" {
800        return true;
801    }
802    let first = lower.split_whitespace().next().unwrap_or("");
803    matches!(
804        first,
805        "write-host" | "write-output" | "write-verbose" | "write-warning" | "write-debug"
806    )
807}
808
809/// Accumulate a hashtable body opened by `@{` until its matching `}`,
810/// consuming further physical lines as needed. `None` when it never closes.
811fn collect_hashtable_body(after_brace: &str, lines: &[&str], i: &mut usize) -> Option<String> {
812    let mut depth = 1i32;
813    let mut body = String::new();
814    let mut chunk = after_brace.to_string();
815    loop {
816        if let Some(pos) = scan_braces(&chunk, &mut depth) {
817            body.push_str(&chunk[..pos]);
818            return Some(body);
819        }
820        body.push_str(&chunk);
821        body.push('\n');
822        if *i >= lines.len() {
823            return None;
824        }
825        chunk = strip_ps_comment(lines[*i].trim());
826        *i += 1;
827    }
828}
829
830/// Walk `chunk` adjusting `depth` for braces outside strings; returns the
831/// byte position where depth reaches zero.
832fn scan_braces(chunk: &str, depth: &mut i32) -> Option<usize> {
833    let mut in_single = false;
834    let mut in_double = false;
835    let mut escaped = false;
836    for (pos, c) in chunk.char_indices() {
837        if escaped {
838            escaped = false;
839            continue;
840        }
841        match c {
842            '`' if in_double => escaped = true,
843            '\'' if !in_double => in_single = !in_single,
844            '"' if !in_single => in_double = !in_double,
845            '{' if !in_single && !in_double => *depth += 1,
846            '}' if !in_single && !in_double => {
847                *depth -= 1;
848                if *depth == 0 {
849                    return Some(pos);
850                }
851            }
852            _ => {}
853        }
854    }
855    None
856}
857
858/// Split a hashtable line on `;` outside strings.
859fn split_semis(s: &str) -> Vec<&str> {
860    let mut out = Vec::new();
861    let mut in_single = false;
862    let mut in_double = false;
863    let mut escaped = false;
864    let mut start = 0usize;
865    for (pos, c) in s.char_indices() {
866        if escaped {
867            escaped = false;
868            continue;
869        }
870        match c {
871            '`' if in_double => escaped = true,
872            '\'' if !in_double => in_single = !in_single,
873            '"' if !in_single => in_double = !in_double,
874            ';' if !in_single && !in_double => {
875                out.push(&s[start..pos]);
876                start = pos + 1;
877            }
878            _ => {}
879        }
880    }
881    out.push(&s[start..]);
882    out
883}
884
885/// Split a hashtable entry on its first top-level `=`; the key must be a
886/// plain identifier.
887fn split_kv(entry: &str) -> Option<(&str, &str)> {
888    let mut in_single = false;
889    let mut in_double = false;
890    let mut escaped = false;
891    for (pos, c) in entry.char_indices() {
892        if escaped {
893            escaped = false;
894            continue;
895        }
896        match c {
897            '`' if in_double => escaped = true,
898            '\'' if !in_double => in_single = !in_single,
899            '"' if !in_single => in_double = !in_double,
900            '=' if !in_single && !in_double => {
901                let key = entry[..pos].trim();
902                let value = entry[pos + 1..].trim();
903                if is_ident(key) && !value.starts_with('=') {
904                    return Some((key, value));
905                }
906                return None;
907            }
908            _ => {}
909        }
910    }
911    None
912}
913
914/// Lowercase an argument/hashtable key and fold known aliases
915/// (`Url64` → `url64bit`, `Destination` → `unziplocation`).
916fn normalize_key(key: &str) -> String {
917    let key = key.to_ascii_lowercase();
918    match key.as_str() {
919        "url64" => "url64bit".to_string(),
920        "destination" => "unziplocation".to_string(),
921        _ => key,
922    }
923}
924
925/// Index of the `)` matching the `(` at byte 0 of `s`.
926fn find_matching_paren(s: &str) -> Option<usize> {
927    let mut depth = 0i32;
928    let mut in_single = false;
929    let mut in_double = false;
930    for (pos, c) in s.char_indices() {
931        match c {
932            '\'' if !in_double => in_single = !in_single,
933            '"' if !in_single => in_double = !in_double,
934            '(' if !in_single && !in_double => depth += 1,
935            ')' if !in_single && !in_double => {
936                depth -= 1;
937                if depth == 0 {
938                    return Some(pos);
939                }
940            }
941            _ => {}
942        }
943    }
944    None
945}
946
947/// Body of a fully single-quoted literal (with `''` unescaped to `'`).
948fn full_single_quoted(s: &str) -> Option<String> {
949    let body = s.strip_prefix('\'')?;
950    let mut out = String::new();
951    let mut chars = body.char_indices().peekable();
952    while let Some((pos, c)) = chars.next() {
953        if c == '\'' {
954            if chars.peek().is_some_and(|&(_, n)| n == '\'') {
955                out.push('\'');
956                chars.next();
957            } else {
958                return (pos == body.len() - 1).then_some(out);
959            }
960        } else {
961            out.push(c);
962        }
963    }
964    None
965}
966
967/// Body of a fully double-quoted literal (backtick-escape aware; the body is
968/// NOT interpolated here).
969fn full_double_quoted(s: &str) -> Option<&str> {
970    let body = s.strip_prefix('"')?;
971    let mut escaped = false;
972    for (pos, c) in body.char_indices() {
973        if escaped {
974            escaped = false;
975            continue;
976        }
977        match c {
978            '`' => escaped = true,
979            '"' => return (pos == body.len() - 1).then(|| &body[..pos]),
980            _ => {}
981        }
982    }
983    None
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    /// Minimal-but-realistic nuspec XML for `id`/`version`.
991    fn nuspec_xml(id: &str, version: &str) -> String {
992        format!(
993            r#"<?xml version="1.0" encoding="utf-8"?>
994<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
995  <metadata>
996    <id>{id}</id>
997    <version>{version}</version>
998    <title>{id}</title>
999    <authors>test</authors>
1000  </metadata>
1001</package>"#
1002        )
1003    }
1004
1005    /// Build a tiny in-memory `.nupkg` zip from (name, content) entries.
1006    fn build_nupkg(files: &[(&str, &str)]) -> Vec<u8> {
1007        use std::io::Write;
1008        let mut buf = Vec::new();
1009        {
1010            let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
1011            let opts = zip::write::SimpleFileOptions::default();
1012            for (name, content) in files {
1013                zw.start_file(*name, opts).unwrap();
1014                zw.write_all(content.as_bytes()).unwrap();
1015            }
1016            zw.finish().unwrap();
1017        }
1018        buf
1019    }
1020
1021    /// Parse a nupkg whose nuspec is `id`/`version` and whose install script
1022    /// is `ps1`, against prefix `/tc/<id>`.
1023    fn recipe_for(id: &str, version: &str, ps1: &str) -> ChocoRecipe {
1024        let nuspec = nuspec_xml(id, version);
1025        let bytes = build_nupkg(&[
1026            (&format!("{id}.nuspec"), nuspec.as_str()),
1027            ("tools/chocolateyInstall.ps1", ps1),
1028        ]);
1029        parse_nupkg(&bytes, Path::new(&format!("/tc/{id}"))).expect("nupkg should parse")
1030    }
1031
1032    /// 1a. Modern splat-style zip package. Fixture modeled on the REAL
1033    /// terraform chocolateyInstall.ps1 (fetched from
1034    /// community.chocolatey.org/api/v2/package/terraform, v1.16.0-alpha
1035    /// series: `$packageParams = @{...}` with `Url64`, a shared
1036    /// `ChecksumType`, and `Install-ChocolateyZipPackage @packageParams`),
1037    /// trimmed of its legacy plugin-cleanup `if` blocks.
1038    #[test]
1039    fn splat_zip_package_terraform_style() {
1040        let ps1 = r"$ErrorActionPreference = 'Stop'
1041
1042# DO NOT CHANGE THESE MANUALLY. USE update.ps1
1043$url = 'https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_386.zip'
1044$url64 = 'https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_amd64.zip'
1045$checksum = 'bbb9fe7d4b6d6f7f9160bd729d190f98bd3c8fa4845793313d72d521d768bc04'
1046$checksum64 = '4f36ac8f3ca338f9bec28e1fc48ce5c3de6e6bd4f11e6cfb25d3bbf65a0ef87a'
1047
1048$unzipLocation = Split-Path -Parent $MyInvocation.MyCommand.Definition
1049
1050$packageParams = @{
1051  PackageName   = 'terraform'
1052  UnzipLocation = $unzipLocation
1053  Url           = $url
1054  Url64         = $url64
1055  Checksum      = $checksum
1056  Checksum64    = $checksum64
1057  ChecksumType  = 'sha256'
1058}
1059
1060Install-ChocolateyZipPackage @packageParams
1061";
1062        let r = recipe_for("terraform", "1.16.0", ps1);
1063        assert_eq!(r.id, "terraform");
1064        assert_eq!(r.version, "1.16.0");
1065        assert!(
1066            r.plan.is_fully_supported(),
1067            "{:?}",
1068            r.plan.unsupported_constructs()
1069        );
1070        assert_eq!(
1071            r.plan.resources,
1072            vec![ResourceSpec {
1073                name: "terraform".into(),
1074                url: "https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_amd64.zip".into(),
1075                sha256: "4f36ac8f3ca338f9bec28e1fc48ce5c3de6e6bd4f11e6cfb25d3bbf65a0ef87a".into(),
1076                stage_to: None,
1077            }]
1078        );
1079        assert_eq!(
1080            r.plan.steps,
1081            vec![
1082                InstallStep::StageResource {
1083                    name: "terraform".into(),
1084                    dir: None
1085                },
1086                InstallStep::PrefixInstall {
1087                    from: ".".into(),
1088                    to: None
1089                },
1090            ]
1091        );
1092    }
1093
1094    /// 1b. `Get-ChocolateyWebFile` splat package: the REAL jq 1.8.1
1095    /// chocolateyInstall.ps1 embedded VERBATIM (fetched from
1096    /// community.chocolatey.org/api/v2/package/jq).
1097    #[test]
1098    fn get_chocolatey_web_file_jq_verbatim() {
1099        let ps1 = r#"$ErrorActionPreference = 'Stop'
1100$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
1101
1102$fileFullPath = Join-Path $toolsDir "$packageName.exe"
1103$url = 'https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-i386.exe'
1104$checksumType = "sha256"
1105$checksum = '414ec99417830178bd2f6e77fc78b34de3b12fc6b6c3229f07038c5811307124'
1106$url64 = 'https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe'
1107$checksumType64 = "sha256"
1108$checksum64 = '23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334'
1109
1110$packageArgs = @{
1111    PackageName    = $env:ChocolateyPackageName
1112    FileFullPath   = $fileFullPath
1113    Url            = $url
1114    ChecksumType   = $checksumType
1115    Checksum       = $checksum
1116    Url64bit       = $url64
1117    ChecksumType64 = $checksumType64
1118    Checksum64     = $checksum64
1119}
1120
1121Get-ChocolateyWebFile @packageArgs
1122"#;
1123        let r = recipe_for("jq", "1.8.1", ps1);
1124        assert!(
1125            r.plan.is_fully_supported(),
1126            "{:?}",
1127            r.plan.unsupported_constructs()
1128        );
1129        assert_eq!(
1130            r.plan.resources,
1131            vec![ResourceSpec {
1132                name: "jq".into(),
1133                url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe"
1134                    .into(),
1135                sha256: "23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334".into(),
1136                stage_to: None,
1137            }]
1138        );
1139        // Web file: download/stage only, no extract-into-prefix step.
1140        assert_eq!(
1141            r.plan.steps,
1142            vec![InstallStep::StageResource {
1143                name: "jq".into(),
1144                dir: None
1145            }]
1146        );
1147    }
1148
1149    /// 2. Positional `Install-ChocolateyZipPackage` form
1150    ///    (mixed with named checksum params).
1151    #[test]
1152    fn positional_zip_package() {
1153        let ps1 = r#"$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
1154Install-ChocolateyZipPackage 'sample' 'https://example.com/sample-1.0.0.zip' $toolsDir -Checksum '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' -ChecksumType 'sha256'
1155"#;
1156        let r = recipe_for("sample", "1.0.0", ps1);
1157        assert!(
1158            r.plan.is_fully_supported(),
1159            "{:?}",
1160            r.plan.unsupported_constructs()
1161        );
1162        assert_eq!(
1163            r.plan.resources,
1164            vec![ResourceSpec {
1165                name: "sample".into(),
1166                url: "https://example.com/sample-1.0.0.zip".into(),
1167                sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
1168                stage_to: None,
1169            }]
1170        );
1171        assert_eq!(
1172            r.plan.steps,
1173            vec![
1174                InstallStep::StageResource {
1175                    name: "sample".into(),
1176                    dir: None
1177                },
1178                InstallStep::PrefixInstall {
1179                    from: ".".into(),
1180                    to: None
1181                },
1182            ]
1183        );
1184    }
1185
1186    /// 3. `Install-ChocolateyPackage` (exe installer) → `Unsupported`
1187    ///    surfaced with the URL; the plan is not natively executable.
1188    #[test]
1189    fn install_chocolatey_package_exe_is_unsupported() {
1190        let ps1 = r"$packageArgs = @{
1191  packageName    = 'sample'
1192  fileType       = 'exe'
1193  url            = 'https://example.com/sample-setup.exe'
1194  silentArgs     = '/S'
1195  validExitCodes = @(0)
1196}
1197Install-ChocolateyPackage @packageArgs
1198";
1199        let r = recipe_for("sample", "1.0.0", ps1);
1200        assert!(!r.plan.is_fully_supported());
1201        assert!(r.plan.resources.is_empty());
1202        let constructs = r.plan.unsupported_constructs();
1203        assert_eq!(constructs.len(), 1);
1204        assert!(constructs[0].contains("Install-ChocolateyPackage"));
1205        assert!(constructs[0].contains("https://example.com/sample-setup.exe"));
1206    }
1207
1208    /// 4. md5 `checksumType` → URL kept, sha256 left EMPTY,
1209    ///    weak-checksum marker surfaced as `Unsupported`.
1210    #[test]
1211    fn md5_checksum_type_weakens_verification() {
1212        let ps1 = r#"Install-ChocolateyZipPackage -PackageName 'sample' -Url 'https://example.com/s.zip' -Checksum 'd41d8cd98f00b204e9800998ecf8427e' -ChecksumType 'md5' -UnzipLocation "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
1213"#;
1214        let r = recipe_for("sample", "1.0.0", ps1);
1215        assert!(!r.plan.is_fully_supported());
1216        assert_eq!(
1217            r.plan.resources,
1218            vec![ResourceSpec {
1219                name: "sample".into(),
1220                url: "https://example.com/s.zip".into(),
1221                sha256: String::new(),
1222                stage_to: None,
1223            }]
1224        );
1225        // Download/extract steps still present, plus the weak marker.
1226        assert_eq!(
1227            r.plan.steps,
1228            vec![
1229                InstallStep::StageResource {
1230                    name: "sample".into(),
1231                    dir: None
1232                },
1233                InstallStep::PrefixInstall {
1234                    from: ".".into(),
1235                    to: None
1236                },
1237                InstallStep::Unsupported {
1238                    construct: "checksumType md5".into()
1239                },
1240            ]
1241        );
1242    }
1243
1244    /// 5. Missing nuspec / missing ps1 / not-a-zip → clean `RegistryError`.
1245    #[test]
1246    fn malformed_packages_error_cleanly() {
1247        let prefix = Path::new("/tc/sample");
1248
1249        let err = parse_nupkg(b"definitely not a zip archive", prefix).unwrap_err();
1250        assert!(matches!(err, ToolchainError::RegistryError { .. }), "{err}");
1251
1252        let no_nuspec = build_nupkg(&[("tools/chocolateyInstall.ps1", "Write-Host hi")]);
1253        let err = parse_nupkg(&no_nuspec, prefix).unwrap_err();
1254        assert!(
1255            matches!(&err, ToolchainError::RegistryError { message } if message.contains("nuspec")),
1256            "{err}"
1257        );
1258
1259        let nuspec = nuspec_xml("sample", "1.0.0");
1260        let no_ps1 = build_nupkg(&[("sample.nuspec", nuspec.as_str())]);
1261        let err = parse_nupkg(&no_ps1, prefix).unwrap_err();
1262        assert!(
1263            matches!(&err, ToolchainError::RegistryError { message } if message.contains("chocolateyInstall")),
1264            "{err}"
1265        );
1266    }
1267
1268    /// 6. `Install-BinFile -Name x -Path y` → `BinInstall`
1269    ///    (path relativized against the tools dir / prefix).
1270    #[test]
1271    fn install_bin_file_maps_to_bin_install() {
1272        let ps1 = r#"$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
1273Install-BinFile -Name 'fooctl' -Path "$toolsDir\fooctl.exe"
1274"#;
1275        let r = recipe_for("sample", "1.0.0", ps1);
1276        assert!(
1277            r.plan.is_fully_supported(),
1278            "{:?}",
1279            r.plan.unsupported_constructs()
1280        );
1281        assert_eq!(
1282            r.plan.steps,
1283            vec![InstallStep::BinInstall {
1284                from: "fooctl.exe".into(),
1285                to: Some("fooctl".into()),
1286            }]
1287        );
1288    }
1289
1290    /// Conservatism: opaque cmdlets and unresolvable assignments surface as
1291    /// Unsupported (one entry per construct; an `if (...) { ... }` block folds
1292    /// into ONE Unsupported step) — nothing is silently swallowed.
1293    #[test]
1294    fn opaque_constructs_surface_as_unsupported() {
1295        let ps1 = r#"$pp = Get-PackageParameters
1296if (Test-Path "C:\old") {
1297  Remove-Item "C:\old" -Recurse -Force
1298}
1299Start-ChocolateyProcessAsAdmin 'setup.exe'
1300"#;
1301        let r = recipe_for("sample", "1.0.0", ps1);
1302        assert!(!r.plan.is_fully_supported());
1303        let constructs = r.plan.unsupported_constructs();
1304        assert_eq!(constructs.len(), 3, "{constructs:?}");
1305        assert!(constructs[0].contains("Get-PackageParameters"));
1306        assert!(constructs[1].starts_with("if "));
1307        assert!(constructs[2].contains("Start-ChocolateyProcessAsAdmin"));
1308    }
1309}