Skip to main content

cli/
bin_links.rs

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