truce_utils/shell_sidecar.rs
1//! Sidecar file that pins the `--shell` logic dylib path at install time.
2//!
3//! `cargo truce install --shell` writes one of these per plugin; the
4//! shell binary loaded by the DAW reads it at first hot-reload to find
5//! the matching logic dylib.
6//!
7//! ## Path layout
8//!
9//! ```text
10//! ~/.truce/shell/<crate_name>.path
11//! ```
12//!
13//! `<crate_name>` matches the consuming crate's `CARGO_PKG_NAME`. The
14//! file content is one line: the absolute path to the logic dylib
15//! (e.g. `/Users/me/projects/my-plugin/target/shell/libmy_plugin.dylib`).
16//! No TOML / no JSON — a single path keeps both writer and reader
17//! trivial and parser-free.
18//!
19//! ## Why `~/.truce/` and not the bundle
20//!
21//! Per-bundle sidecars (e.g. `MyPlugin.clap/Contents/.truce-shell`)
22//! were considered, but the runtime read would need `dladdr` /
23//! `GetModuleFileName` to locate the shell binary's own path on disk.
24//! Putting the sidecar at a `crate_name`-keyed home-relative path
25//! sidesteps that: the shell binary already has `env!("CARGO_PKG_NAME")`
26//! baked at compile time, so the read site needs only `$HOME` plus the
27//! crate name. Trade-off: only one shell install per crate at a time,
28//! which is fine — the only reason to install the same plugin twice is
29//! beta/release coexistence, and shell-mode is a dev-loop feature.
30
31use std::path::PathBuf;
32
33/// Resolve `$HOME/.truce/shell/` (the directory the per-crate sidecar
34/// files live in). Returns `None` when neither `HOME` (Unix) nor
35/// `USERPROFILE` (Windows) is set — the caller should fail loud
36/// rather than guess a path.
37#[must_use]
38pub fn shell_dir() -> Option<PathBuf> {
39 let home = home_dir()?;
40 Some(home.join(".truce").join("shell"))
41}
42
43/// Resolve `$HOME/.truce/shell/<crate_name>.path` for a given crate.
44/// `crate_name` is the consuming crate's `CARGO_PKG_NAME` — the
45/// reader passes `env!("CARGO_PKG_NAME")` and the writer passes the
46/// resolved plugin's `crate_name` from `truce.toml`.
47#[must_use]
48pub fn sidecar_path(crate_name: &str) -> Option<PathBuf> {
49 Some(shell_dir()?.join(format!("{crate_name}.path")))
50}
51
52fn home_dir() -> Option<PathBuf> {
53 // Unix: HOME. Windows: USERPROFILE. No external `dirs` dep — both
54 // env vars are set by every shell / login session truce supports.
55 if let Ok(home) = std::env::var("HOME")
56 && !home.is_empty()
57 {
58 return Some(PathBuf::from(home));
59 }
60 if let Ok(profile) = std::env::var("USERPROFILE")
61 && !profile.is_empty()
62 {
63 return Some(PathBuf::from(profile));
64 }
65 None
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn sidecar_path_layout() {
74 // Don't mutate $HOME (truce-utils forbids unsafe blocks; the
75 // 2024-edition `std::env::set_var` is unsafe). Instead, accept
76 // both outcomes: when HOME / USERPROFILE is set the path ends
77 // with the expected suffix; otherwise it's None and the writer
78 // surfaces a clear error.
79 if let Some(p) = sidecar_path("my-plugin") {
80 assert!(p.ends_with(".truce/shell/my-plugin.path"));
81 }
82 }
83}