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