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
16pub 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
40pub 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
78pub 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
97pub 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}