Skip to main content

kimun_notes/update/
channel.rs

1//! Install-channel detection. Decides whether this binary may self-update or
2//! must defer to a package manager. See adr/0013.
3//!
4//! Order of precedence:
5//!   1. The install marker (`install.toml`) written by `install.sh` — deterministic.
6//!   2. A heuristic on the canonicalised executable path.
7//!
8//! Anything that cannot be classified fails safe to notify-only.
9
10use std::env;
11use std::path::Path;
12use std::sync::OnceLock;
13
14const MARKER_FILE: &str = "install.toml";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum InstallChannel {
18    /// Installed via the official `install.sh`.
19    Script,
20    /// A manually downloaded release archive.
21    Direct,
22    /// Homebrew tap — package-manager owned, notify-only.
23    Brew,
24    /// `cargo install` — package-manager owned, notify-only.
25    Cargo,
26    /// Could not determine; treated as notify-only.
27    Unknown,
28}
29
30impl InstallChannel {
31    /// Whether kimün may replace its own binary on this channel.
32    pub fn self_update_eligible(self) -> bool {
33        matches!(self, Self::Script | Self::Direct)
34    }
35
36    /// The command a user should run to upgrade on a package-manager channel,
37    /// or `None` where self-update applies (or the channel is unknown).
38    pub fn upgrade_hint(self) -> Option<&'static str> {
39        match self {
40            Self::Brew => Some("brew upgrade kimun"),
41            Self::Cargo => Some("cargo install kimun-notes"),
42            _ => None,
43        }
44    }
45}
46
47#[derive(serde::Deserialize)]
48struct InstallMarker {
49    channel: String,
50}
51
52/// Detect how the running binary was installed. `config_dir` is kimün's config
53/// directory (where `install.sh` writes the marker).
54///
55/// The marker (cheap file read, depends on `config_dir`) is consulted first.
56/// The fallback path heuristic — `current_exe` canonicalisation plus a
57/// filesystem writability probe — is the expensive part and is invariant for
58/// the process, so only *it* is cached. Caching keyed on the result of an
59/// argument would let the first caller's `config_dir` win forever.
60pub fn detect(config_dir: &Path) -> InstallChannel {
61    if let Some(channel) = channel_from_marker(config_dir) {
62        return channel;
63    }
64    static EXE_CHANNEL: OnceLock<InstallChannel> = OnceLock::new();
65    *EXE_CHANNEL.get_or_init(channel_from_exe_path)
66}
67
68fn channel_from_marker(config_dir: &Path) -> Option<InstallChannel> {
69    let raw = std::fs::read_to_string(config_dir.join(MARKER_FILE)).ok()?;
70    let marker: InstallMarker = toml::from_str(&raw).ok()?;
71    match marker.channel.as_str() {
72        "script" => Some(InstallChannel::Script),
73        "direct" => Some(InstallChannel::Direct),
74        "brew" => Some(InstallChannel::Brew),
75        "cargo" => Some(InstallChannel::Cargo),
76        _ => None,
77    }
78}
79
80fn channel_from_exe_path() -> InstallChannel {
81    let exe = match env::current_exe().and_then(|p| p.canonicalize()) {
82        Ok(p) => p,
83        // No idea where we live — do not risk touching a managed binary.
84        Err(_) => return InstallChannel::Unknown,
85    };
86    let path = exe.to_string_lossy();
87
88    // Homebrew: an explicit prefix env var, or the Cellar layout the formula
89    // installs into (current_exe is canonicalised, so brew's bin symlink is
90    // already resolved into the Cellar path).
91    if let Ok(prefix) = env::var("HOMEBREW_PREFIX")
92        && !prefix.is_empty()
93        && path.starts_with(prefix.as_str())
94    {
95        return InstallChannel::Brew;
96    }
97    if path.contains("/Cellar/") || path.contains("/homebrew/") {
98        return InstallChannel::Brew;
99    }
100
101    // cargo install: under CARGO_HOME/bin or ~/.cargo/bin.
102    if let Ok(cargo_home) = env::var("CARGO_HOME")
103        && !cargo_home.is_empty()
104        && exe.starts_with(&cargo_home)
105    {
106        return InstallChannel::Cargo;
107    }
108    if let Ok(home) = crate::settings::get_home_dir()
109        && exe.starts_with(home.join(".cargo").join("bin"))
110    {
111        return InstallChannel::Cargo;
112    }
113
114    // Otherwise the user placed this binary themselves. Only call it
115    // self-update eligible if its directory is actually writable: a binary in a
116    // root-owned/system location (e.g. /usr/bin, a distro package, the Nix
117    // store) must stay notify-only and never be overwritten in place, even
118    // though it is neither brew nor cargo.
119    match exe.parent() {
120        Some(dir) if dir_is_writable(dir) => InstallChannel::Direct,
121        _ => InstallChannel::Unknown,
122    }
123}
124
125/// Whether a probe file can be created in `dir` (i.e. the current user may
126/// write there). Cleans up the probe. A best-effort check used only to gate
127/// self-update eligibility; on any error it returns false (fail safe).
128fn dir_is_writable(dir: &Path) -> bool {
129    let probe = dir.join(format!(".kimun-write-probe-{}", std::process::id()));
130    match std::fs::File::create(&probe) {
131        Ok(_) => {
132            let _ = std::fs::remove_file(&probe);
133            true
134        }
135        Err(_) => false,
136    }
137}