Skip to main content

shine_core/runtime/
launcher.rs

1use crate::runtime::{
2    BunDependencyMode, FileKind, FileSystemHost, FileSystemObservationHost, LinkRuntime,
3};
4#[cfg(test)]
5use anyhow::Context;
6use anyhow::Result;
7use std::collections::HashSet;
8use std::ffi::{OsStr, OsString};
9use std::path::{Path, PathBuf};
10
11const LINKABLE_SCRIPT_EXTENSIONS: &[&str] = &["sh", "bash", "zsh", "fish", "ps1"];
12
13/// Script extensions runnable through the `bun` runtime launcher.
14const BUN_SCRIPT_EXTENSIONS: &[&str] = &["ts", "js", "mts", "mjs"];
15
16#[cfg(not(unix))]
17const EXECUTABLE_EXTENSIONS: &[&str] = &["sh", "ps1"];
18
19// Marker lines identifying a shine-managed launcher. Unix and PowerShell use `#`;
20// cmd uses its native `REM` form. Ownership and current-ness detection accept both.
21const SHIM_MANAGED_MARKER: &str = "# shine-managed";
22const CMD_SHIM_MANAGED_MARKER: &str = "REM shine-managed";
23const SHIM_TARGET_PREFIX: &str = "# shine-target: ";
24
25/// Runtime used to invoke a linked command.
26///
27/// `Native` is the historical behavior: a Unix symlink or a Windows bash/PowerShell
28/// shim pointing directly at the script. `Bun` wraps the script in a generated
29/// launcher that runs `bun <script> "$@"` — a real regular file on Unix (not a
30/// symlink) carrying the managed marker, and a Bun shim on Windows.
31/// Whether an existing on-disk launcher/shim is a current, stale, or foreign file.
32///
33/// Shared by the Unix bun-launcher path and the Windows shim path. `NotManaged`
34/// protects user files: it means the file lacks the managed marker (or points at a
35/// different source), so it is treated as a conflict, never silently replaced.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37enum LauncherStatus {
38    Current,
39    Stale,
40    NotManaged,
41}
42
43pub struct LinkReport {
44    pub created: Vec<PathBuf>,
45    pub skipped: Vec<PathBuf>,
46    pub conflicts: Vec<LinkConflict>,
47    pub overwritten: Vec<PathBuf>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum LinkConflictKind {
52    ExistingEntry,
53    DuplicateName,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct LinkConflict {
58    pub link_path: PathBuf,
59    pub source: PathBuf,
60    pub kind: LinkConflictKind,
61}
62
63pub struct UnlinkReport {
64    pub removed: Vec<PathBuf>,
65    pub skipped: Vec<PathBuf>,
66}
67
68pub(crate) struct ManagedLauncherProbe {
69    pub resources: Vec<PreparedLauncherResource>,
70    pub conflicts: Vec<PathBuf>,
71}
72
73#[derive(Clone)]
74pub struct LinkSpec {
75    pub source: PathBuf,
76    pub link_name: OsString,
77    pub runtime: LinkRuntime,
78    /// Whether Bun may resolve locked third-party dependencies for this entry.
79    pub bun_dependencies: BunDependencyMode,
80    /// For `LinkRuntime::Bun`: ordered `--with` argument tokens (`KEY` or
81    /// `SOURCE=TARGET`) injected at launch through `shine env run`. When empty,
82    /// the launcher invokes Bun directly without a `shine env run` dependency.
83    pub env: Vec<String>,
84    /// Canonical installed target to lazily render before execution in external live mode.
85    pub render_target: Option<String>,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub(crate) enum PreparedLauncherResource {
90    Symlink {
91        destination: PathBuf,
92        target: PathBuf,
93    },
94    File {
95        destination: PathBuf,
96        bytes: Vec<u8>,
97        unix_mode: Option<u32>,
98    },
99}
100
101impl PreparedLauncherResource {
102    pub(crate) fn destination(&self) -> &Path {
103        match self {
104            Self::Symlink { destination, .. } | Self::File { destination, .. } => destination,
105        }
106    }
107}
108
109pub(crate) fn prepare_launcher_resources(
110    bin_dir: &Path,
111    spec: &LinkSpec,
112) -> Vec<PreparedLauncherResource> {
113    let link_path = command_path_for_name(bin_dir, &spec.link_name);
114    #[cfg(unix)]
115    {
116        let content = if let Some(target) = spec.render_target.as_deref() {
117            Some(unix_live_launcher_content(
118                &spec.source,
119                &launcher_command_name(&link_path),
120                spec.runtime,
121                spec.bun_dependencies,
122                &spec.env,
123                target,
124            ))
125        } else if spec.runtime == LinkRuntime::Bun {
126            Some(unix_bun_launcher_content(
127                &spec.source,
128                &launcher_command_name(&link_path),
129                spec.bun_dependencies,
130                &spec.env,
131            ))
132        } else {
133            None
134        };
135        match content {
136            Some(content) => vec![PreparedLauncherResource::File {
137                destination: link_path,
138                bytes: content.into_bytes(),
139                unix_mode: Some(0o755),
140            }],
141            None => vec![PreparedLauncherResource::Symlink {
142                destination: link_path,
143                target: spec.source.clone(),
144            }],
145        }
146    }
147    #[cfg(not(unix))]
148    {
149        let name = launcher_command_name(&link_path);
150        let ps1 = powershell_shim_content(
151            &spec.source,
152            spec.runtime,
153            &name,
154            spec.bun_dependencies,
155            &spec.env,
156            spec.render_target.as_deref(),
157        );
158        let cmd = cmd_shim_content(
159            &spec.source,
160            spec.runtime,
161            &name,
162            spec.bun_dependencies,
163            &spec.env,
164            spec.render_target.as_deref(),
165        );
166        vec![
167            PreparedLauncherResource::File {
168                destination: link_path.clone(),
169                bytes: ps1.into_bytes(),
170                unix_mode: None,
171            },
172            PreparedLauncherResource::File {
173                destination: link_path.with_extension("cmd"),
174                bytes: cmd.into_bytes(),
175                unix_mode: None,
176            },
177        ]
178    }
179}
180
181pub(crate) async fn apply_prepared_launcher_resource(
182    host: &impl FileSystemHost,
183    resource: &PreparedLauncherResource,
184) -> Result<()> {
185    let destination = resource.destination();
186    if let Some(parent) = destination.parent() {
187        host.create_dir_all(parent)
188            .await
189            .map_err(|error| error.into_anyhow("creating shell launcher directory"))?;
190    }
191    match resource {
192        PreparedLauncherResource::Symlink {
193            destination,
194            target,
195        } => host
196            .symlink(target, destination)
197            .await
198            .map_err(|error| error.into_anyhow("creating shell symlink")),
199        PreparedLauncherResource::File {
200            destination,
201            bytes,
202            unix_mode,
203        } => {
204            host.write_atomic(destination, bytes)
205                .await
206                .map_err(|error| error.into_anyhow("writing shell launcher"))?;
207            if let Some(mode) = unix_mode {
208                host.set_mode(destination, *mode)
209                    .await
210                    .map_err(|error| error.into_anyhow("setting shell launcher permissions"))?;
211            }
212            Ok(())
213        }
214    }
215}
216
217pub(crate) async fn prepared_launcher_resource_is_exact(
218    host: &impl FileSystemObservationHost,
219    resource: &PreparedLauncherResource,
220) -> Result<bool> {
221    let metadata = match host.metadata(resource.destination()).await {
222        Ok(metadata) => metadata,
223        Err(error) if error.is_not_found() => return Ok(false),
224        Err(error) => return Err(error.into_anyhow("observing prepared Shell launcher")),
225    };
226    Ok(match resource {
227        PreparedLauncherResource::Symlink { target, .. } => {
228            metadata.kind == FileKind::Symlink
229                && host
230                    .read_link(resource.destination())
231                    .await
232                    .is_ok_and(|current| current == *target)
233        }
234        PreparedLauncherResource::File {
235            bytes, unix_mode, ..
236        } => {
237            metadata.kind == FileKind::File
238                && host
239                    .read(resource.destination())
240                    .await
241                    .is_ok_and(|current| current == *bytes)
242                && unix_mode.is_none_or(|expected| {
243                    metadata
244                        .unix_mode
245                        .is_some_and(|mode| mode & 0o777 == expected)
246                })
247        }
248    })
249}
250
251async fn host_remove_link(host: &impl FileSystemHost, link_path: &Path) -> Result<()> {
252    #[cfg(unix)]
253    {
254        host.remove_file(link_path)
255            .await
256            .map_err(|error| error.into_anyhow("removing existing shell launcher"))
257    }
258    #[cfg(not(unix))]
259    {
260        for path in [link_path.to_path_buf(), link_path.with_extension("cmd")] {
261            match host.remove_file(&path).await {
262                Ok(()) => {}
263                Err(error) if error.is_not_found() => {}
264                Err(error) => {
265                    return Err(error.into_anyhow("removing existing shell shim"));
266                }
267            }
268        }
269        Ok(())
270    }
271}
272
273async fn host_create_link(
274    host: &impl FileSystemHost,
275    source: &Path,
276    link_path: &Path,
277    runtime: LinkRuntime,
278    bun_dependencies: BunDependencyMode,
279    env: &[String],
280    render_target: Option<&str>,
281) -> Result<()> {
282    let spec = LinkSpec {
283        source: source.to_path_buf(),
284        link_name: {
285            #[cfg(unix)]
286            let name = link_path.file_name();
287            #[cfg(not(unix))]
288            let name = link_path.file_stem();
289            name.unwrap_or_default().to_os_string()
290        },
291        runtime,
292        bun_dependencies,
293        env: env.to_vec(),
294        render_target: render_target.map(str::to_string),
295    };
296    for resource in
297        prepare_launcher_resources(link_path.parent().unwrap_or_else(|| Path::new("")), &spec)
298    {
299        apply_prepared_launcher_resource(host, &resource).await?;
300    }
301    Ok(())
302}
303
304async fn host_launcher_status(
305    host: &impl FileSystemObservationHost,
306    link_path: &Path,
307    source: &Path,
308    runtime: LinkRuntime,
309    bun_dependencies: BunDependencyMode,
310    env: &[String],
311    render_target: Option<&str>,
312) -> Result<LauncherStatus> {
313    let content = match host.read(link_path).await {
314        Ok(bytes) => match String::from_utf8(bytes) {
315            Ok(content) => content,
316            Err(_) => return Ok(LauncherStatus::NotManaged),
317        },
318        Err(_) => return Ok(LauncherStatus::NotManaged),
319    };
320    if !has_shim_managed_marker(&content) {
321        return Ok(LauncherStatus::NotManaged);
322    }
323    let Some(target) = shim_target_from_content(&content) else {
324        return Ok(LauncherStatus::Stale);
325    };
326    #[cfg(unix)]
327    if target.as_os_str() != source.as_os_str() {
328        return Ok(LauncherStatus::NotManaged);
329    }
330    #[cfg(not(unix))]
331    if windows_path_key(&target) != windows_path_key(source) {
332        return Ok(LauncherStatus::NotManaged);
333    }
334
335    let name = launcher_command_name(link_path);
336    #[cfg(unix)]
337    let current = if let Some(target) = render_target {
338        content == unix_live_launcher_content(source, &name, runtime, bun_dependencies, env, target)
339    } else {
340        runtime == LinkRuntime::Bun
341            && content == unix_bun_launcher_content(source, &name, bun_dependencies, env)
342    };
343    #[cfg(not(unix))]
344    let current = {
345        let expected_ps1 =
346            powershell_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
347        let expected_cmd =
348            cmd_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
349        let cmd_current = host
350            .read(&link_path.with_extension("cmd"))
351            .await
352            .ok()
353            .is_some_and(|bytes| bytes == expected_cmd.as_bytes());
354        content == expected_ps1 && cmd_current
355    };
356    Ok(if current {
357        LauncherStatus::Current
358    } else {
359        LauncherStatus::Stale
360    })
361}
362
363async fn host_source_is_linkable(host: &impl FileSystemHost, path: &Path) -> bool {
364    if has_linkable_script_extension(path) {
365        return true;
366    }
367    #[cfg(unix)]
368    return host
369        .metadata(path)
370        .await
371        .ok()
372        .and_then(|metadata| metadata.unix_mode)
373        .is_some_and(|mode| mode & 0o111 != 0);
374    #[cfg(not(unix))]
375    return path
376        .extension()
377        .and_then(|extension| extension.to_str())
378        .is_some_and(|extension| EXECUTABLE_EXTENSIONS.contains(&extension));
379}
380
381pub async fn link_executables_with_host(
382    host: &impl FileSystemHost,
383    bin_dir: &Path,
384    specs: &[LinkSpec],
385    overwrite: bool,
386) -> Result<LinkReport> {
387    let mut report = LinkReport {
388        created: Vec::new(),
389        skipped: Vec::new(),
390        conflicts: Vec::new(),
391        overwritten: Vec::new(),
392    };
393    let mut seen = HashSet::new();
394    for spec in specs {
395        if spec.runtime == LinkRuntime::Native && !host_source_is_linkable(host, &spec.source).await
396        {
397            continue;
398        }
399        if spec.source.file_name().is_none() {
400            continue;
401        }
402        if !seen.insert(spec.link_name.clone()) {
403            report.conflicts.push(LinkConflict {
404                link_path: command_path_for_name(bin_dir, &spec.link_name),
405                source: spec.source.clone(),
406                kind: LinkConflictKind::DuplicateName,
407            });
408            continue;
409        }
410        let link_path = command_path_for_name(bin_dir, &spec.link_name);
411        match host.metadata(&link_path).await {
412            Ok(metadata) if metadata.kind == FileKind::Symlink => {
413                let current = host
414                    .read_link(&link_path)
415                    .await
416                    .is_ok_and(|target| target == spec.source && spec.render_target.is_none());
417                if current {
418                    report.skipped.push(link_path);
419                } else if overwrite {
420                    host_remove_link(host, &link_path).await?;
421                    host_create_link(
422                        host,
423                        &spec.source,
424                        &link_path,
425                        spec.runtime,
426                        spec.bun_dependencies,
427                        &spec.env,
428                        spec.render_target.as_deref(),
429                    )
430                    .await?;
431                    report.overwritten.push(link_path);
432                } else {
433                    report.conflicts.push(LinkConflict {
434                        link_path,
435                        source: spec.source.clone(),
436                        kind: LinkConflictKind::ExistingEntry,
437                    });
438                }
439            }
440            Ok(_) => match host_launcher_status(
441                host,
442                &link_path,
443                &spec.source,
444                spec.runtime,
445                spec.bun_dependencies,
446                &spec.env,
447                spec.render_target.as_deref(),
448            )
449            .await?
450            {
451                LauncherStatus::Current => report.skipped.push(link_path),
452                LauncherStatus::Stale => {
453                    host_remove_link(host, &link_path).await?;
454                    host_create_link(
455                        host,
456                        &spec.source,
457                        &link_path,
458                        spec.runtime,
459                        spec.bun_dependencies,
460                        &spec.env,
461                        spec.render_target.as_deref(),
462                    )
463                    .await?;
464                    report.overwritten.push(link_path);
465                }
466                LauncherStatus::NotManaged if overwrite => {
467                    host_remove_link(host, &link_path).await?;
468                    host_create_link(
469                        host,
470                        &spec.source,
471                        &link_path,
472                        spec.runtime,
473                        spec.bun_dependencies,
474                        &spec.env,
475                        spec.render_target.as_deref(),
476                    )
477                    .await?;
478                    report.overwritten.push(link_path);
479                }
480                LauncherStatus::NotManaged => report.conflicts.push(LinkConflict {
481                    link_path,
482                    source: spec.source.clone(),
483                    kind: LinkConflictKind::ExistingEntry,
484                }),
485            },
486            Err(error) if error.is_not_found() => {
487                host_create_link(
488                    host,
489                    &spec.source,
490                    &link_path,
491                    spec.runtime,
492                    spec.bun_dependencies,
493                    &spec.env,
494                    spec.render_target.as_deref(),
495                )
496                .await?;
497                report.created.push(link_path);
498            }
499            Err(error) => return Err(error.into_anyhow("inspecting shell launcher")),
500        }
501    }
502    Ok(report)
503}
504
505pub async fn link_is_current_with_host(
506    host: &impl FileSystemObservationHost,
507    link_path: &Path,
508    source: &Path,
509    runtime: LinkRuntime,
510    bun_dependencies: BunDependencyMode,
511    env: &[String],
512    render_target: Option<&str>,
513) -> Result<bool> {
514    match host.metadata(link_path).await {
515        Ok(metadata) if metadata.kind == FileKind::Symlink => {
516            if runtime != LinkRuntime::Native || render_target.is_some() {
517                return Ok(false);
518            }
519            Ok(host
520                .read_link(link_path)
521                .await
522                .is_ok_and(|target| target == source))
523        }
524        Ok(_) => Ok(matches!(
525            host_launcher_status(
526                host,
527                link_path,
528                source,
529                runtime,
530                bun_dependencies,
531                env,
532                render_target,
533            )
534            .await?,
535            LauncherStatus::Current
536        )),
537        Err(error) if error.is_not_found() => Ok(false),
538        Err(error) => Err(error.into_anyhow("inspecting shell launcher")),
539    }
540}
541
542pub async fn unlink_managed_command_with_host(
543    host: &impl FileSystemHost,
544    bin_dir: &Path,
545    command: &OsStr,
546    managed_roots: &[PathBuf],
547    dry_run: bool,
548) -> Result<UnlinkReport> {
549    let probe = probe_managed_command_with_host(host, bin_dir, command, managed_roots).await?;
550    if !probe.conflicts.is_empty() {
551        return Ok(UnlinkReport {
552            removed: Vec::new(),
553            skipped: probe.conflicts,
554        });
555    }
556    let removed = probe
557        .resources
558        .iter()
559        .map(|resource| resource.destination().to_path_buf())
560        .collect::<Vec<_>>();
561    if !dry_run {
562        for path in &removed {
563            host.remove_file(path)
564                .await
565                .map_err(|error| error.into_anyhow("removing managed shell launcher"))?;
566        }
567    }
568    Ok(UnlinkReport {
569        removed,
570        skipped: Vec::new(),
571    })
572}
573
574pub(crate) async fn probe_managed_command_with_host(
575    host: &impl FileSystemObservationHost,
576    bin_dir: &Path,
577    command: &OsStr,
578    managed_roots: &[PathBuf],
579) -> Result<ManagedLauncherProbe> {
580    let primary = command_path_for_name(bin_dir, command);
581    #[cfg(unix)]
582    let candidates = vec![primary];
583    #[cfg(not(unix))]
584    let candidates = vec![primary.clone(), primary.with_extension("cmd")];
585
586    let mut resources = Vec::new();
587    let mut conflicts = Vec::new();
588    for path in candidates {
589        let metadata = match host.metadata(&path).await {
590            Ok(metadata) => metadata,
591            Err(error) if error.is_not_found() => continue,
592            Err(error) => return Err(error.into_anyhow("inspecting shell launcher")),
593        };
594        let (target, resource) = if metadata.kind == FileKind::Symlink {
595            let target = host
596                .read_link(&path)
597                .await
598                .map_err(|error| error.into_anyhow("reading shell launcher target"))?;
599            (
600                target.clone(),
601                PreparedLauncherResource::Symlink {
602                    destination: path.clone(),
603                    target,
604                },
605            )
606        } else if metadata.kind == FileKind::File {
607            let bytes = host
608                .read(&path)
609                .await
610                .map_err(|error| error.into_anyhow("reading shell launcher"))?;
611            let target = String::from_utf8(bytes.clone())
612                .ok()
613                .filter(|content| has_shim_managed_marker(content))
614                .and_then(|content| shim_target_from_content(&content));
615            let Some(target) = target else {
616                conflicts.push(path);
617                continue;
618            };
619            (
620                target,
621                PreparedLauncherResource::File {
622                    destination: path.clone(),
623                    bytes,
624                    unix_mode: metadata.unix_mode.map(|mode| mode & 0o777),
625                },
626            )
627        } else {
628            conflicts.push(path);
629            continue;
630        };
631        if managed_roots
632            .iter()
633            .any(|root| target_is_managed(&target, root, bin_dir))
634        {
635            resources.push(resource);
636        } else {
637            conflicts.push(path);
638        }
639    }
640    if !conflicts.is_empty() {
641        resources.clear();
642    }
643    Ok(ManagedLauncherProbe {
644        resources,
645        conflicts,
646    })
647}
648
649/// Remove symlinks in `bin_dir` whose link target starts with `managed_root`.
650///
651/// Non-symlinks and symlinks pointing outside `managed_root` are untouched.
652/// Missing `bin_dir` is treated as a no-op (returns empty report).
653/// When `dry_run` is true, nothing is removed.
654#[cfg(test)]
655pub async fn unlink_managed(
656    bin_dir: &Path,
657    managed_root: &Path,
658    dry_run: bool,
659) -> Result<UnlinkReport> {
660    let mut report = UnlinkReport {
661        removed: Vec::new(),
662        skipped: Vec::new(),
663    };
664
665    let mut read_dir = match tokio::fs::read_dir(bin_dir).await {
666        Ok(rd) => rd,
667        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(report),
668        Err(e) => return Err(e).with_context(|| format!("reading bin dir: {bin_dir:?}")),
669    };
670
671    while let Some(entry) = read_dir
672        .next_entry()
673        .await
674        .with_context(|| format!("iterating bin dir: {bin_dir:?}"))?
675    {
676        let path = entry.path();
677        let meta = match tokio::fs::symlink_metadata(&path).await {
678            Ok(m) => m,
679            Err(_) => continue,
680        };
681
682        // Regular files are shine-managed only when they carry the managed marker
683        // and record a target under `managed_root`. On Windows these are the
684        // `.ps1`/`.cmd` shims; on Unix they are the generated bun launcher scripts.
685        // User files (no marker, foreign target, or unreadable) are always skipped —
686        // this is the "uninstall never touches user files" invariant.
687        if !meta.file_type().is_symlink() {
688            match launcher_target(&path).await {
689                Ok(Some(target)) if target_is_managed(&target, managed_root, bin_dir) => {
690                    if !dry_run {
691                        remove_link(&path).await?;
692                    }
693                    report.removed.push(path);
694                }
695                _ => report.skipped.push(path),
696            }
697            continue;
698        }
699
700        let target = match tokio::fs::read_link(&path).await {
701            Ok(t) => t,
702            Err(_) => {
703                report.skipped.push(path);
704                continue;
705            }
706        };
707
708        // Lexical prefix check — works even if the target file no longer exists.
709        if target_is_managed(&target, managed_root, bin_dir) {
710            if !dry_run {
711                tokio::fs::remove_file(&path)
712                    .await
713                    .with_context(|| format!("removing symlink: {path:?}"))?;
714            }
715            report.removed.push(path);
716        } else {
717            report.skipped.push(path);
718        }
719    }
720
721    Ok(report)
722}
723
724/// Remove one command entry only when it is owned by Shine and points below one
725/// of `managed_roots`. This is the command-scoped counterpart to
726/// [`unlink_managed`]; foreign files and links are reported as skipped.
727#[cfg(test)]
728#[expect(dead_code, reason = "retained as a no-host launcher regression helper")]
729pub async fn unlink_managed_command(
730    bin_dir: &Path,
731    command: &OsStr,
732    managed_roots: &[PathBuf],
733    dry_run: bool,
734) -> Result<UnlinkReport> {
735    let path = command_path_for_name(bin_dir, command);
736    let mut report = UnlinkReport {
737        removed: Vec::new(),
738        skipped: Vec::new(),
739    };
740    let meta = match tokio::fs::symlink_metadata(&path).await {
741        Ok(meta) => meta,
742        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(report),
743        Err(error) => return Err(error).with_context(|| format!("stat failed: {path:?}")),
744    };
745
746    let target = if meta.file_type().is_symlink() {
747        tokio::fs::read_link(&path).await.ok()
748    } else {
749        launcher_target(&path).await?
750    };
751    let managed = target.is_some_and(|target| {
752        managed_roots
753            .iter()
754            .any(|root| target_is_managed(&target, root, bin_dir))
755    });
756    if !managed {
757        report.skipped.push(path);
758        return Ok(report);
759    }
760
761    if !dry_run {
762        remove_link(&path).await?;
763    }
764    report.removed.push(path);
765    Ok(report)
766}
767
768/// Create flat symlinks in `bin_dir` for each executable file in `sources`.
769///
770/// - Existing correct symlinks are skipped (idempotent).
771/// - Conflicting entries (wrong target or regular file) are recorded and skipped
772///   unless `overwrite` is true.
773/// - Two sources sharing the same filename → second is recorded as a conflict.
774#[cfg(test)]
775pub async fn link_executables(
776    bin_dir: &Path,
777    sources: &[PathBuf],
778    overwrite: bool,
779) -> Result<LinkReport> {
780    let specs: Vec<_> = sources
781        .iter()
782        .map(|source| LinkSpec {
783            source: source.clone(),
784            link_name: link_stem(source),
785            runtime: LinkRuntime::Native,
786            bun_dependencies: BunDependencyMode::Disabled,
787            env: Vec::new(),
788            render_target: None,
789        })
790        .collect();
791    link_executables_with_names(bin_dir, &specs, overwrite).await
792}
793
794#[cfg(test)]
795pub async fn link_executables_with_names(
796    bin_dir: &Path,
797    specs: &[LinkSpec],
798    overwrite: bool,
799) -> Result<LinkReport> {
800    let mut report = LinkReport {
801        created: Vec::new(),
802        skipped: Vec::new(),
803        conflicts: Vec::new(),
804        overwritten: Vec::new(),
805    };
806
807    let mut seen: HashSet<OsString> = HashSet::new();
808
809    for spec in specs {
810        // Native links require a runnable/linkable source; bun launchers wrap any
811        // declared bun script, so they bypass the executable/extension gate.
812        if spec.runtime == LinkRuntime::Native && !is_linkable_source(&spec.source) {
813            continue;
814        }
815
816        if spec.source.file_name().is_none() {
817            continue;
818        }
819        let stem = spec.link_name.clone();
820
821        if !seen.insert(stem.clone()) {
822            report.conflicts.push(LinkConflict {
823                link_path: command_path_for_name(bin_dir, &stem),
824                source: spec.source.clone(),
825                kind: LinkConflictKind::DuplicateName,
826            });
827            continue;
828        }
829
830        let link_path = command_path_for_name(bin_dir, &stem);
831
832        match tokio::fs::symlink_metadata(&link_path).await {
833            Ok(meta) if meta.file_type().is_symlink() => {
834                match tokio::fs::read_link(&link_path).await {
835                    Ok(existing) if existing == spec.source && spec.render_target.is_none() => {
836                        report.skipped.push(link_path);
837                    }
838                    _ => {
839                        if overwrite {
840                            tokio::fs::remove_file(&link_path).await.with_context(|| {
841                                format!("removing stale symlink: {link_path:?}")
842                            })?;
843                            create_link(
844                                &spec.source,
845                                &link_path,
846                                spec.runtime,
847                                spec.bun_dependencies,
848                                &spec.env,
849                                spec.render_target.as_deref(),
850                            )
851                            .await?;
852                            report.overwritten.push(link_path);
853                        } else {
854                            report.conflicts.push(LinkConflict {
855                                link_path,
856                                source: spec.source.clone(),
857                                kind: LinkConflictKind::ExistingEntry,
858                            });
859                        }
860                    }
861                }
862            }
863            Ok(_) => {
864                match launcher_status(
865                    &link_path,
866                    &spec.source,
867                    spec.runtime,
868                    spec.bun_dependencies,
869                    &spec.env,
870                    spec.render_target.as_deref(),
871                )
872                .await?
873                {
874                    LauncherStatus::Current => {
875                        report.skipped.push(link_path);
876                        continue;
877                    }
878                    LauncherStatus::Stale => {
879                        remove_link(&link_path).await?;
880                        create_link(
881                            &spec.source,
882                            &link_path,
883                            spec.runtime,
884                            spec.bun_dependencies,
885                            &spec.env,
886                            spec.render_target.as_deref(),
887                        )
888                        .await?;
889                        report.overwritten.push(link_path);
890                        continue;
891                    }
892                    LauncherStatus::NotManaged => {}
893                }
894
895                if overwrite {
896                    remove_link(&link_path).await?;
897                    create_link(
898                        &spec.source,
899                        &link_path,
900                        spec.runtime,
901                        spec.bun_dependencies,
902                        &spec.env,
903                        spec.render_target.as_deref(),
904                    )
905                    .await?;
906                    report.overwritten.push(link_path);
907                } else {
908                    report.conflicts.push(LinkConflict {
909                        link_path,
910                        source: spec.source.clone(),
911                        kind: LinkConflictKind::ExistingEntry,
912                    });
913                }
914            }
915            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
916                create_link(
917                    &spec.source,
918                    &link_path,
919                    spec.runtime,
920                    spec.bun_dependencies,
921                    &spec.env,
922                    spec.render_target.as_deref(),
923                )
924                .await?;
925                report.created.push(link_path);
926            }
927            Err(e) => {
928                return Err(e).with_context(|| format!("stat failed: {link_path:?}"));
929            }
930        }
931    }
932
933    Ok(report)
934}
935
936/// Return whether an installed command exactly matches its expected source, runtime, and
937/// runtime environment declaration.
938///
939/// Status surfaces use the same current-ness rules as install/upgrade so an existing command
940/// from an older source or runtime is reported as an available update.
941#[cfg(test)]
942#[expect(dead_code, reason = "retained as a no-host launcher regression helper")]
943pub async fn link_is_current(
944    link_path: &Path,
945    source: &Path,
946    runtime: LinkRuntime,
947    bun_dependencies: BunDependencyMode,
948    env: &[String],
949    render_target: Option<&str>,
950) -> Result<bool> {
951    match tokio::fs::symlink_metadata(link_path).await {
952        Ok(meta) if meta.file_type().is_symlink() => {
953            if runtime != LinkRuntime::Native || render_target.is_some() {
954                return Ok(false);
955            }
956            Ok(tokio::fs::read_link(link_path)
957                .await
958                .is_ok_and(|target| target == source))
959        }
960        Ok(_) => Ok(matches!(
961            launcher_status(
962                link_path,
963                source,
964                runtime,
965                bun_dependencies,
966                env,
967                render_target,
968            )
969            .await?,
970            LauncherStatus::Current
971        )),
972        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
973        Err(error) => Err(error).with_context(|| format!("stat failed: {link_path:?}")),
974    }
975}
976
977pub fn command_path_for_name(bin_dir: &Path, stem: &OsStr) -> PathBuf {
978    #[cfg(unix)]
979    {
980        bin_dir.join(stem)
981    }
982    #[cfg(not(unix))]
983    {
984        let mut name = stem.to_os_string();
985        name.push(".ps1");
986        bin_dir.join(name)
987    }
988}
989
990pub fn link_stem(path: &Path) -> std::ffi::OsString {
991    if has_linkable_script_extension(path) || has_bun_script_extension(path) {
992        path.file_stem().map(|s| s.to_owned()).unwrap_or_default()
993    } else {
994        path.file_name().map(|n| n.to_owned()).unwrap_or_default()
995    }
996}
997
998#[cfg(test)]
999fn is_executable(path: &Path) -> bool {
1000    #[cfg(unix)]
1001    {
1002        use std::os::unix::fs::PermissionsExt;
1003        std::fs::metadata(path)
1004            .map(|m| m.permissions().mode() & 0o111 != 0)
1005            .unwrap_or(false)
1006    }
1007    #[cfg(not(unix))]
1008    {
1009        path.extension()
1010            .and_then(|e| e.to_str())
1011            .map(|ext| EXECUTABLE_EXTENSIONS.contains(&ext))
1012            .unwrap_or(false)
1013    }
1014}
1015
1016#[cfg(test)]
1017fn is_linkable_source(path: &Path) -> bool {
1018    is_executable(path) || has_linkable_script_extension(path)
1019}
1020
1021fn has_linkable_script_extension(path: &Path) -> bool {
1022    path.extension()
1023        .and_then(|e| e.to_str())
1024        .map(|ext| LINKABLE_SCRIPT_EXTENSIONS.contains(&ext))
1025        .unwrap_or(false)
1026}
1027
1028fn has_bun_script_extension(path: &Path) -> bool {
1029    path.extension()
1030        .and_then(|e| e.to_str())
1031        .map(|ext| BUN_SCRIPT_EXTENSIONS.contains(&ext))
1032        .unwrap_or(false)
1033}
1034
1035/// True when `target` (from a launcher's native target marker or a symlink) lexically
1036/// resolves under `managed_root`. Relative targets are resolved against `bin_dir`.
1037/// Works even if the target file no longer exists.
1038fn target_is_managed(target: &Path, managed_root: &Path, bin_dir: &Path) -> bool {
1039    if target.is_absolute() {
1040        target.starts_with(managed_root)
1041    } else {
1042        bin_dir.join(target).starts_with(managed_root)
1043    }
1044}
1045
1046fn has_shim_managed_marker(content: &str) -> bool {
1047    content.contains(SHIM_MANAGED_MARKER) || content.contains(CMD_SHIM_MANAGED_MARKER)
1048}
1049
1050/// The command name a launcher exposes — the link path's file stem.
1051fn launcher_command_name(link_path: &Path) -> String {
1052    link_path
1053        .file_stem()
1054        .map(|s| s.to_string_lossy().into_owned())
1055        .unwrap_or_default()
1056}
1057
1058/// Read a launcher/shim's recorded `# shine-target:` path, or `None` if the file
1059/// is not a shine-managed launcher (missing marker) or is unreadable. Any read
1060/// error yields `None` so a user file is never mistaken for a managed launcher.
1061#[cfg(test)]
1062async fn launcher_target(path: &Path) -> Result<Option<PathBuf>> {
1063    let content = match tokio::fs::read_to_string(path).await {
1064        Ok(content) => content,
1065        Err(_) => return Ok(None),
1066    };
1067    if !has_shim_managed_marker(&content) {
1068        return Ok(None);
1069    }
1070    Ok(shim_target_from_content(&content))
1071}
1072
1073fn shim_target_from_content(content: &str) -> Option<PathBuf> {
1074    content.lines().find_map(|line| {
1075        line.strip_prefix(SHIM_TARGET_PREFIX)
1076            .or_else(|| line.strip_prefix("REM shine-target: "))
1077            .map(PathBuf::from)
1078    })
1079}
1080
1081#[cfg(test)]
1082async fn create_link(
1083    source: &Path,
1084    link_path: &Path,
1085    runtime: LinkRuntime,
1086    bun_dependencies: BunDependencyMode,
1087    env: &[String],
1088    render_target: Option<&str>,
1089) -> Result<()> {
1090    #[cfg(unix)]
1091    {
1092        if let Some(target) = render_target {
1093            return write_unix_live_launcher(
1094                source,
1095                link_path,
1096                runtime,
1097                bun_dependencies,
1098                env,
1099                target,
1100            )
1101            .await;
1102        }
1103        match runtime {
1104            LinkRuntime::Native => tokio::fs::symlink(source, link_path)
1105                .await
1106                .with_context(|| format!("creating symlink {link_path:?} -> {source:?}")),
1107            LinkRuntime::Bun => {
1108                write_unix_bun_launcher(source, link_path, bun_dependencies, env).await
1109            }
1110        }
1111    }
1112    #[cfg(not(unix))]
1113    {
1114        create_windows_shims(
1115            source,
1116            link_path,
1117            runtime,
1118            bun_dependencies,
1119            env,
1120            render_target,
1121        )
1122        .await
1123    }
1124}
1125
1126#[cfg(test)]
1127async fn remove_link(link_path: &Path) -> Result<()> {
1128    #[cfg(unix)]
1129    {
1130        tokio::fs::remove_file(link_path)
1131            .await
1132            .with_context(|| format!("removing existing file: {link_path:?}"))
1133    }
1134    #[cfg(not(unix))]
1135    {
1136        remove_windows_shims(link_path).await
1137    }
1138}
1139
1140/// Whether the existing regular file at `link_path` is a current/stale/foreign
1141/// launcher for `source` under `runtime`. Native runtime on Unix has no managed
1142/// regular-file form (its links are symlinks), so any regular file is `NotManaged`
1143/// (a user-file conflict).
1144#[cfg(test)]
1145async fn launcher_status(
1146    link_path: &Path,
1147    source: &Path,
1148    runtime: LinkRuntime,
1149    bun_dependencies: BunDependencyMode,
1150    env: &[String],
1151    render_target: Option<&str>,
1152) -> Result<LauncherStatus> {
1153    #[cfg(unix)]
1154    {
1155        if let Some(target) = render_target {
1156            return unix_live_launcher_status(
1157                link_path,
1158                source,
1159                runtime,
1160                bun_dependencies,
1161                env,
1162                target,
1163            )
1164            .await;
1165        }
1166        match runtime {
1167            LinkRuntime::Bun => {
1168                unix_launcher_status(link_path, source, bun_dependencies, env).await
1169            }
1170            LinkRuntime::Native => Ok(LauncherStatus::NotManaged),
1171        }
1172    }
1173    #[cfg(not(unix))]
1174    {
1175        windows_shim_status(
1176            link_path,
1177            source,
1178            runtime,
1179            bun_dependencies,
1180            env,
1181            render_target,
1182        )
1183        .await
1184    }
1185}
1186
1187#[cfg(unix)]
1188fn shell_single_quote(value: &str) -> String {
1189    format!("'{}'", value.replace('\'', "'\\''"))
1190}
1191
1192/// Deterministic content of a Unix bun launcher. Regenerated byte-for-byte by
1193/// `unix_launcher_status` to detect staleness, so any change here is a format
1194/// change that will refresh installed launchers on upgrade.
1195#[cfg(unix)]
1196fn unix_bun_launcher_content(
1197    source: &Path,
1198    name: &str,
1199    bun_dependencies: BunDependencyMode,
1200    env: &[String],
1201) -> String {
1202    let target = source.display().to_string();
1203    let quoted_target = shell_single_quote(&target);
1204    let quoted_name = shell_single_quote(name);
1205    let install_arg = bun_dependencies.install_arg();
1206    // Empty `env` reproduces the v1 launcher byte-for-byte (no `shine` dependency);
1207    // a declared `env` adds a `shine` presence check and runs the child through
1208    // `shine env run --no-workspace` so values reach Bun via `Bun.env`.
1209    let (shine_check, runner) = if env.is_empty() {
1210        (
1211            String::new(),
1212            format!("exec bun {install_arg} {quoted_target} \"$@\"\n"),
1213        )
1214    } else {
1215        let with_args = env
1216            .iter()
1217            .map(|token| format!("--with {}", shell_single_quote(token)))
1218            .collect::<Vec<_>>()
1219            .join(" ");
1220        (
1221            format!(
1222                "if ! command -v shine >/dev/null 2>&1; then\n  \
1223                 printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n  \
1224                 exit 127\nfi\n"
1225            ),
1226            format!(
1227                "exec shine env run --no-workspace {with_args} -- bun {install_arg} {quoted_target} \"$@\"\n"
1228            ),
1229        )
1230    };
1231    format!(
1232        "#!/usr/bin/env bash\n\
1233         {SHIM_MANAGED_MARKER}\n\
1234         {SHIM_TARGET_PREFIX}{target}\n\
1235         if ! command -v bun >/dev/null 2>&1; then\n  \
1236         printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n  \
1237         printf 'shine: install Bun from https://bun.sh, then re-run %s.\\n' {quoted_name} >&2\n  \
1238         exit 127\nfi\n\
1239         {shine_check}{runner}"
1240    )
1241}
1242
1243#[cfg(unix)]
1244fn unix_live_launcher_content(
1245    source: &Path,
1246    name: &str,
1247    runtime: LinkRuntime,
1248    bun_dependencies: BunDependencyMode,
1249    env: &[String],
1250    render_target: &str,
1251) -> String {
1252    let target = source.display().to_string();
1253    let quoted_source = shell_single_quote(&target);
1254    let quoted_name = shell_single_quote(name);
1255    let quoted_render_target = shell_single_quote(render_target);
1256    let config_dir = live_config_dir(source);
1257    let config_arg = if config_dir.file_name() == Some(OsStr::new(".shine")) {
1258        String::new()
1259    } else {
1260        format!(
1261            "--config-dir {} ",
1262            shell_single_quote(&config_dir.display().to_string())
1263        )
1264    };
1265    let render = format!(
1266        "if ! command -v shine >/dev/null 2>&1; then\n  \
1267         printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n  \
1268         return 127 2>/dev/null || exit 127\nfi\n\
1269         shine {config_arg}__shell-render {quoted_render_target} || {{ _shine_code=$?; return $_shine_code 2>/dev/null || exit $_shine_code; }}\n"
1270    );
1271    let runner = match runtime {
1272        LinkRuntime::Native => format!(
1273            "_shine_sourced=false\n\
1274             case \"$ZSH_EVAL_CONTEXT\" in *:file|*:file:*) _shine_sourced=true ;; esac\n\
1275             if [ -n \"$BASH_VERSION\" ] && [ \"$BASH_SOURCE\" != \"$0\" ]; then _shine_sourced=true; fi\n\
1276             if [ \"$_shine_sourced\" = true ]; then\n  . {quoted_source} \"$@\"\n  return $?\nfi\n\
1277             exec {quoted_source} \"$@\"\n"
1278        ),
1279        LinkRuntime::Bun => {
1280            let install_arg = bun_dependencies.install_arg();
1281            let bun_check = format!(
1282                "if ! command -v bun >/dev/null 2>&1; then\n  \
1283                 printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n  \
1284                 exit 127\nfi\n"
1285            );
1286            if env.is_empty() {
1287                format!("{bun_check}exec bun {install_arg} {quoted_source} \"$@\"\n")
1288            } else {
1289                let with_args = env
1290                    .iter()
1291                    .map(|token| format!("--with {}", shell_single_quote(token)))
1292                    .collect::<Vec<_>>()
1293                    .join(" ");
1294                format!(
1295                    "{bun_check}exec shine env run --no-workspace {with_args} -- bun {install_arg} {quoted_source} \"$@\"\n"
1296                )
1297            }
1298        }
1299    };
1300    format!(
1301        "#!/usr/bin/env bash\n{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{runner}"
1302    )
1303}
1304
1305fn live_config_dir(rendered_source: &Path) -> PathBuf {
1306    rendered_source
1307        .ancestors()
1308        .find(|path| path.file_name() == Some(OsStr::new("rendered")))
1309        .and_then(Path::parent)
1310        .map(Path::to_path_buf)
1311        .unwrap_or_else(|| {
1312            rendered_source
1313                .parent()
1314                .unwrap_or_else(|| Path::new("."))
1315                .to_path_buf()
1316        })
1317}
1318
1319#[cfg(unix)]
1320#[cfg(test)]
1321async fn write_unix_live_launcher(
1322    source: &Path,
1323    link_path: &Path,
1324    runtime: LinkRuntime,
1325    bun_dependencies: BunDependencyMode,
1326    env: &[String],
1327    render_target: &str,
1328) -> Result<()> {
1329    use std::os::unix::fs::PermissionsExt;
1330    if let Some(parent) = link_path.parent() {
1331        tokio::fs::create_dir_all(parent).await?;
1332    }
1333    let name = launcher_command_name(link_path);
1334    let content =
1335        unix_live_launcher_content(source, &name, runtime, bun_dependencies, env, render_target);
1336    crate::persist::atomic_write(link_path, content.as_bytes()).await?;
1337    tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755)).await?;
1338    Ok(())
1339}
1340
1341#[cfg(unix)]
1342#[cfg(test)]
1343async fn unix_live_launcher_status(
1344    link_path: &Path,
1345    source: &Path,
1346    runtime: LinkRuntime,
1347    bun_dependencies: BunDependencyMode,
1348    env: &[String],
1349    render_target: &str,
1350) -> Result<LauncherStatus> {
1351    let content = match tokio::fs::read_to_string(link_path).await {
1352        Ok(content) => content,
1353        Err(_) => return Ok(LauncherStatus::NotManaged),
1354    };
1355    if !has_shim_managed_marker(&content) {
1356        return Ok(LauncherStatus::NotManaged);
1357    }
1358    let Some(target) = shim_target_from_content(&content) else {
1359        return Ok(LauncherStatus::Stale);
1360    };
1361    if target.as_os_str() != source.as_os_str() {
1362        return Ok(LauncherStatus::NotManaged);
1363    }
1364    let name = launcher_command_name(link_path);
1365    if content
1366        == unix_live_launcher_content(source, &name, runtime, bun_dependencies, env, render_target)
1367    {
1368        Ok(LauncherStatus::Current)
1369    } else {
1370        Ok(LauncherStatus::Stale)
1371    }
1372}
1373
1374#[cfg(unix)]
1375#[cfg(test)]
1376async fn write_unix_bun_launcher(
1377    source: &Path,
1378    link_path: &Path,
1379    bun_dependencies: BunDependencyMode,
1380    env: &[String],
1381) -> Result<()> {
1382    use std::os::unix::fs::PermissionsExt;
1383    if let Some(parent) = link_path.parent() {
1384        tokio::fs::create_dir_all(parent)
1385            .await
1386            .with_context(|| format!("creating bin dir: {parent:?}"))?;
1387    }
1388    let name = launcher_command_name(link_path);
1389    tokio::fs::write(
1390        link_path,
1391        unix_bun_launcher_content(source, &name, bun_dependencies, env),
1392    )
1393    .await
1394    .with_context(|| format!("writing bun launcher: {link_path:?}"))?;
1395    tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755))
1396        .await
1397        .with_context(|| format!("setting bun launcher permissions: {link_path:?}"))?;
1398    Ok(())
1399}
1400
1401#[cfg(unix)]
1402#[cfg(test)]
1403async fn unix_launcher_status(
1404    link_path: &Path,
1405    source: &Path,
1406    bun_dependencies: BunDependencyMode,
1407    env: &[String],
1408) -> Result<LauncherStatus> {
1409    let content = match tokio::fs::read_to_string(link_path).await {
1410        Ok(content) => content,
1411        // Missing, non-UTF-8, or otherwise unreadable → treat as a user file.
1412        Err(_) => return Ok(LauncherStatus::NotManaged),
1413    };
1414    if !has_shim_managed_marker(&content) {
1415        return Ok(LauncherStatus::NotManaged);
1416    }
1417    let Some(target) = shim_target_from_content(&content) else {
1418        return Ok(LauncherStatus::Stale);
1419    };
1420    if target.as_os_str() != source.as_os_str() {
1421        return Ok(LauncherStatus::NotManaged);
1422    }
1423    let name = launcher_command_name(link_path);
1424    // Byte comparison against the regenerated content — which embeds the ordered
1425    // `env` spec — so an added/removed/reordered declaration is detected as stale.
1426    if content == unix_bun_launcher_content(source, &name, bun_dependencies, env) {
1427        Ok(LauncherStatus::Current)
1428    } else {
1429        Ok(LauncherStatus::Stale)
1430    }
1431}
1432
1433#[cfg(all(test, not(unix)))]
1434async fn create_windows_shims(
1435    source: &Path,
1436    ps1_path: &Path,
1437    runtime: LinkRuntime,
1438    bun_dependencies: BunDependencyMode,
1439    env: &[String],
1440    render_target: Option<&str>,
1441) -> Result<()> {
1442    let cmd_path = ps1_path.with_extension("cmd");
1443    if let Some(parent) = ps1_path.parent() {
1444        tokio::fs::create_dir_all(parent)
1445            .await
1446            .with_context(|| format!("creating bin dir: {parent:?}"))?;
1447    }
1448    let name = launcher_command_name(ps1_path);
1449    tokio::fs::write(
1450        ps1_path,
1451        powershell_shim_content(source, runtime, &name, bun_dependencies, env, render_target),
1452    )
1453    .await
1454    .with_context(|| format!("writing PowerShell shim: {ps1_path:?}"))?;
1455    tokio::fs::write(
1456        &cmd_path,
1457        cmd_shim_content(source, runtime, &name, bun_dependencies, env, render_target),
1458    )
1459    .await
1460    .with_context(|| format!("writing cmd shim: {cmd_path:?}"))?;
1461    Ok(())
1462}
1463
1464#[cfg(not(unix))]
1465fn powershell_shim_content(
1466    source: &Path,
1467    runtime: LinkRuntime,
1468    name: &str,
1469    bun_dependencies: BunDependencyMode,
1470    env: &[String],
1471    render_target: Option<&str>,
1472) -> String {
1473    let target = windows_native_path(source);
1474    let escaped = target.replace('\'', "''");
1475    let render = render_target.map_or_else(String::new, |render_target| {
1476        let render_target = render_target.replace('\'', "''");
1477        let config_dir = windows_native_path(&live_config_dir(source)).replace('\'', "''");
1478        let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
1479            String::new()
1480        } else {
1481            format!("--config-dir '{config_dir}' ")
1482        };
1483        format!(
1484            "$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"
1485        )
1486    });
1487    match runtime {
1488        LinkRuntime::Bun => {
1489            let install_arg = bun_dependencies.install_arg();
1490            let name_escaped = name.replace('\'', "''");
1491            let bun_check = format!(
1492                "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"
1493            );
1494            if env.is_empty() {
1495                format!(
1496                    "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{bun_check}& bun {install_arg} '{escaped}' @args\nexit $LASTEXITCODE\n"
1497                )
1498            } else {
1499                let with_args = env
1500                    .iter()
1501                    .map(|token| format!("--with '{}'", token.replace('\'', "''")))
1502                    .collect::<Vec<_>>()
1503                    .join(" ");
1504                format!(
1505                    "{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"
1506                )
1507            }
1508        }
1509        LinkRuntime::Native => {
1510            let bash_target = bash_compatible_path(source);
1511            let bash_escaped = bash_target.replace('\'', "''");
1512            match source.extension().and_then(|e| e.to_str()) {
1513                Some("ps1") => format!(
1514                    "{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"
1515                ),
1516                _ => format!(
1517                    "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}& bash '{bash_escaped}' @args\nexit $LASTEXITCODE\n"
1518                ),
1519            }
1520        }
1521    }
1522}
1523
1524#[cfg(not(unix))]
1525fn cmd_shim_content(
1526    source: &Path,
1527    runtime: LinkRuntime,
1528    name: &str,
1529    bun_dependencies: BunDependencyMode,
1530    env: &[String],
1531    render_target: Option<&str>,
1532) -> String {
1533    let target = windows_native_path(source);
1534    let render = render_target.map_or_else(String::new, |render_target| {
1535        let config_dir = windows_native_path(&live_config_dir(source));
1536        let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
1537            String::new()
1538        } else {
1539            format!("--config-dir \"{config_dir}\" ")
1540        };
1541        format!(
1542            "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"
1543        )
1544    });
1545    match runtime {
1546        LinkRuntime::Bun => {
1547            let install_arg = bun_dependencies.install_arg();
1548            let bun_check = format!(
1549                "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"
1550            );
1551            if env.is_empty() {
1552                format!(
1553                    "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}{bun_check}bun {install_arg} \"{target}\" %*\r\n"
1554                )
1555            } else {
1556                let with_args = env
1557                    .iter()
1558                    .map(|token| format!("--with {token}"))
1559                    .collect::<Vec<_>>()
1560                    .join(" ");
1561                format!(
1562                    "@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"
1563                )
1564            }
1565        }
1566        LinkRuntime::Native => {
1567            let escaped = target.replace('\'', "''");
1568            let bash_target = bash_compatible_path(source);
1569            match source.extension().and_then(|e| e.to_str()) {
1570                Some("ps1") => format!(
1571                    "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"{escaped}\" %*\r\n"
1572                ),
1573                _ => format!(
1574                    "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}bash \"{bash_target}\" %*\r\n"
1575                ),
1576            }
1577        }
1578    }
1579}
1580
1581#[cfg(not(unix))]
1582fn bash_compatible_path(path: &Path) -> String {
1583    windows_native_path(path).replace('\\', "/")
1584}
1585
1586#[cfg(not(unix))]
1587fn windows_native_path(path: &Path) -> String {
1588    strip_windows_verbatim_prefix(&path.display().to_string())
1589}
1590
1591#[cfg(not(unix))]
1592fn strip_windows_verbatim_prefix(value: &str) -> String {
1593    value
1594        .strip_prefix(r"\\?\UNC\")
1595        .map(|rest| format!(r"\\{rest}"))
1596        .or_else(|| value.strip_prefix(r"\\?\").map(str::to_string))
1597        .unwrap_or_else(|| value.to_string())
1598}
1599
1600#[cfg(all(test, not(unix)))]
1601async fn windows_shim_status(
1602    link_path: &Path,
1603    source: &Path,
1604    runtime: LinkRuntime,
1605    bun_dependencies: BunDependencyMode,
1606    env: &[String],
1607    render_target: Option<&str>,
1608) -> Result<LauncherStatus> {
1609    let content = match tokio::fs::read_to_string(link_path).await {
1610        Ok(content) => content,
1611        // Missing or unreadable (e.g. non-UTF-8 user file) → treat as a user file.
1612        Err(_) => return Ok(LauncherStatus::NotManaged),
1613    };
1614    if !has_shim_managed_marker(&content) {
1615        return Ok(LauncherStatus::NotManaged);
1616    }
1617
1618    let Some(target) = shim_target_from_content(&content) else {
1619        return Ok(LauncherStatus::Stale);
1620    };
1621    if windows_path_key(&target) != windows_path_key(source) {
1622        return Ok(LauncherStatus::NotManaged);
1623    }
1624
1625    let name = launcher_command_name(link_path);
1626    let expected_ps1 =
1627        powershell_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
1628    let expected_cmd =
1629        cmd_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
1630    let cmd_path = link_path.with_extension("cmd");
1631    let cmd_content = tokio::fs::read_to_string(&cmd_path).await.ok();
1632    if content == expected_ps1 && cmd_content.as_deref() == Some(expected_cmd.as_str()) {
1633        Ok(LauncherStatus::Current)
1634    } else {
1635        Ok(LauncherStatus::Stale)
1636    }
1637}
1638
1639#[cfg(not(unix))]
1640fn windows_path_key(path: &Path) -> String {
1641    windows_native_path(path)
1642        .replace('\\', "/")
1643        .to_ascii_lowercase()
1644}
1645
1646#[cfg(all(test, not(unix)))]
1647async fn remove_windows_shims(ps1_path: &Path) -> Result<()> {
1648    let cmd_path = ps1_path.with_extension("cmd");
1649    match tokio::fs::remove_file(ps1_path).await {
1650        Ok(()) => {}
1651        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1652        Err(err) => return Err(err).with_context(|| format!("removing shim: {ps1_path:?}")),
1653    }
1654    match tokio::fs::remove_file(&cmd_path).await {
1655        Ok(()) => {}
1656        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1657        Err(err) => return Err(err).with_context(|| format!("removing shim: {cmd_path:?}")),
1658    }
1659    Ok(())
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664    use super::*;
1665    #[cfg(unix)]
1666    use tokio::fs;
1667
1668    #[test]
1669    fn recognizes_powershell_unix_and_cmd_managed_markers() {
1670        assert!(has_shim_managed_marker("# shine-managed\n"));
1671        assert!(has_shim_managed_marker("REM shine-managed\r\n"));
1672        assert!(!has_shim_managed_marker("shine-managed\n"));
1673    }
1674
1675    #[cfg(unix)]
1676    async fn make_dirs() -> (PathBuf, PathBuf) {
1677        let id = uuid::Uuid::new_v4();
1678        let src_dir = std::env::temp_dir().join(format!("shine-bl-src-{id}"));
1679        let bin_dir = std::env::temp_dir().join(format!("shine-bl-bin-{id}"));
1680        fs::create_dir_all(&src_dir).await.unwrap();
1681        fs::create_dir_all(&bin_dir).await.unwrap();
1682        (src_dir, bin_dir)
1683    }
1684
1685    /// Write a file and set the executable bit so `is_executable` returns true.
1686    #[cfg(unix)]
1687    async fn make_executable(dir: &Path, name: &str) -> PathBuf {
1688        use std::os::unix::fs::PermissionsExt;
1689        let path = dir.join(name);
1690        fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1691        let mut perms = fs::metadata(&path).await.unwrap().permissions();
1692        perms.set_mode(0o755);
1693        fs::set_permissions(&path, perms).await.unwrap();
1694        path
1695    }
1696
1697    #[cfg(unix)]
1698    async fn make_plain(dir: &Path, name: &str) -> PathBuf {
1699        let path = dir.join(name);
1700        fs::write(&path, b"data").await.unwrap();
1701        path
1702    }
1703
1704    #[cfg(unix)]
1705    #[tokio::test]
1706    async fn creates_symlink_for_executable_source() {
1707        let (src, bin) = make_dirs().await;
1708        let exe = make_executable(&src, "run.sh").await;
1709
1710        let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1711            .await
1712            .unwrap();
1713
1714        assert_eq!(report.created.len(), 1);
1715        let link = &report.created[0];
1716        assert!(link.is_symlink());
1717        assert_eq!(fs::read_link(link).await.unwrap(), exe);
1718        // symlink name is the stem, not the full filename
1719        assert_eq!(link.file_name().unwrap(), "run");
1720
1721        fs::remove_dir_all(&src).await.unwrap();
1722        fs::remove_dir_all(&bin).await.unwrap();
1723    }
1724
1725    #[cfg(unix)]
1726    #[tokio::test]
1727    async fn skips_non_executable_source() {
1728        let (src, bin) = make_dirs().await;
1729        let plain = make_plain(&src, "readme.txt").await;
1730
1731        let report = link_executables(&bin, &[plain], false).await.unwrap();
1732
1733        assert!(report.created.is_empty());
1734        assert!(report.skipped.is_empty());
1735
1736        fs::remove_dir_all(&src).await.unwrap();
1737        fs::remove_dir_all(&bin).await.unwrap();
1738    }
1739
1740    #[cfg(unix)]
1741    #[tokio::test]
1742    async fn skips_when_correct_symlink_already_exists() {
1743        let (src, bin) = make_dirs().await;
1744        let exe = make_executable(&src, "run.sh").await;
1745        tokio::fs::symlink(&exe, bin.join("run")).await.unwrap();
1746
1747        let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1748            .await
1749            .unwrap();
1750
1751        assert!(report.created.is_empty());
1752        assert_eq!(report.skipped.len(), 1);
1753
1754        fs::remove_dir_all(&src).await.unwrap();
1755        fs::remove_dir_all(&bin).await.unwrap();
1756    }
1757
1758    #[cfg(unix)]
1759    #[tokio::test]
1760    async fn reports_conflict_when_regular_file_exists() {
1761        let (src, bin) = make_dirs().await;
1762        let exe = make_executable(&src, "run.sh").await;
1763        make_plain(&bin, "run").await;
1764
1765        let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1766            .await
1767            .unwrap();
1768
1769        assert!(report.created.is_empty());
1770        assert_eq!(report.conflicts.len(), 1);
1771        assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1772        assert_eq!(report.conflicts[0].source, exe);
1773        assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1774
1775        fs::remove_dir_all(&src).await.unwrap();
1776        fs::remove_dir_all(&bin).await.unwrap();
1777    }
1778
1779    #[cfg(unix)]
1780    #[tokio::test]
1781    async fn overwrites_stale_symlink_when_overwrite_true() {
1782        let (src, bin) = make_dirs().await;
1783        let exe = make_executable(&src, "run.sh").await;
1784        let other = make_executable(&src, "other.sh").await;
1785        tokio::fs::symlink(&other, bin.join("run")).await.unwrap();
1786
1787        let report = link_executables(&bin, std::slice::from_ref(&exe), true)
1788            .await
1789            .unwrap();
1790
1791        assert_eq!(report.overwritten.len(), 1);
1792        assert_eq!(fs::read_link(bin.join("run")).await.unwrap(), exe);
1793
1794        fs::remove_dir_all(&src).await.unwrap();
1795        fs::remove_dir_all(&bin).await.unwrap();
1796    }
1797
1798    #[cfg(unix)]
1799    #[tokio::test]
1800    async fn flattens_nested_preset_path_into_bin_dir() {
1801        let (src, bin) = make_dirs().await;
1802        let sub = src.join("shell").join("proxy");
1803        fs::create_dir_all(&sub).await.unwrap();
1804        let exe = {
1805            use std::os::unix::fs::PermissionsExt;
1806            let path = sub.join("set_proxy.sh");
1807            fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1808            let mut perms = fs::metadata(&path).await.unwrap().permissions();
1809            perms.set_mode(0o755);
1810            fs::set_permissions(&path, perms).await.unwrap();
1811            path
1812        };
1813
1814        let report = link_executables(&bin, &[exe], false).await.unwrap();
1815
1816        assert_eq!(report.created.len(), 1);
1817        assert!(bin.join("set_proxy").exists());
1818
1819        fs::remove_dir_all(&src).await.unwrap();
1820        fs::remove_dir_all(&bin).await.unwrap();
1821    }
1822
1823    #[cfg(unix)]
1824    #[tokio::test]
1825    async fn reports_collision_when_two_sources_share_basename() {
1826        let (src, bin) = make_dirs().await;
1827        let sub1 = src.join("a");
1828        let sub2 = src.join("b");
1829        fs::create_dir_all(&sub1).await.unwrap();
1830        fs::create_dir_all(&sub2).await.unwrap();
1831        let exe1 = make_executable(&sub1, "run.sh").await;
1832        let exe2 = make_executable(&sub2, "run.sh").await;
1833
1834        let report = link_executables(&bin, &[exe1, exe2.clone()], false)
1835            .await
1836            .unwrap();
1837
1838        assert_eq!(report.created.len(), 1);
1839        assert_eq!(report.conflicts.len(), 1);
1840        assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1841        assert_eq!(report.conflicts[0].source, exe2);
1842        assert_eq!(report.conflicts[0].kind, LinkConflictKind::DuplicateName);
1843
1844        fs::remove_dir_all(&src).await.unwrap();
1845        fs::remove_dir_all(&bin).await.unwrap();
1846    }
1847
1848    #[cfg(unix)]
1849    #[tokio::test]
1850    async fn creates_symlink_with_explicit_link_name() {
1851        let (src, bin) = make_dirs().await;
1852        let exe = make_executable(&src, "set_proxy.sh").await;
1853        let specs = [LinkSpec {
1854            source: exe.clone(),
1855            link_name: OsString::from("setproxy"),
1856            runtime: LinkRuntime::Native,
1857            bun_dependencies: BunDependencyMode::Disabled,
1858            env: Vec::new(),
1859            render_target: None,
1860        }];
1861
1862        let report = link_executables_with_names(&bin, &specs, false)
1863            .await
1864            .unwrap();
1865
1866        assert_eq!(report.created.len(), 1);
1867        assert!(bin.join("setproxy").exists());
1868        assert!(!bin.join("set_proxy").exists());
1869        assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), exe);
1870
1871        fs::remove_dir_all(&src).await.unwrap();
1872        fs::remove_dir_all(&bin).await.unwrap();
1873    }
1874
1875    #[cfg(unix)]
1876    #[tokio::test]
1877    async fn links_non_executable_shell_script_source() {
1878        let (src, bin) = make_dirs().await;
1879        let script = src.join("set_proxy.sh");
1880        fs::write(&script, b"#!/bin/sh\n").await.unwrap();
1881        let specs = [LinkSpec {
1882            source: script.clone(),
1883            link_name: OsString::from("setproxy"),
1884            runtime: LinkRuntime::Native,
1885            bun_dependencies: BunDependencyMode::Disabled,
1886            env: Vec::new(),
1887            render_target: None,
1888        }];
1889
1890        let report = link_executables_with_names(&bin, &specs, false)
1891            .await
1892            .unwrap();
1893
1894        assert_eq!(report.created.len(), 1);
1895        assert!(bin.join("setproxy").exists());
1896        assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), script);
1897
1898        fs::remove_dir_all(&src).await.unwrap();
1899        fs::remove_dir_all(&bin).await.unwrap();
1900    }
1901
1902    #[cfg(unix)]
1903    #[tokio::test]
1904    async fn skips_non_executable_non_script_source_with_custom_name() {
1905        let (src, bin) = make_dirs().await;
1906        let plain = make_plain(&src, "proxy.txt").await;
1907        let specs = [LinkSpec {
1908            source: plain,
1909            link_name: OsString::from("setproxy"),
1910            runtime: LinkRuntime::Native,
1911            bun_dependencies: BunDependencyMode::Disabled,
1912            env: Vec::new(),
1913            render_target: None,
1914        }];
1915
1916        let report = link_executables_with_names(&bin, &specs, false)
1917            .await
1918            .unwrap();
1919
1920        assert!(report.created.is_empty());
1921        assert!(!bin.join("setproxy").exists());
1922
1923        fs::remove_dir_all(&src).await.unwrap();
1924        fs::remove_dir_all(&bin).await.unwrap();
1925    }
1926
1927    // --- unlink_managed tests ---
1928
1929    #[cfg(unix)]
1930    #[tokio::test]
1931    async fn unlink_removes_symlink_pointing_into_managed_root() {
1932        let (src, bin) = make_dirs().await;
1933        let exe = make_executable(&src, "run.sh").await;
1934        tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1935
1936        let report = unlink_managed(&bin, &src, false).await.unwrap();
1937
1938        assert_eq!(report.removed.len(), 1);
1939        assert!(!bin.join("run.sh").exists());
1940
1941        fs::remove_dir_all(&src).await.unwrap();
1942        fs::remove_dir_all(&bin).await.unwrap();
1943    }
1944
1945    #[cfg(unix)]
1946    #[tokio::test]
1947    async fn unlink_skips_symlink_outside_managed_root() {
1948        let (src, bin) = make_dirs().await;
1949        let outside = std::env::temp_dir().join(format!("shine-bl-out-{}", uuid::Uuid::new_v4()));
1950        fs::create_dir_all(&outside).await.unwrap();
1951        let exe = make_executable(&outside, "run.sh").await;
1952        tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1953
1954        let report = unlink_managed(&bin, &src, false).await.unwrap();
1955
1956        assert_eq!(report.skipped.len(), 1);
1957        assert!(bin.join("run.sh").is_symlink());
1958
1959        fs::remove_dir_all(&src).await.unwrap();
1960        fs::remove_dir_all(&bin).await.unwrap();
1961        fs::remove_dir_all(&outside).await.unwrap();
1962    }
1963
1964    #[cfg(unix)]
1965    #[tokio::test]
1966    async fn unlink_skips_regular_files_in_bin_dir() {
1967        let (src, bin) = make_dirs().await;
1968        make_plain(&bin, "user_script.sh").await;
1969
1970        let report = unlink_managed(&bin, &src, false).await.unwrap();
1971
1972        assert!(report.removed.is_empty());
1973        assert_eq!(report.skipped.len(), 1);
1974        assert!(bin.join("user_script.sh").exists());
1975
1976        fs::remove_dir_all(&src).await.unwrap();
1977        fs::remove_dir_all(&bin).await.unwrap();
1978    }
1979
1980    #[cfg(unix)]
1981    #[tokio::test]
1982    async fn unlink_dry_run_reports_but_does_not_remove() {
1983        let (src, bin) = make_dirs().await;
1984        let exe = make_executable(&src, "run.sh").await;
1985        tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1986
1987        let report = unlink_managed(&bin, &src, true).await.unwrap();
1988
1989        assert_eq!(report.removed.len(), 1);
1990        assert!(bin.join("run.sh").is_symlink(), "dry-run must not remove");
1991
1992        fs::remove_dir_all(&src).await.unwrap();
1993        fs::remove_dir_all(&bin).await.unwrap();
1994    }
1995
1996    #[cfg(unix)]
1997    #[tokio::test]
1998    async fn unlink_is_idempotent_on_empty_bin_dir() {
1999        let (src, bin) = make_dirs().await;
2000
2001        let r1 = unlink_managed(&bin, &src, false).await.unwrap();
2002        let r2 = unlink_managed(&bin, &src, false).await.unwrap();
2003
2004        assert!(r1.removed.is_empty());
2005        assert!(r2.removed.is_empty());
2006
2007        fs::remove_dir_all(&src).await.unwrap();
2008        fs::remove_dir_all(&bin).await.unwrap();
2009    }
2010
2011    #[tokio::test]
2012    async fn unlink_returns_empty_report_when_bin_dir_missing() {
2013        let missing = std::env::temp_dir().join(format!("shine-bl-miss-{}", uuid::Uuid::new_v4()));
2014        let managed = std::env::temp_dir().join(format!("shine-bl-mgd-{}", uuid::Uuid::new_v4()));
2015
2016        let report = unlink_managed(&missing, &managed, false).await.unwrap();
2017
2018        assert!(report.removed.is_empty());
2019        assert!(report.skipped.is_empty());
2020    }
2021
2022    #[test]
2023    fn link_stem_strips_bun_extensions() {
2024        assert_eq!(link_stem(Path::new("tool.ts")), OsString::from("tool"));
2025        assert_eq!(link_stem(Path::new("tool.js")), OsString::from("tool"));
2026        assert_eq!(link_stem(Path::new("tool.mts")), OsString::from("tool"));
2027        assert_eq!(link_stem(Path::new("tool.mjs")), OsString::from("tool"));
2028    }
2029
2030    #[cfg(unix)]
2031    fn bun_spec(source: &Path, name: &str) -> LinkSpec {
2032        bun_spec_with_env(source, name, Vec::new())
2033    }
2034
2035    #[cfg(unix)]
2036    fn locked_bun_spec(source: &Path, name: &str) -> LinkSpec {
2037        LinkSpec {
2038            source: source.to_path_buf(),
2039            link_name: OsString::from(name),
2040            runtime: LinkRuntime::Bun,
2041            bun_dependencies: BunDependencyMode::Locked,
2042            env: Vec::new(),
2043            render_target: None,
2044        }
2045    }
2046
2047    #[cfg(unix)]
2048    fn bun_spec_with_env(source: &Path, name: &str, env: Vec<String>) -> LinkSpec {
2049        LinkSpec {
2050            source: source.to_path_buf(),
2051            link_name: OsString::from(name),
2052            runtime: LinkRuntime::Bun,
2053            bun_dependencies: BunDependencyMode::Disabled,
2054            env,
2055            render_target: None,
2056        }
2057    }
2058
2059    #[cfg(unix)]
2060    #[tokio::test]
2061    async fn creates_bun_launcher_as_marked_executable_regular_file() {
2062        use std::os::unix::fs::PermissionsExt;
2063        let (src, bin) = make_dirs().await;
2064        let script = src.join("tool.ts");
2065        fs::write(&script, b"console.log('hi')\n").await.unwrap();
2066
2067        let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2068            .await
2069            .unwrap();
2070
2071        assert_eq!(report.created.len(), 1);
2072        let launcher = bin.join("tool");
2073        assert!(launcher.exists());
2074        assert!(
2075            !launcher.is_symlink(),
2076            "bun launcher must be a regular file, not a symlink"
2077        );
2078        let content = fs::read_to_string(&launcher).await.unwrap();
2079        assert!(content.contains("# shine-managed"));
2080        assert!(content.contains(&format!("# shine-target: {}", script.display())));
2081        assert!(content.contains("command -v bun"));
2082        assert!(content.contains("exit 127"));
2083        assert!(content.contains(&format!(
2084            "exec bun --no-install '{}' \"$@\"",
2085            script.display()
2086        )));
2087        let mode = fs::metadata(&launcher).await.unwrap().permissions().mode();
2088        assert!(mode & 0o111 != 0, "launcher must be executable");
2089
2090        fs::remove_dir_all(&src).await.unwrap();
2091        fs::remove_dir_all(&bin).await.unwrap();
2092    }
2093
2094    #[cfg(unix)]
2095    #[tokio::test]
2096    async fn bun_launcher_is_idempotent_and_refreshes_when_stale() {
2097        let (src, bin) = make_dirs().await;
2098        let script = src.join("tool.ts");
2099        fs::write(&script, b"console.log('hi')\n").await.unwrap();
2100
2101        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2102            .await
2103            .unwrap();
2104        let again = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2105            .await
2106            .unwrap();
2107        assert_eq!(
2108            again.skipped.len(),
2109            1,
2110            "identical launcher should be skipped"
2111        );
2112        assert!(again.created.is_empty());
2113        assert!(again.overwritten.is_empty());
2114
2115        // Same marker + target but different body → stale, refreshed without --force.
2116        let launcher = bin.join("tool");
2117        fs::write(
2118            &launcher,
2119            format!(
2120                "#!/usr/bin/env bash\n# shine-managed\n# shine-target: {}\necho stale\n",
2121                script.display()
2122            ),
2123        )
2124        .await
2125        .unwrap();
2126        let refreshed = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2127            .await
2128            .unwrap();
2129        assert_eq!(
2130            refreshed.overwritten.len(),
2131            1,
2132            "stale launcher should refresh"
2133        );
2134        assert!(
2135            fs::read_to_string(&launcher)
2136                .await
2137                .unwrap()
2138                .contains("exec bun")
2139        );
2140
2141        fs::remove_dir_all(&src).await.unwrap();
2142        fs::remove_dir_all(&bin).await.unwrap();
2143    }
2144
2145    #[cfg(unix)]
2146    #[tokio::test]
2147    async fn bun_launcher_conflicts_with_user_file_unless_forced() {
2148        let (src, bin) = make_dirs().await;
2149        let script = src.join("tool.ts");
2150        fs::write(&script, b"console.log('hi')\n").await.unwrap();
2151        // A user's own file at the same command name, no managed marker.
2152        make_plain(&bin, "tool").await;
2153
2154        let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2155            .await
2156            .unwrap();
2157        assert_eq!(report.conflicts.len(), 1);
2158        assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
2159        assert_eq!(fs::read_to_string(bin.join("tool")).await.unwrap(), "data");
2160
2161        let forced = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], true)
2162            .await
2163            .unwrap();
2164        assert_eq!(forced.overwritten.len(), 1);
2165        assert!(
2166            fs::read_to_string(bin.join("tool"))
2167                .await
2168                .unwrap()
2169                .contains("exec bun")
2170        );
2171
2172        fs::remove_dir_all(&src).await.unwrap();
2173        fs::remove_dir_all(&bin).await.unwrap();
2174    }
2175
2176    #[cfg(unix)]
2177    #[tokio::test]
2178    async fn unlink_removes_managed_bun_launcher_but_skips_user_file() {
2179        let (src, bin) = make_dirs().await;
2180        let script = src.join("tool.ts");
2181        fs::write(&script, b"console.log('hi')\n").await.unwrap();
2182        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2183            .await
2184            .unwrap();
2185        // A user's own regular file that must survive uninstall.
2186        make_plain(&bin, "user_tool").await;
2187
2188        let report = unlink_managed(&bin, &src, false).await.unwrap();
2189
2190        assert!(report.removed.iter().any(|p| p.ends_with("tool")));
2191        assert!(
2192            !bin.join("tool").exists(),
2193            "managed launcher should be removed"
2194        );
2195        assert!(
2196            bin.join("user_tool").exists(),
2197            "user file must be preserved"
2198        );
2199        assert!(report.skipped.iter().any(|p| p.ends_with("user_tool")));
2200
2201        fs::remove_dir_all(&src).await.unwrap();
2202        fs::remove_dir_all(&bin).await.unwrap();
2203    }
2204
2205    #[cfg(unix)]
2206    #[tokio::test]
2207    async fn bun_launcher_without_env_has_no_shine_dependency() {
2208        let (src, bin) = make_dirs().await;
2209        let script = src.join("tool.ts");
2210        fs::write(&script, b"console.log('hi')\n").await.unwrap();
2211
2212        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2213            .await
2214            .unwrap();
2215
2216        let content = fs::read_to_string(bin.join("tool")).await.unwrap();
2217        assert!(
2218            content.contains(&format!(
2219                "exec bun --no-install '{}' \"$@\"",
2220                script.display()
2221            )),
2222            "no-env launcher must run bun directly: {content}"
2223        );
2224        assert!(
2225            !content.contains("shine env run"),
2226            "no-env launcher must not depend on shine: {content}"
2227        );
2228
2229        fs::remove_dir_all(&src).await.unwrap();
2230        fs::remove_dir_all(&bin).await.unwrap();
2231    }
2232
2233    #[cfg(unix)]
2234    #[tokio::test]
2235    async fn bun_launcher_with_env_wraps_shine_env_run() {
2236        let (src, bin) = make_dirs().await;
2237        let script = src.join("tool.ts");
2238        fs::write(&script, b"console.log('hi')\n").await.unwrap();
2239
2240        let env = vec!["API_URL".to_string(), "SERVICE_TOKEN=API_TOKEN".to_string()];
2241        link_executables_with_names(&bin, &[bun_spec_with_env(&script, "tool", env)], false)
2242            .await
2243            .unwrap();
2244
2245        let content = fs::read_to_string(bin.join("tool")).await.unwrap();
2246        // Both prerequisites are checked with a 127 exit.
2247        assert!(content.contains("command -v bun"));
2248        assert!(content.contains("command -v shine"));
2249        assert_eq!(content.matches("exit 127").count(), 2);
2250        // The child runs through shine env run with the declared, ordered specs.
2251        assert!(content.contains(&format!(
2252            "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun --no-install '{}' \"$@\"",
2253            script.display()
2254        )));
2255
2256        fs::remove_dir_all(&src).await.unwrap();
2257        fs::remove_dir_all(&bin).await.unwrap();
2258    }
2259
2260    #[cfg(unix)]
2261    #[tokio::test]
2262    async fn locked_bun_launcher_uses_fallback_and_refreshes_disabled_launcher() {
2263        let (src, bin) = make_dirs().await;
2264        let script = src.join("tool.ts");
2265        fs::write(&script, b"import 'zod'\n").await.unwrap();
2266
2267        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2268            .await
2269            .unwrap();
2270        let refreshed =
2271            link_executables_with_names(&bin, &[locked_bun_spec(&script, "tool")], false)
2272                .await
2273                .unwrap();
2274        assert_eq!(refreshed.overwritten.len(), 1);
2275        let content = fs::read_to_string(bin.join("tool")).await.unwrap();
2276        assert!(content.contains("exec bun --install=fallback"));
2277
2278        fs::remove_dir_all(&src).await.unwrap();
2279        fs::remove_dir_all(&bin).await.unwrap();
2280    }
2281
2282    #[cfg(unix)]
2283    #[tokio::test]
2284    async fn bun_launcher_refreshes_when_env_declaration_changes() {
2285        let (src, bin) = make_dirs().await;
2286        let script = src.join("tool.ts");
2287        fs::write(&script, b"console.log('hi')\n").await.unwrap();
2288
2289        // Install with no env, then replace the same source with a declaration.
2290        link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2291            .await
2292            .unwrap();
2293        let changed = link_executables_with_names(
2294            &bin,
2295            &[bun_spec_with_env(
2296                &script,
2297                "tool",
2298                vec!["API_URL".to_string()],
2299            )],
2300            false,
2301        )
2302        .await
2303        .unwrap();
2304        assert_eq!(
2305            changed.overwritten.len(),
2306            1,
2307            "adding an env declaration must refresh the launcher without --force"
2308        );
2309
2310        // Re-running with the same declaration is a no-op (byte-identical).
2311        let again = link_executables_with_names(
2312            &bin,
2313            &[bun_spec_with_env(
2314                &script,
2315                "tool",
2316                vec!["API_URL".to_string()],
2317            )],
2318            false,
2319        )
2320        .await
2321        .unwrap();
2322        assert_eq!(again.skipped.len(), 1);
2323        assert!(again.overwritten.is_empty());
2324
2325        fs::remove_dir_all(&src).await.unwrap();
2326        fs::remove_dir_all(&bin).await.unwrap();
2327    }
2328
2329    #[cfg(not(unix))]
2330    #[test]
2331    fn shell_shims_pass_bash_compatible_paths_on_windows() {
2332        let source = PathBuf::from(r"C:\Users\me\.shine\rendered\shell\utils\copyfile.sh");
2333
2334        let ps1 = powershell_shim_content(
2335            &source,
2336            LinkRuntime::Native,
2337            "copyfile",
2338            BunDependencyMode::Disabled,
2339            &[],
2340            None,
2341        );
2342        let cmd = cmd_shim_content(
2343            &source,
2344            LinkRuntime::Native,
2345            "copyfile",
2346            BunDependencyMode::Disabled,
2347            &[],
2348            None,
2349        );
2350
2351        assert!(ps1.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
2352        assert!(cmd.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
2353        assert!(!ps1.contains(r"& bash 'C:\Users\me"));
2354    }
2355}