Skip to main content

cli/
bin_links.rs

1use anyhow::{Context, Result};
2use std::collections::HashSet;
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5
6const LINKABLE_SCRIPT_EXTENSIONS: &[&str] = &["sh", "bash", "zsh", "fish", "ps1"];
7
8/// Script extensions runnable through the `bun` runtime launcher.
9const BUN_SCRIPT_EXTENSIONS: &[&str] = &["ts", "js", "mts", "mjs"];
10
11#[cfg(not(unix))]
12const EXECUTABLE_EXTENSIONS: &[&str] = &["sh", "ps1"];
13
14// Marker lines identifying a shine-managed launcher. The Unix bun launcher script
15// and the Windows `.ps1`/`.cmd` shims all use the same convention so ownership
16// (`unlink_managed`) and current-ness detection are shared across platforms.
17const SHIM_MANAGED_MARKER: &str = "# shine-managed";
18const SHIM_TARGET_PREFIX: &str = "# shine-target: ";
19
20/// Runtime used to invoke a linked command.
21///
22/// `Native` is the historical behavior: a Unix symlink or a Windows bash/PowerShell
23/// shim pointing directly at the script. `Bun` wraps the script in a generated
24/// launcher that runs `bun <script> "$@"` — a real regular file on Unix (not a
25/// symlink) carrying the managed marker, and a bun-invoking shim on Windows.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum LinkRuntime {
28    #[default]
29    Native,
30    Bun,
31}
32
33/// Whether an existing on-disk launcher/shim is a current, stale, or foreign file.
34///
35/// Shared by the Unix bun-launcher path and the Windows shim path. `NotManaged`
36/// protects user files: it means the file lacks the managed marker (or points at a
37/// different source), so it is treated as a conflict, never silently replaced.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39enum LauncherStatus {
40    Current,
41    Stale,
42    NotManaged,
43}
44
45pub struct LinkReport {
46    pub created: Vec<PathBuf>,
47    pub skipped: Vec<PathBuf>,
48    pub conflicts: Vec<LinkConflict>,
49    pub overwritten: Vec<PathBuf>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum LinkConflictKind {
54    ExistingEntry,
55    DuplicateName,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct LinkConflict {
60    pub link_path: PathBuf,
61    pub source: PathBuf,
62    pub kind: LinkConflictKind,
63}
64
65pub struct UnlinkReport {
66    pub removed: Vec<PathBuf>,
67    pub skipped: Vec<PathBuf>,
68}
69
70pub struct LinkSpec {
71    pub source: PathBuf,
72    pub link_name: OsString,
73    pub runtime: LinkRuntime,
74    /// For `LinkRuntime::Bun`: ordered `--with` argument tokens (`KEY` or
75    /// `SOURCE=TARGET`) injected at launch through `shine env run`. Empty keeps
76    /// the v1 behavior — a plain `bun <script>` launcher with no `shine`
77    /// dependency — and produces byte-identical launcher content.
78    pub env: Vec<String>,
79    /// Canonical installed target to lazily render before execution in external live mode.
80    pub render_target: Option<String>,
81}
82
83/// Remove symlinks in `bin_dir` whose link target starts with `managed_root`.
84///
85/// Non-symlinks and symlinks pointing outside `managed_root` are untouched.
86/// Missing `bin_dir` is treated as a no-op (returns empty report).
87/// When `dry_run` is true, nothing is removed.
88pub async fn unlink_managed(
89    bin_dir: &Path,
90    managed_root: &Path,
91    dry_run: bool,
92) -> Result<UnlinkReport> {
93    let mut report = UnlinkReport {
94        removed: Vec::new(),
95        skipped: Vec::new(),
96    };
97
98    let mut read_dir = match tokio::fs::read_dir(bin_dir).await {
99        Ok(rd) => rd,
100        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(report),
101        Err(e) => return Err(e).with_context(|| format!("reading bin dir: {bin_dir:?}")),
102    };
103
104    while let Some(entry) = read_dir
105        .next_entry()
106        .await
107        .with_context(|| format!("iterating bin dir: {bin_dir:?}"))?
108    {
109        let path = entry.path();
110        let meta = match tokio::fs::symlink_metadata(&path).await {
111            Ok(m) => m,
112            Err(_) => continue,
113        };
114
115        // Regular files are shine-managed only when they carry the managed marker
116        // and record a target under `managed_root`. On Windows these are the
117        // `.ps1`/`.cmd` shims; on Unix they are the generated bun launcher scripts.
118        // User files (no marker, foreign target, or unreadable) are always skipped —
119        // this is the "uninstall never touches user files" invariant.
120        if !meta.file_type().is_symlink() {
121            match launcher_target(&path).await {
122                Ok(Some(target)) if target_is_managed(&target, managed_root, bin_dir) => {
123                    if !dry_run {
124                        remove_link(&path).await?;
125                    }
126                    report.removed.push(path);
127                }
128                _ => report.skipped.push(path),
129            }
130            continue;
131        }
132
133        let target = match tokio::fs::read_link(&path).await {
134            Ok(t) => t,
135            Err(_) => {
136                report.skipped.push(path);
137                continue;
138            }
139        };
140
141        // Lexical prefix check — works even if the target file no longer exists.
142        if target_is_managed(&target, managed_root, bin_dir) {
143            if !dry_run {
144                tokio::fs::remove_file(&path)
145                    .await
146                    .with_context(|| format!("removing symlink: {path:?}"))?;
147            }
148            report.removed.push(path);
149        } else {
150            report.skipped.push(path);
151        }
152    }
153
154    Ok(report)
155}
156
157/// Create flat symlinks in `bin_dir` for each executable file in `sources`.
158///
159/// - Existing correct symlinks are skipped (idempotent).
160/// - Conflicting entries (wrong target or regular file) are recorded and skipped
161///   unless `overwrite` is true.
162/// - Two sources sharing the same filename → second is recorded as a conflict.
163#[cfg(test)]
164pub async fn link_executables(
165    bin_dir: &Path,
166    sources: &[PathBuf],
167    overwrite: bool,
168) -> Result<LinkReport> {
169    let specs: Vec<_> = sources
170        .iter()
171        .map(|source| LinkSpec {
172            source: source.clone(),
173            link_name: link_stem(source),
174            runtime: LinkRuntime::Native,
175            env: Vec::new(),
176            render_target: None,
177        })
178        .collect();
179    link_executables_with_names(bin_dir, &specs, overwrite).await
180}
181
182pub async fn link_executables_with_names(
183    bin_dir: &Path,
184    specs: &[LinkSpec],
185    overwrite: bool,
186) -> Result<LinkReport> {
187    let mut report = LinkReport {
188        created: Vec::new(),
189        skipped: Vec::new(),
190        conflicts: Vec::new(),
191        overwritten: Vec::new(),
192    };
193
194    let mut seen: HashSet<OsString> = HashSet::new();
195
196    for spec in specs {
197        // Native links require a runnable/linkable source; bun launchers wrap any
198        // declared bun script, so they bypass the executable/extension gate.
199        if spec.runtime == LinkRuntime::Native && !is_linkable_source(&spec.source) {
200            continue;
201        }
202
203        if spec.source.file_name().is_none() {
204            continue;
205        }
206        let stem = spec.link_name.clone();
207
208        if !seen.insert(stem.clone()) {
209            report.conflicts.push(LinkConflict {
210                link_path: command_path_for_name(bin_dir, &stem),
211                source: spec.source.clone(),
212                kind: LinkConflictKind::DuplicateName,
213            });
214            continue;
215        }
216
217        let link_path = command_path_for_name(bin_dir, &stem);
218
219        match tokio::fs::symlink_metadata(&link_path).await {
220            Ok(meta) if meta.file_type().is_symlink() => {
221                match tokio::fs::read_link(&link_path).await {
222                    Ok(existing) if existing == spec.source && spec.render_target.is_none() => {
223                        report.skipped.push(link_path);
224                    }
225                    _ => {
226                        if overwrite {
227                            tokio::fs::remove_file(&link_path).await.with_context(|| {
228                                format!("removing stale symlink: {link_path:?}")
229                            })?;
230                            create_link(
231                                &spec.source,
232                                &link_path,
233                                spec.runtime,
234                                &spec.env,
235                                spec.render_target.as_deref(),
236                            )
237                            .await?;
238                            report.overwritten.push(link_path);
239                        } else {
240                            report.conflicts.push(LinkConflict {
241                                link_path,
242                                source: spec.source.clone(),
243                                kind: LinkConflictKind::ExistingEntry,
244                            });
245                        }
246                    }
247                }
248            }
249            Ok(_) => {
250                match launcher_status(
251                    &link_path,
252                    &spec.source,
253                    spec.runtime,
254                    &spec.env,
255                    spec.render_target.as_deref(),
256                )
257                .await?
258                {
259                    LauncherStatus::Current => {
260                        report.skipped.push(link_path);
261                        continue;
262                    }
263                    LauncherStatus::Stale => {
264                        remove_link(&link_path).await?;
265                        create_link(
266                            &spec.source,
267                            &link_path,
268                            spec.runtime,
269                            &spec.env,
270                            spec.render_target.as_deref(),
271                        )
272                        .await?;
273                        report.overwritten.push(link_path);
274                        continue;
275                    }
276                    LauncherStatus::NotManaged => {}
277                }
278
279                if overwrite {
280                    remove_link(&link_path).await?;
281                    create_link(
282                        &spec.source,
283                        &link_path,
284                        spec.runtime,
285                        &spec.env,
286                        spec.render_target.as_deref(),
287                    )
288                    .await?;
289                    report.overwritten.push(link_path);
290                } else {
291                    report.conflicts.push(LinkConflict {
292                        link_path,
293                        source: spec.source.clone(),
294                        kind: LinkConflictKind::ExistingEntry,
295                    });
296                }
297            }
298            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
299                create_link(
300                    &spec.source,
301                    &link_path,
302                    spec.runtime,
303                    &spec.env,
304                    spec.render_target.as_deref(),
305                )
306                .await?;
307                report.created.push(link_path);
308            }
309            Err(e) => {
310                return Err(e).with_context(|| format!("stat failed: {link_path:?}"));
311            }
312        }
313    }
314
315    Ok(report)
316}
317
318/// Return whether an installed command exactly matches its expected source, runtime, and
319/// runtime environment declaration.
320///
321/// Status surfaces use the same current-ness rules as install/upgrade so an existing command
322/// from an older source or runtime is reported as an available update.
323pub(crate) async fn link_is_current(
324    link_path: &Path,
325    source: &Path,
326    runtime: LinkRuntime,
327    env: &[String],
328    render_target: Option<&str>,
329) -> Result<bool> {
330    match tokio::fs::symlink_metadata(link_path).await {
331        Ok(meta) if meta.file_type().is_symlink() => {
332            if runtime != LinkRuntime::Native || render_target.is_some() {
333                return Ok(false);
334            }
335            Ok(tokio::fs::read_link(link_path)
336                .await
337                .is_ok_and(|target| target == source))
338        }
339        Ok(_) => Ok(matches!(
340            launcher_status(link_path, source, runtime, env, render_target).await?,
341            LauncherStatus::Current
342        )),
343        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
344        Err(error) => Err(error).with_context(|| format!("stat failed: {link_path:?}")),
345    }
346}
347
348pub fn command_path_for_name(bin_dir: &Path, stem: &OsStr) -> PathBuf {
349    #[cfg(unix)]
350    {
351        bin_dir.join(stem)
352    }
353    #[cfg(not(unix))]
354    {
355        let mut name = stem.to_os_string();
356        name.push(".ps1");
357        bin_dir.join(name)
358    }
359}
360
361pub fn link_stem(path: &Path) -> std::ffi::OsString {
362    if has_linkable_script_extension(path) || has_bun_script_extension(path) {
363        path.file_stem().map(|s| s.to_owned()).unwrap_or_default()
364    } else {
365        path.file_name().map(|n| n.to_owned()).unwrap_or_default()
366    }
367}
368
369fn is_executable(path: &Path) -> bool {
370    #[cfg(unix)]
371    {
372        use std::os::unix::fs::PermissionsExt;
373        std::fs::metadata(path)
374            .map(|m| m.permissions().mode() & 0o111 != 0)
375            .unwrap_or(false)
376    }
377    #[cfg(not(unix))]
378    {
379        path.extension()
380            .and_then(|e| e.to_str())
381            .map(|ext| EXECUTABLE_EXTENSIONS.contains(&ext))
382            .unwrap_or(false)
383    }
384}
385
386fn is_linkable_source(path: &Path) -> bool {
387    is_executable(path) || has_linkable_script_extension(path)
388}
389
390fn has_linkable_script_extension(path: &Path) -> bool {
391    path.extension()
392        .and_then(|e| e.to_str())
393        .map(|ext| LINKABLE_SCRIPT_EXTENSIONS.contains(&ext))
394        .unwrap_or(false)
395}
396
397fn has_bun_script_extension(path: &Path) -> bool {
398    path.extension()
399        .and_then(|e| e.to_str())
400        .map(|ext| BUN_SCRIPT_EXTENSIONS.contains(&ext))
401        .unwrap_or(false)
402}
403
404/// True when `target` (from a launcher's `# shine-target:` line or a symlink)
405/// lexically resolves under `managed_root`. Relative targets are resolved against
406/// `bin_dir`. Works even if the target file no longer exists.
407fn target_is_managed(target: &Path, managed_root: &Path, bin_dir: &Path) -> bool {
408    if target.is_absolute() {
409        target.starts_with(managed_root)
410    } else {
411        bin_dir.join(target).starts_with(managed_root)
412    }
413}
414
415/// The command name a launcher exposes — the link path's file stem.
416fn launcher_command_name(link_path: &Path) -> String {
417    link_path
418        .file_stem()
419        .map(|s| s.to_string_lossy().into_owned())
420        .unwrap_or_default()
421}
422
423/// Read a launcher/shim's recorded `# shine-target:` path, or `None` if the file
424/// is not a shine-managed launcher (missing marker) or is unreadable. Any read
425/// error yields `None` so a user file is never mistaken for a managed launcher.
426async fn launcher_target(path: &Path) -> Result<Option<PathBuf>> {
427    let content = match tokio::fs::read_to_string(path).await {
428        Ok(content) => content,
429        Err(_) => return Ok(None),
430    };
431    if !content.contains(SHIM_MANAGED_MARKER) {
432        return Ok(None);
433    }
434    Ok(shim_target_from_content(&content))
435}
436
437fn shim_target_from_content(content: &str) -> Option<PathBuf> {
438    content.lines().find_map(|line| {
439        line.strip_prefix(SHIM_TARGET_PREFIX)
440            .or_else(|| line.strip_prefix("REM shine-target: "))
441            .map(PathBuf::from)
442    })
443}
444
445async fn create_link(
446    source: &Path,
447    link_path: &Path,
448    runtime: LinkRuntime,
449    env: &[String],
450    render_target: Option<&str>,
451) -> Result<()> {
452    #[cfg(unix)]
453    {
454        if let Some(target) = render_target {
455            return write_unix_live_launcher(source, link_path, runtime, env, target).await;
456        }
457        match runtime {
458            LinkRuntime::Native => tokio::fs::symlink(source, link_path)
459                .await
460                .with_context(|| format!("creating symlink {link_path:?} -> {source:?}")),
461            LinkRuntime::Bun => write_unix_bun_launcher(source, link_path, env).await,
462        }
463    }
464    #[cfg(not(unix))]
465    {
466        create_windows_shims(source, link_path, runtime, env, render_target).await
467    }
468}
469
470async fn remove_link(link_path: &Path) -> Result<()> {
471    #[cfg(unix)]
472    {
473        tokio::fs::remove_file(link_path)
474            .await
475            .with_context(|| format!("removing existing file: {link_path:?}"))
476    }
477    #[cfg(not(unix))]
478    {
479        remove_windows_shims(link_path).await
480    }
481}
482
483/// Whether the existing regular file at `link_path` is a current/stale/foreign
484/// launcher for `source` under `runtime`. Native runtime on Unix has no managed
485/// regular-file form (its links are symlinks), so any regular file is `NotManaged`
486/// (a user-file conflict).
487async fn launcher_status(
488    link_path: &Path,
489    source: &Path,
490    runtime: LinkRuntime,
491    env: &[String],
492    render_target: Option<&str>,
493) -> Result<LauncherStatus> {
494    #[cfg(unix)]
495    {
496        if let Some(target) = render_target {
497            return unix_live_launcher_status(link_path, source, runtime, env, target).await;
498        }
499        match runtime {
500            LinkRuntime::Bun => unix_launcher_status(link_path, source, env).await,
501            LinkRuntime::Native => Ok(LauncherStatus::NotManaged),
502        }
503    }
504    #[cfg(not(unix))]
505    {
506        windows_shim_status(link_path, source, runtime, env, render_target).await
507    }
508}
509
510#[cfg(unix)]
511fn shell_single_quote(value: &str) -> String {
512    format!("'{}'", value.replace('\'', "'\\''"))
513}
514
515/// Deterministic content of a Unix bun launcher. Regenerated byte-for-byte by
516/// `unix_launcher_status` to detect staleness, so any change here is a format
517/// change that will refresh installed launchers on upgrade.
518#[cfg(unix)]
519fn unix_bun_launcher_content(source: &Path, name: &str, env: &[String]) -> String {
520    let target = source.display().to_string();
521    let quoted_target = shell_single_quote(&target);
522    let quoted_name = shell_single_quote(name);
523    // Empty `env` reproduces the v1 launcher byte-for-byte (no `shine` dependency);
524    // a declared `env` adds a `shine` presence check and runs the child through
525    // `shine env run --no-workspace` so values reach Bun via `Bun.env`.
526    let (shine_check, runner) = if env.is_empty() {
527        (String::new(), format!("exec bun {quoted_target} \"$@\"\n"))
528    } else {
529        let with_args = env
530            .iter()
531            .map(|token| format!("--with {}", shell_single_quote(token)))
532            .collect::<Vec<_>>()
533            .join(" ");
534        (
535            format!(
536                "if ! command -v shine >/dev/null 2>&1; then\n  \
537                 printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n  \
538                 exit 127\nfi\n"
539            ),
540            format!(
541                "exec shine env run --no-workspace {with_args} -- bun {quoted_target} \"$@\"\n"
542            ),
543        )
544    };
545    format!(
546        "#!/usr/bin/env bash\n\
547         {SHIM_MANAGED_MARKER}\n\
548         {SHIM_TARGET_PREFIX}{target}\n\
549         if ! command -v bun >/dev/null 2>&1; then\n  \
550         printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n  \
551         printf 'shine: install Bun from https://bun.sh, then re-run %s.\\n' {quoted_name} >&2\n  \
552         exit 127\nfi\n\
553         {shine_check}{runner}"
554    )
555}
556
557#[cfg(unix)]
558fn unix_live_launcher_content(
559    source: &Path,
560    name: &str,
561    runtime: LinkRuntime,
562    env: &[String],
563    render_target: &str,
564) -> String {
565    let target = source.display().to_string();
566    let quoted_source = shell_single_quote(&target);
567    let quoted_name = shell_single_quote(name);
568    let quoted_render_target = shell_single_quote(render_target);
569    let config_dir = live_config_dir(source);
570    let config_arg = if config_dir.file_name() == Some(OsStr::new(".shine")) {
571        String::new()
572    } else {
573        format!(
574            "--config-dir {} ",
575            shell_single_quote(&config_dir.display().to_string())
576        )
577    };
578    let render = format!(
579        "if ! command -v shine >/dev/null 2>&1; then\n  \
580         printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n  \
581         return 127 2>/dev/null || exit 127\nfi\n\
582         shine {config_arg}__shell-render {quoted_render_target} || {{ _shine_code=$?; return $_shine_code 2>/dev/null || exit $_shine_code; }}\n"
583    );
584    let runner = match runtime {
585        LinkRuntime::Native => format!(
586            "_shine_sourced=false\n\
587             case \"$ZSH_EVAL_CONTEXT\" in *:file|*:file:*) _shine_sourced=true ;; esac\n\
588             if [ -n \"$BASH_VERSION\" ] && [ \"$BASH_SOURCE\" != \"$0\" ]; then _shine_sourced=true; fi\n\
589             if [ \"$_shine_sourced\" = true ]; then\n  . {quoted_source} \"$@\"\n  return $?\nfi\n\
590             exec {quoted_source} \"$@\"\n"
591        ),
592        LinkRuntime::Bun => {
593            let bun_check = format!(
594                "if ! command -v bun >/dev/null 2>&1; then\n  \
595                 printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n  \
596                 exit 127\nfi\n"
597            );
598            if env.is_empty() {
599                format!("{bun_check}exec bun {quoted_source} \"$@\"\n")
600            } else {
601                let with_args = env
602                    .iter()
603                    .map(|token| format!("--with {}", shell_single_quote(token)))
604                    .collect::<Vec<_>>()
605                    .join(" ");
606                format!(
607                    "{bun_check}exec shine env run --no-workspace {with_args} -- bun {quoted_source} \"$@\"\n"
608                )
609            }
610        }
611    };
612    format!(
613        "#!/usr/bin/env bash\n{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{runner}"
614    )
615}
616
617fn live_config_dir(rendered_source: &Path) -> PathBuf {
618    rendered_source
619        .ancestors()
620        .find(|path| path.file_name() == Some(OsStr::new("rendered")))
621        .and_then(Path::parent)
622        .map(Path::to_path_buf)
623        .unwrap_or_else(|| {
624            rendered_source
625                .parent()
626                .unwrap_or_else(|| Path::new("."))
627                .to_path_buf()
628        })
629}
630
631#[cfg(unix)]
632async fn write_unix_live_launcher(
633    source: &Path,
634    link_path: &Path,
635    runtime: LinkRuntime,
636    env: &[String],
637    render_target: &str,
638) -> Result<()> {
639    use std::os::unix::fs::PermissionsExt;
640    if let Some(parent) = link_path.parent() {
641        tokio::fs::create_dir_all(parent).await?;
642    }
643    let name = launcher_command_name(link_path);
644    let content = unix_live_launcher_content(source, &name, runtime, env, render_target);
645    crate::persist::atomic_write(link_path, content.as_bytes()).await?;
646    tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755)).await?;
647    Ok(())
648}
649
650#[cfg(unix)]
651async fn unix_live_launcher_status(
652    link_path: &Path,
653    source: &Path,
654    runtime: LinkRuntime,
655    env: &[String],
656    render_target: &str,
657) -> Result<LauncherStatus> {
658    let content = match tokio::fs::read_to_string(link_path).await {
659        Ok(content) => content,
660        Err(_) => return Ok(LauncherStatus::NotManaged),
661    };
662    if !content.contains(SHIM_MANAGED_MARKER) {
663        return Ok(LauncherStatus::NotManaged);
664    }
665    let Some(target) = shim_target_from_content(&content) else {
666        return Ok(LauncherStatus::Stale);
667    };
668    if target.as_os_str() != source.as_os_str() {
669        return Ok(LauncherStatus::NotManaged);
670    }
671    let name = launcher_command_name(link_path);
672    if content == unix_live_launcher_content(source, &name, runtime, env, render_target) {
673        Ok(LauncherStatus::Current)
674    } else {
675        Ok(LauncherStatus::Stale)
676    }
677}
678
679#[cfg(unix)]
680async fn write_unix_bun_launcher(source: &Path, link_path: &Path, env: &[String]) -> Result<()> {
681    use std::os::unix::fs::PermissionsExt;
682    if let Some(parent) = link_path.parent() {
683        tokio::fs::create_dir_all(parent)
684            .await
685            .with_context(|| format!("creating bin dir: {parent:?}"))?;
686    }
687    let name = launcher_command_name(link_path);
688    tokio::fs::write(link_path, unix_bun_launcher_content(source, &name, env))
689        .await
690        .with_context(|| format!("writing bun launcher: {link_path:?}"))?;
691    tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755))
692        .await
693        .with_context(|| format!("setting bun launcher permissions: {link_path:?}"))?;
694    Ok(())
695}
696
697#[cfg(unix)]
698async fn unix_launcher_status(
699    link_path: &Path,
700    source: &Path,
701    env: &[String],
702) -> Result<LauncherStatus> {
703    let content = match tokio::fs::read_to_string(link_path).await {
704        Ok(content) => content,
705        // Missing, non-UTF-8, or otherwise unreadable → treat as a user file.
706        Err(_) => return Ok(LauncherStatus::NotManaged),
707    };
708    if !content.contains(SHIM_MANAGED_MARKER) {
709        return Ok(LauncherStatus::NotManaged);
710    }
711    let Some(target) = shim_target_from_content(&content) else {
712        return Ok(LauncherStatus::Stale);
713    };
714    if target.as_os_str() != source.as_os_str() {
715        return Ok(LauncherStatus::NotManaged);
716    }
717    let name = launcher_command_name(link_path);
718    // Byte comparison against the regenerated content — which embeds the ordered
719    // `env` spec — so an added/removed/reordered declaration is detected as stale.
720    if content == unix_bun_launcher_content(source, &name, env) {
721        Ok(LauncherStatus::Current)
722    } else {
723        Ok(LauncherStatus::Stale)
724    }
725}
726
727#[cfg(not(unix))]
728async fn create_windows_shims(
729    source: &Path,
730    ps1_path: &Path,
731    runtime: LinkRuntime,
732    env: &[String],
733    render_target: Option<&str>,
734) -> Result<()> {
735    let cmd_path = ps1_path.with_extension("cmd");
736    if let Some(parent) = ps1_path.parent() {
737        tokio::fs::create_dir_all(parent)
738            .await
739            .with_context(|| format!("creating bin dir: {parent:?}"))?;
740    }
741    let name = launcher_command_name(ps1_path);
742    tokio::fs::write(
743        ps1_path,
744        powershell_shim_content(source, runtime, &name, env, render_target),
745    )
746    .await
747    .with_context(|| format!("writing PowerShell shim: {ps1_path:?}"))?;
748    tokio::fs::write(
749        &cmd_path,
750        cmd_shim_content(source, runtime, &name, env, render_target),
751    )
752    .await
753    .with_context(|| format!("writing cmd shim: {cmd_path:?}"))?;
754    Ok(())
755}
756
757#[cfg(not(unix))]
758fn powershell_shim_content(
759    source: &Path,
760    runtime: LinkRuntime,
761    name: &str,
762    env: &[String],
763    render_target: Option<&str>,
764) -> String {
765    let target = windows_native_path(source);
766    let escaped = target.replace('\'', "''");
767    let render = render_target.map_or_else(String::new, |render_target| {
768        let render_target = render_target.replace('\'', "''");
769        let config_dir = windows_native_path(&live_config_dir(source)).replace('\'', "''");
770        let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
771            String::new()
772        } else {
773            format!("--config-dir '{config_dir}' ")
774        };
775        format!(
776            "$shineDotSourced = $MyInvocation.InvocationName -eq '.'\nif (-not (Get-Command shine -ErrorAction SilentlyContinue)) {{\n  [Console]::Error.WriteLine('shine: live transformed command requires shine on PATH.')\n  if ($shineDotSourced) {{ return }} else {{ exit 127 }}\n}}\n& shine {config_arg}__shell-render '{render_target}'\nif ($LASTEXITCODE -ne 0) {{\n  $shineRenderCode = $LASTEXITCODE\n  if ($shineDotSourced) {{ return }} else {{ exit $shineRenderCode }}\n}}\n"
777        )
778    });
779    match runtime {
780        LinkRuntime::Bun => {
781            let name_escaped = name.replace('\'', "''");
782            let bun_check = format!(
783                "if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {{\n  [Console]::Error.WriteLine('shine: {name_escaped} requires Bun, which was not found on PATH. Install from https://bun.sh')\n  exit 127\n}}\n"
784            );
785            if env.is_empty() {
786                format!(
787                    "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{bun_check}& bun '{escaped}' @args\nexit $LASTEXITCODE\n"
788                )
789            } else {
790                let with_args = env
791                    .iter()
792                    .map(|token| format!("--with '{}'", token.replace('\'', "''")))
793                    .collect::<Vec<_>>()
794                    .join(" ");
795                format!(
796                    "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{bun_check}if (-not (Get-Command shine -ErrorAction SilentlyContinue)) {{\n  [Console]::Error.WriteLine('shine: {name_escaped} requires the shine command, which was not found on PATH.')\n  exit 127\n}}\n& shine env run --no-workspace {with_args} -- bun '{escaped}' @args\nexit $LASTEXITCODE\n"
797                )
798            }
799        }
800        LinkRuntime::Native => {
801            let bash_target = bash_compatible_path(source);
802            let bash_escaped = bash_target.replace('\'', "''");
803            match source.extension().and_then(|e| e.to_str()) {
804                Some("ps1") => format!(
805                    "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}if ($MyInvocation.InvocationName -eq '.') {{\n  . '{escaped}' @args\n}} else {{\n  & '{escaped}' @args\n  exit $LASTEXITCODE\n}}\n"
806                ),
807                _ => format!(
808                    "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}& bash '{bash_escaped}' @args\nexit $LASTEXITCODE\n"
809                ),
810            }
811        }
812    }
813}
814
815#[cfg(not(unix))]
816fn cmd_shim_content(
817    source: &Path,
818    runtime: LinkRuntime,
819    name: &str,
820    env: &[String],
821    render_target: Option<&str>,
822) -> String {
823    let target = windows_native_path(source);
824    let render = render_target.map_or_else(String::new, |render_target| {
825        let config_dir = windows_native_path(&live_config_dir(source));
826        let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
827            String::new()
828        } else {
829            format!("--config-dir \"{config_dir}\" ")
830        };
831        format!(
832            "where shine >nul 2>nul\r\nif errorlevel 1 exit /b 127\r\nshine {config_arg}__shell-render \"{render_target}\"\r\nif errorlevel 1 exit /b %errorlevel%\r\n"
833        )
834    });
835    match runtime {
836        LinkRuntime::Bun => {
837            let bun_check = format!(
838                "where bun >nul 2>nul\r\nif errorlevel 1 (\r\n  echo shine: {name} requires Bun, which was not found on PATH. Install from https://bun.sh 1>&2\r\n  exit /b 127\r\n)\r\n"
839            );
840            if env.is_empty() {
841                format!(
842                    "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}{bun_check}bun \"{target}\" %*\r\n"
843                )
844            } else {
845                let with_args = env
846                    .iter()
847                    .map(|token| format!("--with {token}"))
848                    .collect::<Vec<_>>()
849                    .join(" ");
850                format!(
851                    "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}{bun_check}where shine >nul 2>nul\r\nif errorlevel 1 (\r\n  echo shine: {name} requires the shine command, which was not found on PATH. 1>&2\r\n  exit /b 127\r\n)\r\nshine env run --no-workspace {with_args} -- bun \"{target}\" %*\r\n"
852                )
853            }
854        }
855        LinkRuntime::Native => {
856            let escaped = target.replace('\'', "''");
857            let bash_target = bash_compatible_path(source);
858            match source.extension().and_then(|e| e.to_str()) {
859                Some("ps1") => format!(
860                    "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"{escaped}\" %*\r\n"
861                ),
862                _ => format!(
863                    "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}bash \"{bash_target}\" %*\r\n"
864                ),
865            }
866        }
867    }
868}
869
870#[cfg(not(unix))]
871fn bash_compatible_path(path: &Path) -> String {
872    windows_native_path(path).replace('\\', "/")
873}
874
875#[cfg(not(unix))]
876fn windows_native_path(path: &Path) -> String {
877    crate::path_display::strip_windows_verbatim_prefix(&path.display().to_string())
878}
879
880#[cfg(not(unix))]
881async fn windows_shim_status(
882    link_path: &Path,
883    source: &Path,
884    runtime: LinkRuntime,
885    env: &[String],
886    render_target: Option<&str>,
887) -> Result<LauncherStatus> {
888    let content = match tokio::fs::read_to_string(link_path).await {
889        Ok(content) => content,
890        // Missing or unreadable (e.g. non-UTF-8 user file) → treat as a user file.
891        Err(_) => return Ok(LauncherStatus::NotManaged),
892    };
893    if !content.contains(SHIM_MANAGED_MARKER) {
894        return Ok(LauncherStatus::NotManaged);
895    }
896
897    let Some(target) = shim_target_from_content(&content) else {
898        return Ok(LauncherStatus::Stale);
899    };
900    if windows_path_key(&target) != windows_path_key(source) {
901        return Ok(LauncherStatus::NotManaged);
902    }
903
904    let name = launcher_command_name(link_path);
905    let expected_ps1 = powershell_shim_content(source, runtime, &name, env, render_target);
906    let expected_cmd = cmd_shim_content(source, runtime, &name, env, render_target);
907    let cmd_path = link_path.with_extension("cmd");
908    let cmd_content = tokio::fs::read_to_string(&cmd_path).await.ok();
909    if content == expected_ps1 && cmd_content.as_deref() == Some(expected_cmd.as_str()) {
910        Ok(LauncherStatus::Current)
911    } else {
912        Ok(LauncherStatus::Stale)
913    }
914}
915
916#[cfg(not(unix))]
917fn windows_path_key(path: &Path) -> String {
918    windows_native_path(path)
919        .replace('\\', "/")
920        .to_ascii_lowercase()
921}
922
923#[cfg(not(unix))]
924async fn remove_windows_shims(ps1_path: &Path) -> Result<()> {
925    let cmd_path = ps1_path.with_extension("cmd");
926    match tokio::fs::remove_file(ps1_path).await {
927        Ok(()) => {}
928        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
929        Err(err) => return Err(err).with_context(|| format!("removing shim: {ps1_path:?}")),
930    }
931    match tokio::fs::remove_file(&cmd_path).await {
932        Ok(()) => {}
933        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
934        Err(err) => return Err(err).with_context(|| format!("removing shim: {cmd_path:?}")),
935    }
936    Ok(())
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use tokio::fs;
943
944    async fn make_dirs() -> (PathBuf, PathBuf) {
945        let id = uuid::Uuid::new_v4();
946        let src_dir = std::env::temp_dir().join(format!("shine-bl-src-{id}"));
947        let bin_dir = std::env::temp_dir().join(format!("shine-bl-bin-{id}"));
948        fs::create_dir_all(&src_dir).await.unwrap();
949        fs::create_dir_all(&bin_dir).await.unwrap();
950        (src_dir, bin_dir)
951    }
952
953    /// Write a file and set the executable bit so `is_executable` returns true.
954    #[cfg(unix)]
955    async fn make_executable(dir: &Path, name: &str) -> PathBuf {
956        use std::os::unix::fs::PermissionsExt;
957        let path = dir.join(name);
958        fs::write(&path, b"#!/bin/sh\n").await.unwrap();
959        let mut perms = fs::metadata(&path).await.unwrap().permissions();
960        perms.set_mode(0o755);
961        fs::set_permissions(&path, perms).await.unwrap();
962        path
963    }
964
965    async fn make_plain(dir: &Path, name: &str) -> PathBuf {
966        let path = dir.join(name);
967        fs::write(&path, b"data").await.unwrap();
968        path
969    }
970
971    #[cfg(unix)]
972    #[tokio::test]
973    async fn creates_symlink_for_executable_source() {
974        let (src, bin) = make_dirs().await;
975        let exe = make_executable(&src, "run.sh").await;
976
977        let report = link_executables(&bin, std::slice::from_ref(&exe), false)
978            .await
979            .unwrap();
980
981        assert_eq!(report.created.len(), 1);
982        let link = &report.created[0];
983        assert!(link.is_symlink());
984        assert_eq!(fs::read_link(link).await.unwrap(), exe);
985        // symlink name is the stem, not the full filename
986        assert_eq!(link.file_name().unwrap(), "run");
987
988        fs::remove_dir_all(&src).await.unwrap();
989        fs::remove_dir_all(&bin).await.unwrap();
990    }
991
992    #[cfg(unix)]
993    #[tokio::test]
994    async fn skips_non_executable_source() {
995        let (src, bin) = make_dirs().await;
996        let plain = make_plain(&src, "readme.txt").await;
997
998        let report = link_executables(&bin, &[plain], false).await.unwrap();
999
1000        assert!(report.created.is_empty());
1001        assert!(report.skipped.is_empty());
1002
1003        fs::remove_dir_all(&src).await.unwrap();
1004        fs::remove_dir_all(&bin).await.unwrap();
1005    }
1006
1007    #[cfg(unix)]
1008    #[tokio::test]
1009    async fn skips_when_correct_symlink_already_exists() {
1010        let (src, bin) = make_dirs().await;
1011        let exe = make_executable(&src, "run.sh").await;
1012        tokio::fs::symlink(&exe, bin.join("run")).await.unwrap();
1013
1014        let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1015            .await
1016            .unwrap();
1017
1018        assert!(report.created.is_empty());
1019        assert_eq!(report.skipped.len(), 1);
1020
1021        fs::remove_dir_all(&src).await.unwrap();
1022        fs::remove_dir_all(&bin).await.unwrap();
1023    }
1024
1025    #[cfg(unix)]
1026    #[tokio::test]
1027    async fn reports_conflict_when_regular_file_exists() {
1028        let (src, bin) = make_dirs().await;
1029        let exe = make_executable(&src, "run.sh").await;
1030        make_plain(&bin, "run").await;
1031
1032        let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1033            .await
1034            .unwrap();
1035
1036        assert!(report.created.is_empty());
1037        assert_eq!(report.conflicts.len(), 1);
1038        assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1039        assert_eq!(report.conflicts[0].source, exe);
1040        assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1041
1042        fs::remove_dir_all(&src).await.unwrap();
1043        fs::remove_dir_all(&bin).await.unwrap();
1044    }
1045
1046    #[cfg(unix)]
1047    #[tokio::test]
1048    async fn overwrites_stale_symlink_when_overwrite_true() {
1049        let (src, bin) = make_dirs().await;
1050        let exe = make_executable(&src, "run.sh").await;
1051        let other = make_executable(&src, "other.sh").await;
1052        tokio::fs::symlink(&other, bin.join("run")).await.unwrap();
1053
1054        let report = link_executables(&bin, std::slice::from_ref(&exe), true)
1055            .await
1056            .unwrap();
1057
1058        assert_eq!(report.overwritten.len(), 1);
1059        assert_eq!(fs::read_link(bin.join("run")).await.unwrap(), exe);
1060
1061        fs::remove_dir_all(&src).await.unwrap();
1062        fs::remove_dir_all(&bin).await.unwrap();
1063    }
1064
1065    #[cfg(unix)]
1066    #[tokio::test]
1067    async fn flattens_nested_preset_path_into_bin_dir() {
1068        let (src, bin) = make_dirs().await;
1069        let sub = src.join("shell").join("proxy");
1070        fs::create_dir_all(&sub).await.unwrap();
1071        let exe = {
1072            use std::os::unix::fs::PermissionsExt;
1073            let path = sub.join("set_proxy.sh");
1074            fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1075            let mut perms = fs::metadata(&path).await.unwrap().permissions();
1076            perms.set_mode(0o755);
1077            fs::set_permissions(&path, perms).await.unwrap();
1078            path
1079        };
1080
1081        let report = link_executables(&bin, &[exe], false).await.unwrap();
1082
1083        assert_eq!(report.created.len(), 1);
1084        assert!(bin.join("set_proxy").exists());
1085
1086        fs::remove_dir_all(&src).await.unwrap();
1087        fs::remove_dir_all(&bin).await.unwrap();
1088    }
1089
1090    #[cfg(unix)]
1091    #[tokio::test]
1092    async fn reports_collision_when_two_sources_share_basename() {
1093        let (src, bin) = make_dirs().await;
1094        let sub1 = src.join("a");
1095        let sub2 = src.join("b");
1096        fs::create_dir_all(&sub1).await.unwrap();
1097        fs::create_dir_all(&sub2).await.unwrap();
1098        let exe1 = make_executable(&sub1, "run.sh").await;
1099        let exe2 = make_executable(&sub2, "run.sh").await;
1100
1101        let report = link_executables(&bin, &[exe1, exe2.clone()], false)
1102            .await
1103            .unwrap();
1104
1105        assert_eq!(report.created.len(), 1);
1106        assert_eq!(report.conflicts.len(), 1);
1107        assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1108        assert_eq!(report.conflicts[0].source, exe2);
1109        assert_eq!(report.conflicts[0].kind, LinkConflictKind::DuplicateName);
1110
1111        fs::remove_dir_all(&src).await.unwrap();
1112        fs::remove_dir_all(&bin).await.unwrap();
1113    }
1114
1115    #[cfg(unix)]
1116    #[tokio::test]
1117    async fn creates_symlink_with_explicit_link_name() {
1118        let (src, bin) = make_dirs().await;
1119        let exe = make_executable(&src, "set_proxy.sh").await;
1120        let specs = [LinkSpec {
1121            source: exe.clone(),
1122            link_name: OsString::from("setproxy"),
1123            runtime: LinkRuntime::Native,
1124            env: Vec::new(),
1125            render_target: None,
1126        }];
1127
1128        let report = link_executables_with_names(&bin, &specs, false)
1129            .await
1130            .unwrap();
1131
1132        assert_eq!(report.created.len(), 1);
1133        assert!(bin.join("setproxy").exists());
1134        assert!(!bin.join("set_proxy").exists());
1135        assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), exe);
1136
1137        fs::remove_dir_all(&src).await.unwrap();
1138        fs::remove_dir_all(&bin).await.unwrap();
1139    }
1140
1141    #[cfg(unix)]
1142    #[tokio::test]
1143    async fn links_non_executable_shell_script_source() {
1144        let (src, bin) = make_dirs().await;
1145        let script = src.join("set_proxy.sh");
1146        fs::write(&script, b"#!/bin/sh\n").await.unwrap();
1147        let specs = [LinkSpec {
1148            source: script.clone(),
1149            link_name: OsString::from("setproxy"),
1150            runtime: LinkRuntime::Native,
1151            env: Vec::new(),
1152            render_target: None,
1153        }];
1154
1155        let report = link_executables_with_names(&bin, &specs, false)
1156            .await
1157            .unwrap();
1158
1159        assert_eq!(report.created.len(), 1);
1160        assert!(bin.join("setproxy").exists());
1161        assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), script);
1162
1163        fs::remove_dir_all(&src).await.unwrap();
1164        fs::remove_dir_all(&bin).await.unwrap();
1165    }
1166
1167    #[cfg(unix)]
1168    #[tokio::test]
1169    async fn skips_non_executable_non_script_source_with_custom_name() {
1170        let (src, bin) = make_dirs().await;
1171        let plain = make_plain(&src, "proxy.txt").await;
1172        let specs = [LinkSpec {
1173            source: plain,
1174            link_name: OsString::from("setproxy"),
1175            runtime: LinkRuntime::Native,
1176            env: Vec::new(),
1177            render_target: None,
1178        }];
1179
1180        let report = link_executables_with_names(&bin, &specs, false)
1181            .await
1182            .unwrap();
1183
1184        assert!(report.created.is_empty());
1185        assert!(!bin.join("setproxy").exists());
1186
1187        fs::remove_dir_all(&src).await.unwrap();
1188        fs::remove_dir_all(&bin).await.unwrap();
1189    }
1190
1191    // --- unlink_managed tests ---
1192
1193    #[cfg(unix)]
1194    #[tokio::test]
1195    async fn unlink_removes_symlink_pointing_into_managed_root() {
1196        let (src, bin) = make_dirs().await;
1197        let exe = make_executable(&src, "run.sh").await;
1198        tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1199
1200        let report = unlink_managed(&bin, &src, false).await.unwrap();
1201
1202        assert_eq!(report.removed.len(), 1);
1203        assert!(!bin.join("run.sh").exists());
1204
1205        fs::remove_dir_all(&src).await.unwrap();
1206        fs::remove_dir_all(&bin).await.unwrap();
1207    }
1208
1209    #[cfg(unix)]
1210    #[tokio::test]
1211    async fn unlink_skips_symlink_outside_managed_root() {
1212        let (src, bin) = make_dirs().await;
1213        let outside = std::env::temp_dir().join(format!("shine-bl-out-{}", uuid::Uuid::new_v4()));
1214        fs::create_dir_all(&outside).await.unwrap();
1215        let exe = make_executable(&outside, "run.sh").await;
1216        tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1217
1218        let report = unlink_managed(&bin, &src, false).await.unwrap();
1219
1220        assert_eq!(report.skipped.len(), 1);
1221        assert!(bin.join("run.sh").is_symlink());
1222
1223        fs::remove_dir_all(&src).await.unwrap();
1224        fs::remove_dir_all(&bin).await.unwrap();
1225        fs::remove_dir_all(&outside).await.unwrap();
1226    }
1227
1228    #[cfg(unix)]
1229    #[tokio::test]
1230    async fn unlink_skips_regular_files_in_bin_dir() {
1231        let (src, bin) = make_dirs().await;
1232        make_plain(&bin, "user_script.sh").await;
1233
1234        let report = unlink_managed(&bin, &src, false).await.unwrap();
1235
1236        assert!(report.removed.is_empty());
1237        assert_eq!(report.skipped.len(), 1);
1238        assert!(bin.join("user_script.sh").exists());
1239
1240        fs::remove_dir_all(&src).await.unwrap();
1241        fs::remove_dir_all(&bin).await.unwrap();
1242    }
1243
1244    #[cfg(unix)]
1245    #[tokio::test]
1246    async fn unlink_dry_run_reports_but_does_not_remove() {
1247        let (src, bin) = make_dirs().await;
1248        let exe = make_executable(&src, "run.sh").await;
1249        tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1250
1251        let report = unlink_managed(&bin, &src, true).await.unwrap();
1252
1253        assert_eq!(report.removed.len(), 1);
1254        assert!(bin.join("run.sh").is_symlink(), "dry-run must not remove");
1255
1256        fs::remove_dir_all(&src).await.unwrap();
1257        fs::remove_dir_all(&bin).await.unwrap();
1258    }
1259
1260    #[cfg(unix)]
1261    #[tokio::test]
1262    async fn unlink_is_idempotent_on_empty_bin_dir() {
1263        let (src, bin) = make_dirs().await;
1264
1265        let r1 = unlink_managed(&bin, &src, false).await.unwrap();
1266        let r2 = unlink_managed(&bin, &src, false).await.unwrap();
1267
1268        assert!(r1.removed.is_empty());
1269        assert!(r2.removed.is_empty());
1270
1271        fs::remove_dir_all(&src).await.unwrap();
1272        fs::remove_dir_all(&bin).await.unwrap();
1273    }
1274
1275    #[tokio::test]
1276    async fn unlink_returns_empty_report_when_bin_dir_missing() {
1277        let missing = std::env::temp_dir().join(format!("shine-bl-miss-{}", uuid::Uuid::new_v4()));
1278        let managed = std::env::temp_dir().join(format!("shine-bl-mgd-{}", uuid::Uuid::new_v4()));
1279
1280        let report = unlink_managed(&missing, &managed, false).await.unwrap();
1281
1282        assert!(report.removed.is_empty());
1283        assert!(report.skipped.is_empty());
1284    }
1285
1286    #[test]
1287    fn link_stem_strips_bun_extensions() {
1288        assert_eq!(link_stem(Path::new("tool.ts")), OsString::from("tool"));
1289        assert_eq!(link_stem(Path::new("tool.js")), OsString::from("tool"));
1290        assert_eq!(link_stem(Path::new("tool.mts")), OsString::from("tool"));
1291        assert_eq!(link_stem(Path::new("tool.mjs")), OsString::from("tool"));
1292    }
1293
1294    #[cfg(unix)]
1295    fn bun_spec(source: &Path, name: &str) -> LinkSpec {
1296        bun_spec_with_env(source, name, Vec::new())
1297    }
1298
1299    #[cfg(unix)]
1300    fn bun_spec_with_env(source: &Path, name: &str, env: Vec<String>) -> LinkSpec {
1301        LinkSpec {
1302            source: source.to_path_buf(),
1303            link_name: OsString::from(name),
1304            runtime: LinkRuntime::Bun,
1305            env,
1306            render_target: None,
1307        }
1308    }
1309
1310    #[cfg(unix)]
1311    #[tokio::test]
1312    async fn creates_bun_launcher_as_marked_executable_regular_file() {
1313        use std::os::unix::fs::PermissionsExt;
1314        let (src, bin) = make_dirs().await;
1315        let script = src.join("tool.ts");
1316        fs::write(&script, b"console.log('hi')\n").await.unwrap();
1317
1318        let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1319            .await
1320            .unwrap();
1321
1322        assert_eq!(report.created.len(), 1);
1323        let launcher = bin.join("tool");
1324        assert!(launcher.exists());
1325        assert!(
1326            !launcher.is_symlink(),
1327            "bun launcher must be a regular file, not a symlink"
1328        );
1329        let content = fs::read_to_string(&launcher).await.unwrap();
1330        assert!(content.contains("# shine-managed"));
1331        assert!(content.contains(&format!("# shine-target: {}", script.display())));
1332        assert!(content.contains("command -v bun"));
1333        assert!(content.contains("exit 127"));
1334        assert!(content.contains(&format!("exec bun '{}' \"$@\"", script.display())));
1335        let mode = fs::metadata(&launcher).await.unwrap().permissions().mode();
1336        assert!(mode & 0o111 != 0, "launcher must be executable");
1337
1338        fs::remove_dir_all(&src).await.unwrap();
1339        fs::remove_dir_all(&bin).await.unwrap();
1340    }
1341
1342    #[cfg(unix)]
1343    #[tokio::test]
1344    async fn bun_launcher_is_idempotent_and_refreshes_when_stale() {
1345        let (src, bin) = make_dirs().await;
1346        let script = src.join("tool.ts");
1347        fs::write(&script, b"console.log('hi')\n").await.unwrap();
1348
1349        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1350            .await
1351            .unwrap();
1352        let again = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1353            .await
1354            .unwrap();
1355        assert_eq!(
1356            again.skipped.len(),
1357            1,
1358            "identical launcher should be skipped"
1359        );
1360        assert!(again.created.is_empty());
1361        assert!(again.overwritten.is_empty());
1362
1363        // Same marker + target but different body → stale, refreshed without --force.
1364        let launcher = bin.join("tool");
1365        fs::write(
1366            &launcher,
1367            format!(
1368                "#!/usr/bin/env bash\n# shine-managed\n# shine-target: {}\necho stale\n",
1369                script.display()
1370            ),
1371        )
1372        .await
1373        .unwrap();
1374        let refreshed = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1375            .await
1376            .unwrap();
1377        assert_eq!(
1378            refreshed.overwritten.len(),
1379            1,
1380            "stale launcher should refresh"
1381        );
1382        assert!(
1383            fs::read_to_string(&launcher)
1384                .await
1385                .unwrap()
1386                .contains("exec bun")
1387        );
1388
1389        fs::remove_dir_all(&src).await.unwrap();
1390        fs::remove_dir_all(&bin).await.unwrap();
1391    }
1392
1393    #[cfg(unix)]
1394    #[tokio::test]
1395    async fn bun_launcher_conflicts_with_user_file_unless_forced() {
1396        let (src, bin) = make_dirs().await;
1397        let script = src.join("tool.ts");
1398        fs::write(&script, b"console.log('hi')\n").await.unwrap();
1399        // A user's own file at the same command name, no managed marker.
1400        make_plain(&bin, "tool").await;
1401
1402        let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1403            .await
1404            .unwrap();
1405        assert_eq!(report.conflicts.len(), 1);
1406        assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1407        assert_eq!(fs::read_to_string(bin.join("tool")).await.unwrap(), "data");
1408
1409        let forced = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], true)
1410            .await
1411            .unwrap();
1412        assert_eq!(forced.overwritten.len(), 1);
1413        assert!(
1414            fs::read_to_string(bin.join("tool"))
1415                .await
1416                .unwrap()
1417                .contains("exec bun")
1418        );
1419
1420        fs::remove_dir_all(&src).await.unwrap();
1421        fs::remove_dir_all(&bin).await.unwrap();
1422    }
1423
1424    #[cfg(unix)]
1425    #[tokio::test]
1426    async fn unlink_removes_managed_bun_launcher_but_skips_user_file() {
1427        let (src, bin) = make_dirs().await;
1428        let script = src.join("tool.ts");
1429        fs::write(&script, b"console.log('hi')\n").await.unwrap();
1430        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1431            .await
1432            .unwrap();
1433        // A user's own regular file that must survive uninstall.
1434        make_plain(&bin, "user_tool").await;
1435
1436        let report = unlink_managed(&bin, &src, false).await.unwrap();
1437
1438        assert!(report.removed.iter().any(|p| p.ends_with("tool")));
1439        assert!(
1440            !bin.join("tool").exists(),
1441            "managed launcher should be removed"
1442        );
1443        assert!(
1444            bin.join("user_tool").exists(),
1445            "user file must be preserved"
1446        );
1447        assert!(report.skipped.iter().any(|p| p.ends_with("user_tool")));
1448
1449        fs::remove_dir_all(&src).await.unwrap();
1450        fs::remove_dir_all(&bin).await.unwrap();
1451    }
1452
1453    #[cfg(unix)]
1454    #[tokio::test]
1455    async fn bun_launcher_without_env_has_no_shine_dependency() {
1456        let (src, bin) = make_dirs().await;
1457        let script = src.join("tool.ts");
1458        fs::write(&script, b"console.log('hi')\n").await.unwrap();
1459
1460        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1461            .await
1462            .unwrap();
1463
1464        let content = fs::read_to_string(bin.join("tool")).await.unwrap();
1465        assert!(
1466            content.contains(&format!("exec bun '{}' \"$@\"", script.display())),
1467            "no-env launcher must run bun directly: {content}"
1468        );
1469        assert!(
1470            !content.contains("shine env run"),
1471            "no-env launcher must not depend on shine: {content}"
1472        );
1473
1474        fs::remove_dir_all(&src).await.unwrap();
1475        fs::remove_dir_all(&bin).await.unwrap();
1476    }
1477
1478    #[cfg(unix)]
1479    #[tokio::test]
1480    async fn bun_launcher_with_env_wraps_shine_env_run() {
1481        let (src, bin) = make_dirs().await;
1482        let script = src.join("tool.ts");
1483        fs::write(&script, b"console.log('hi')\n").await.unwrap();
1484
1485        let env = vec!["API_URL".to_string(), "SERVICE_TOKEN=API_TOKEN".to_string()];
1486        link_executables_with_names(&bin, &[bun_spec_with_env(&script, "tool", env)], false)
1487            .await
1488            .unwrap();
1489
1490        let content = fs::read_to_string(bin.join("tool")).await.unwrap();
1491        // Both prerequisites are checked with a 127 exit.
1492        assert!(content.contains("command -v bun"));
1493        assert!(content.contains("command -v shine"));
1494        assert_eq!(content.matches("exit 127").count(), 2);
1495        // The child runs through shine env run with the declared, ordered specs.
1496        assert!(content.contains(&format!(
1497            "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun '{}' \"$@\"",
1498            script.display()
1499        )));
1500
1501        fs::remove_dir_all(&src).await.unwrap();
1502        fs::remove_dir_all(&bin).await.unwrap();
1503    }
1504
1505    #[cfg(unix)]
1506    #[tokio::test]
1507    async fn bun_launcher_refreshes_when_env_declaration_changes() {
1508        let (src, bin) = make_dirs().await;
1509        let script = src.join("tool.ts");
1510        fs::write(&script, b"console.log('hi')\n").await.unwrap();
1511
1512        // Install with no env, then replace the same source with a declaration.
1513        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1514            .await
1515            .unwrap();
1516        let changed = link_executables_with_names(
1517            &bin,
1518            &[bun_spec_with_env(
1519                &script,
1520                "tool",
1521                vec!["API_URL".to_string()],
1522            )],
1523            false,
1524        )
1525        .await
1526        .unwrap();
1527        assert_eq!(
1528            changed.overwritten.len(),
1529            1,
1530            "adding an env declaration must refresh the launcher without --force"
1531        );
1532
1533        // Re-running with the same declaration is a no-op (byte-identical).
1534        let again = link_executables_with_names(
1535            &bin,
1536            &[bun_spec_with_env(
1537                &script,
1538                "tool",
1539                vec!["API_URL".to_string()],
1540            )],
1541            false,
1542        )
1543        .await
1544        .unwrap();
1545        assert_eq!(again.skipped.len(), 1);
1546        assert!(again.overwritten.is_empty());
1547
1548        fs::remove_dir_all(&src).await.unwrap();
1549        fs::remove_dir_all(&bin).await.unwrap();
1550    }
1551
1552    #[cfg(not(unix))]
1553    #[test]
1554    fn shell_shims_pass_bash_compatible_paths_on_windows() {
1555        let source = PathBuf::from(r"C:\Users\me\.shine\rendered\shell\utils\copyfile.sh");
1556
1557        let ps1 = powershell_shim_content(&source, LinkRuntime::Native, "copyfile", &[], None);
1558        let cmd = cmd_shim_content(&source, LinkRuntime::Native, "copyfile", &[], None);
1559
1560        assert!(ps1.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
1561        assert!(cmd.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
1562        assert!(!ps1.contains(r"& bash 'C:\Users\me"));
1563    }
1564}