winbrew_app\operations/
shims.rs

1use anyhow::{Context, Result};
2use std::collections::{BTreeMap, HashSet};
3use std::fs::{self, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use crate::database;
8
9#[derive(Debug, Clone)]
10pub(crate) struct ShimTarget {
11    alias: Option<String>,
12    target_path: String,
13    default_args: Vec<String>,
14}
15
16/// Publish command shims for the given installed package.
17///
18/// The shim files are created under the managed `shims/` root so the caller
19/// can keep a single PATH entry instead of exposing package install roots.
20pub fn publish_package_shims(
21    shims_root: &Path,
22    package_name: &str,
23    bin_metadata: Option<&str>,
24) -> Result<usize> {
25    let conn = database::get_conn()?;
26    let package = database::get_package(&conn, package_name)?.with_context(|| {
27        format!("package '{package_name}' was not found while publishing shims")
28    })?;
29    let commands = database::list_commands_for_package(&conn, package_name)?;
30    let targets = parse_shim_targets(bin_metadata)?;
31
32    publish_shims_for_install_dir(
33        shims_root,
34        Path::new(&package.install_dir),
35        &commands,
36        &targets,
37    )
38}
39
40/// Publish command shims for the given install directory and command list.
41pub fn publish_shims_for_install_dir(
42    shims_root: &Path,
43    install_dir: &Path,
44    commands: &[String],
45    targets: &[ShimTarget],
46) -> Result<usize> {
47    if commands.is_empty() {
48        return Ok(0);
49    }
50
51    fs::create_dir_all(shims_root)
52        .with_context(|| format!("failed to create {}", shims_root.display()))?;
53
54    let mut written = 0usize;
55    let mut alias_lookup = BTreeMap::new();
56
57    for (index, target) in targets.iter().enumerate() {
58        if let Some(alias) = target.alias.as_deref() {
59            alias_lookup
60                .entry(normalize_command_name(alias))
61                .or_insert(index);
62        }
63    }
64
65    for (index, command) in commands.iter().enumerate() {
66        let shim_path = command_shim_path(shims_root, command);
67        let target = alias_lookup
68            .get(&normalize_command_name(command))
69            .and_then(|index| targets.get(*index))
70            .or_else(|| targets.get(index));
71        write_command_shim(&shim_path, install_dir, command, target)?;
72        written += 1;
73    }
74
75    Ok(written)
76}
77
78/// Remove command shims for the given command list.
79pub fn remove_shim_files(shims_root: &Path, commands: &[String]) -> Result<usize> {
80    let mut removed = 0usize;
81
82    for command in commands {
83        let shim_path = command_shim_path(shims_root, command);
84        match fs::remove_file(&shim_path) {
85            Ok(()) => removed += 1,
86            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
87            Err(err) => {
88                return Err(err)
89                    .with_context(|| format!("failed to remove shim {}", shim_path.display()));
90            }
91        }
92    }
93
94    Ok(removed)
95}
96
97/// Return the on-disk path for a command shim under the managed `shims/` root.
98pub fn command_shim_path(shims_root: &Path, command_name: &str) -> PathBuf {
99    shims_root.join(format!("{command_name}.cmd"))
100}
101
102fn write_command_shim(
103    path: &Path,
104    install_dir: &Path,
105    command_name: &str,
106    target: Option<&ShimTarget>,
107) -> Result<()> {
108    if let Some(parent) = path.parent() {
109        fs::create_dir_all(parent)
110            .with_context(|| format!("failed to create {}", parent.display()))?;
111    }
112
113    let mut file = OpenOptions::new()
114        .create(true)
115        .write(true)
116        .truncate(true)
117        .open(path)
118        .with_context(|| format!("failed to open {}", path.display()))?;
119
120    let script = command_shim_script(install_dir, command_name, target);
121    file.write_all(script.as_bytes())
122        .with_context(|| format!("failed to write {}", path.display()))?;
123
124    Ok(())
125}
126
127fn command_shim_script(
128    install_dir: &Path,
129    command_name: &str,
130    target: Option<&ShimTarget>,
131) -> String {
132    match target {
133        Some(target) => explicit_command_shim_script(
134            install_dir,
135            command_name,
136            &target.target_path,
137            &target.default_args,
138        ),
139        None => legacy_command_shim_script(install_dir, command_name),
140    }
141}
142
143fn explicit_command_shim_script(
144    install_dir: &Path,
145    command_name: &str,
146    target_path: &str,
147    default_args: &[String],
148) -> String {
149    let install_dir = install_dir.to_string_lossy();
150    let target_path = normalize_path_separators(target_path);
151    let default_args = render_default_args(default_args);
152    let target_extension = Path::new(&target_path)
153        .extension()
154        .and_then(|extension| extension.to_str())
155        .unwrap_or_default()
156        .to_ascii_lowercase();
157    let invocation = if matches!(target_extension.as_str(), "cmd" | "bat") {
158        format!("call \"%WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_TARGET%\"{default_args} %*")
159    } else {
160        format!("\"%WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_TARGET%\"{default_args} %*")
161    };
162
163    format!(
164        "@echo off\r\nsetlocal\r\nset \"WINBREW_PACKAGE_DIR={install_dir}\"\r\nset \"WINBREW_SHIM_TARGET={target_path}\"\r\nif exist \"%WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_TARGET%\" (\r\n  {invocation}\r\n  exit /b %ERRORLEVEL%\r\n)\r\necho WinBrew shim for {command_name} could not find target executable at %WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_TARGET%.\r\nexit /b 1\r\n",
165    )
166}
167
168fn legacy_command_shim_script(install_dir: &Path, command_name: &str) -> String {
169    let install_dir = install_dir.to_string_lossy();
170
171    format!(
172        "@echo off\r\nsetlocal\r\nset \"WINBREW_PACKAGE_DIR={install_dir}\"\r\nset \"WINBREW_SHIM_NAME=%~n0\"\r\nif exist \"%WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_NAME%.exe\" (\r\n  \"%WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_NAME%.exe\" %*\r\n  exit /b %ERRORLEVEL%\r\n)\r\nif exist \"%WINBREW_PACKAGE_DIR%\\bin\\%WINBREW_SHIM_NAME%.exe\" (\r\n  \"%WINBREW_PACKAGE_DIR%\\bin\\%WINBREW_SHIM_NAME%.exe\" %*\r\n  exit /b %ERRORLEVEL%\r\n)\r\nif exist \"%WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_NAME%.cmd\" (\r\n  call \"%WINBREW_PACKAGE_DIR%\\%WINBREW_SHIM_NAME%.cmd\" %*\r\n  exit /b %ERRORLEVEL%\r\n)\r\nif exist \"%WINBREW_PACKAGE_DIR%\\bin\\%WINBREW_SHIM_NAME%.cmd\" (\r\n  call \"%WINBREW_PACKAGE_DIR%\\bin\\%WINBREW_SHIM_NAME%.cmd\" %*\r\n  exit /b %ERRORLEVEL%\r\n)\r\necho WinBrew shim for {command_name} could not find a target executable.\r\nexit /b 1\r\n",
173    )
174}
175
176pub(crate) fn parse_target_paths(raw_targets: Option<&str>) -> Result<Vec<String>> {
177    let targets = parse_shim_targets(raw_targets)?;
178    Ok(normalize_target_paths(
179        targets.iter().map(|target| target.target_path.as_str()),
180    ))
181}
182
183pub(crate) fn parse_journal_shim_bindings(
184    raw_targets: Option<&str>,
185) -> Result<Vec<database::JournalShimBinding>> {
186    Ok(parse_shim_targets(raw_targets)?
187        .into_iter()
188        .map(journal_shim_binding_from_target)
189        .collect())
190}
191
192pub(crate) fn target_paths_from_journal_bindings(
193    bindings: &[database::JournalShimBinding],
194) -> Vec<String> {
195    normalize_target_paths(bindings.iter().map(|binding| binding.target_path.as_str()))
196}
197
198pub(crate) fn shim_targets_from_journal_bindings(
199    bindings: &[database::JournalShimBinding],
200) -> Vec<ShimTarget> {
201    bindings
202        .iter()
203        .filter_map(|binding| {
204            let target_path = normalize_path_separators(binding.target_path.trim());
205            if target_path.is_empty() {
206                return None;
207            }
208
209            Some(ShimTarget {
210                alias: binding
211                    .alias
212                    .as_deref()
213                    .map(str::trim)
214                    .filter(|alias| !alias.is_empty())
215                    .map(str::to_owned),
216                target_path,
217                default_args: binding
218                    .default_args
219                    .iter()
220                    .map(|value| value.trim().to_owned())
221                    .filter(|value| !value.is_empty())
222                    .collect(),
223            })
224        })
225        .collect()
226}
227
228fn parse_shim_targets(raw_targets: Option<&str>) -> Result<Vec<ShimTarget>> {
229    let Some(raw_targets) = raw_targets else {
230        return Ok(Vec::new());
231    };
232
233    let raw_targets = serde_json::from_str::<serde_json::Value>(raw_targets)
234        .with_context(|| "failed to parse shim target JSON")?;
235
236    let targets = match raw_targets {
237        serde_json::Value::String(target_path) => vec![parse_shim_target_string(target_path)],
238        serde_json::Value::Array(values) => parse_shim_target_array(values)?,
239        _ => {
240            return Err(anyhow::anyhow!(
241                "failed to parse shim target JSON: expected a string or array"
242            ));
243        }
244    };
245
246    Ok(targets)
247}
248
249pub(crate) fn legacy_shim_targets(target_paths: &[String]) -> Vec<ShimTarget> {
250    target_paths
251        .iter()
252        .map(|target_path| ShimTarget {
253            alias: None,
254            target_path: normalize_path_separators(target_path.trim()),
255            default_args: Vec::new(),
256        })
257        .collect()
258}
259
260fn parse_shim_target_array(values: Vec<serde_json::Value>) -> Result<Vec<ShimTarget>> {
261    values.into_iter().map(parse_shim_target_value).collect()
262}
263
264fn parse_shim_target_value(value: serde_json::Value) -> Result<ShimTarget> {
265    match value {
266        serde_json::Value::String(target_path) => Ok(parse_shim_target_string(target_path)),
267        serde_json::Value::Array(values) => parse_shim_tuple_target(values),
268        _ => Err(anyhow::anyhow!(
269            "failed to parse shim target JSON: expected string entries or array entries"
270        )),
271    }
272}
273
274fn parse_shim_tuple_target(values: Vec<serde_json::Value>) -> Result<ShimTarget> {
275    let mut values = values.into_iter();
276    let Some(target_path) = values
277        .next()
278        .and_then(|value| value.as_str().map(str::to_owned))
279    else {
280        return Err(anyhow::anyhow!(
281            "failed to parse shim target JSON: expected target path as first tuple entry"
282        ));
283    };
284
285    let alias = values
286        .next()
287        .and_then(|value| value.as_str().map(|value| value.trim().to_owned()))
288        .filter(|value| !value.is_empty());
289
290    let default_args = values
291        .map(|value| {
292            value
293                .as_str()
294                .map(|value| value.trim().to_owned())
295                .ok_or_else(|| {
296                    anyhow::anyhow!(
297                        "failed to parse shim target JSON: expected tuple args to be strings"
298                    )
299                })
300        })
301        .collect::<Result<Vec<_>>>()?
302        .into_iter()
303        .filter(|value| !value.is_empty())
304        .collect();
305
306    Ok(ShimTarget {
307        alias,
308        target_path: normalize_path_separators(target_path.trim()),
309        default_args,
310    })
311}
312
313fn parse_shim_target_string(target_path: String) -> ShimTarget {
314    ShimTarget {
315        alias: None,
316        target_path: normalize_path_separators(target_path.trim()),
317        default_args: Vec::new(),
318    }
319}
320
321fn journal_shim_binding_from_target(target: ShimTarget) -> database::JournalShimBinding {
322    database::JournalShimBinding {
323        alias: target.alias,
324        target_path: target.target_path,
325        default_args: target.default_args,
326    }
327}
328
329fn normalize_command_name(command_name: &str) -> String {
330    command_name.trim().to_ascii_lowercase()
331}
332
333fn render_default_args(default_args: &[String]) -> String {
334    if default_args.is_empty() {
335        return String::new();
336    }
337
338    let rendered = default_args
339        .iter()
340        .map(|argument| render_cmd_argument(argument))
341        .collect::<Vec<_>>()
342        .join(" ");
343
344    format!(" {rendered}")
345}
346
347fn render_cmd_argument(argument: &str) -> String {
348    if argument
349        .chars()
350        .any(|character| character.is_whitespace() || matches!(character, '"'))
351    {
352        format!("\"{}\"", argument.replace('"', "\"\""))
353    } else {
354        argument.to_owned()
355    }
356}
357
358fn normalize_target_paths<I, S>(targets: I) -> Vec<String>
359where
360    I: IntoIterator<Item = S>,
361    S: AsRef<str>,
362{
363    let mut seen = HashSet::new();
364    let mut normalized = Vec::new();
365
366    for target in targets {
367        let normalized_target = normalize_path_separators(target.as_ref().trim());
368        if normalized_target.is_empty() {
369            continue;
370        }
371
372        let dedupe_key = normalized_target.to_ascii_lowercase();
373        if seen.insert(dedupe_key) {
374            normalized.push(normalized_target);
375        }
376    }
377
378    normalized
379}
380
381fn normalize_path_separators(path: &str) -> String {
382    path.replace('/', "\\")
383}
384
385#[cfg(test)]
386mod tests {
387    use super::{command_shim_path, parse_target_paths, publish_package_shims};
388    use crate::database;
389    use crate::models::domains::install::InstallerType;
390    use crate::models::domains::installed::{InstalledPackage, PackageStatus};
391    use anyhow::Result;
392    use std::fs;
393    use std::path::Path;
394    use winbrew_testing::{init_database, reset_install_state, test_root};
395
396    fn sample_package(name: &str, kind: InstallerType, install_dir: &Path) -> InstalledPackage {
397        InstalledPackage {
398            name: name.to_string(),
399            version: "1.0.0".to_string(),
400            kind,
401            deployment_kind: kind.deployment_kind(),
402            engine_kind: kind.into(),
403            engine_metadata: None,
404            install_dir: install_dir.to_string_lossy().into_owned(),
405            dependencies: Vec::new(),
406            status: PackageStatus::Ok,
407            installed_at: "2026-04-05T00:00:00Z".to_string(),
408        }
409    }
410
411    #[test]
412    fn parse_target_paths_accepts_single_string_and_array() -> Result<()> {
413        let single = parse_target_paths(Some(r#""bin/tool.exe""#))?;
414        assert_eq!(single, vec!["bin\\tool.exe".to_string()]);
415
416        let multiple = parse_target_paths(Some(r#"["bin/tool.exe", "bin/other.exe"]"#))?;
417        assert_eq!(
418            multiple,
419            vec!["bin\\tool.exe".to_string(), "bin\\other.exe".to_string()]
420        );
421
422        Ok(())
423    }
424
425    #[test]
426    fn publish_package_shims_accepts_single_string_bin_metadata() -> Result<()> {
427        let test_root = test_root();
428        let root = test_root.path();
429        init_database(root)?;
430        reset_install_state(root)?;
431        let conn = database::get_conn()?;
432
433        let install_dir = root.join("packages").join("Contoso.Shim");
434        fs::create_dir_all(&install_dir)?;
435
436        let package = sample_package("Contoso.Shim", InstallerType::Portable, &install_dir);
437        database::insert_package(&conn, &package)?;
438        database::sync_package_commands(&conn, &package.name, Some(r#"["contoso"]"#))?;
439
440        let shims_root = root.join("shims");
441        let written = publish_package_shims(&shims_root, &package.name, Some(r#""bin/tool.exe""#))?;
442
443        assert_eq!(written, 1);
444
445        let shim_path = command_shim_path(&shims_root, "contoso");
446        assert!(shim_path.exists());
447
448        let shim_contents = fs::read_to_string(shim_path)?;
449        assert!(shim_contents.contains("WINBREW_SHIM_TARGET=bin\\tool.exe"));
450
451        Ok(())
452    }
453
454    #[test]
455    fn publish_package_shims_supports_tuple_bin_default_args() -> Result<()> {
456        let test_root = test_root();
457        let root = test_root.path();
458        init_database(root)?;
459        reset_install_state(root)?;
460        let conn = database::get_conn()?;
461
462        let install_dir = root.join("packages").join("Contoso.Tuple");
463        fs::create_dir_all(&install_dir)?;
464
465        let package = sample_package("Contoso.Tuple", InstallerType::Portable, &install_dir);
466        database::insert_package(&conn, &package)?;
467        database::sync_package_commands(&conn, &package.name, Some(r#"["git", "git-lfs"]"#))?;
468
469        let shims_root = root.join("shims");
470        let written = publish_package_shims(
471            &shims_root,
472            &package.name,
473            Some(r#"[["bin/git.exe", "git", "--version"], ["bin/git-lfs.exe", "git-lfs"]]"#),
474        )?;
475
476        assert_eq!(written, 2);
477
478        let git_shim = fs::read_to_string(command_shim_path(&shims_root, "git"))?;
479        assert!(git_shim.contains("WINBREW_SHIM_TARGET=bin\\git.exe"));
480        assert!(git_shim.contains("--version"));
481
482        let git_lfs_shim = fs::read_to_string(command_shim_path(&shims_root, "git-lfs"))?;
483        assert!(git_lfs_shim.contains("WINBREW_SHIM_TARGET=bin\\git-lfs.exe"));
484        assert!(!git_lfs_shim.contains("--version"));
485
486        Ok(())
487    }
488}