Skip to main content

pithy_core/
branding.rs

1//! Single source of truth for product branding.
2//!
3//! Every product-name string that appears in filesystem paths, environment
4//! variables, launchd bundle identifiers, socket paths, binary names, or
5//! attribution strings derives from the constants in this module.
6//!
7//! Renaming the product reduces to changing the constants below plus running
8//! the Cargo/launchd/data-dir migration documented in `docs/BRANDING.md`.
9//!
10//! # Backward compatibility during rename
11//!
12//! The [`read_env`] helper tries the current prefix first and falls back to
13//! the historical `ULTRACOS_` prefix, so env vars set by old launchd plists
14//! keep working across a partial rollout.
15
16use std::path::PathBuf;
17
18// ─── Brand constants (SSOT) ───────────────────────────────────────────────────
19
20/// Lowercase product name. Used in filesystem paths, Cargo crate prefix,
21/// binary names, socket stems, and CLI identifiers.
22///
23/// Example uses: `~/.pithy/`, `/tmp/pithy.sock`, `pithy-shim` binary.
24pub const PRODUCT_NAME: &str = "pithy";
25
26/// Display-cased brand name. Used in prose, log prefixes, docs, attribution.
27///
28/// Must match the `PRODUCT_NAME` except for casing (validated by test).
29pub const PRODUCT_BRAND: &str = "Pithy";
30
31/// Uppercase prefix for environment variables.
32///
33/// Example: `PITHY_SHADOW_RATE`, `PITHY_EMBEDDER_SOCKET`.
34pub const ENV_PREFIX: &str = "PITHY";
35
36/// Launchd bundle-identifier namespace (reverse-DNS).
37///
38/// Full bundle IDs combine this with a component: `com.pithy.shim`,
39/// `com.pithy.controller`, `com.pithy.embedder`.
40pub const BUNDLE_NS: &str = "com.pithy";
41
42/// Config/data directory name under `$HOME`. Dot-prefixed.
43///
44/// Example: `~/.pithy/` holds `audit.jsonl`, `shadow.jsonl`,
45/// `rules.toml`, `snapshot.json`, sockets, and PID files.
46pub const CONFIG_DIR_NAME: &str = ".pithy";
47
48/// Public repository URL. Embedded in attribution strings.
49pub const REPO_URL: &str = "https://github.com/MikkoParkkola/pithy";
50
51/// Compile-time attribution string, unremovable in release builds.
52///
53/// Required by `LICENSE-ATTRIBUTION.md` for all derivatives. Validated at
54/// test time to contain [`PRODUCT_BRAND`] and [`REPO_URL`] so renames
55/// cannot leave the attribution desynchronised.
56pub const ATTRIBUTION: &str = "Powered by Pithy(R) - https://github.com/MikkoParkkola/pithy";
57
58// ─── Derived helpers ──────────────────────────────────────────────────────────
59
60/// Build a full environment-variable name by joining [`ENV_PREFIX`] with
61/// `suffix` using `_`.
62///
63/// # Examples
64/// ```
65/// use pithy_core::branding::env_var_name;
66/// assert_eq!(env_var_name("SHADOW_RATE"), "PITHY_SHADOW_RATE");
67/// ```
68#[must_use]
69pub fn env_var_name(suffix: &str) -> String {
70    format!("{ENV_PREFIX}_{suffix}")
71}
72
73/// Build the config-directory absolute path under the user's `$HOME`.
74///
75/// Falls back to `/tmp/<CONFIG_DIR_NAME>` when `HOME` is unset (rare; mostly
76/// minimal container environments) so callers never receive a panic.
77#[must_use]
78pub fn config_dir() -> PathBuf {
79    std::env::var_os("HOME")
80        .map(PathBuf::from)
81        .unwrap_or_else(|| PathBuf::from("/tmp"))
82        .join(CONFIG_DIR_NAME)
83}
84
85/// Build a Unix-socket path under `/tmp/` using the product-name stem.
86///
87/// # Examples
88/// ```
89/// use pithy_core::branding::socket_path;
90/// let p = socket_path("shim");
91/// assert_eq!(p.to_string_lossy(), "/tmp/pithy-shim.sock");
92/// ```
93#[must_use]
94pub fn socket_path(name: &str) -> PathBuf {
95    PathBuf::from("/tmp").join(format!("{PRODUCT_NAME}-{name}.sock"))
96}
97
98/// Build a JSONL log-file path under `/tmp/` using the product-name stem.
99///
100/// # Examples
101/// ```
102/// use pithy_core::branding::log_path;
103/// let p = log_path("shadow");
104/// assert_eq!(p.to_string_lossy(), "/tmp/pithy-shadow.jsonl");
105/// ```
106#[must_use]
107pub fn log_path(name: &str) -> PathBuf {
108    PathBuf::from("/tmp").join(format!("{PRODUCT_NAME}-{name}.jsonl"))
109}
110
111/// Compose a full launchd bundle identifier from the namespace and a
112/// component name.
113///
114/// # Examples
115/// ```
116/// use pithy_core::branding::bundle_id;
117/// assert_eq!(bundle_id("shim"), "com.pithy.shim");
118/// ```
119#[must_use]
120pub fn bundle_id(component: &str) -> String {
121    format!("{BUNDLE_NS}.{component}")
122}
123
124/// Read an environment variable by its suffix, trying current prefix first
125/// and falling back to the legacy `ULTRACOS_` prefix.
126///
127/// This lets a rollout flip [`ENV_PREFIX`] to a new value while old launchd
128/// plists still export variables under the legacy prefix — neither callsite
129/// nor daemon needs a code change during the transition window.
130///
131/// Returns `None` when neither variable is set or when the value is not
132/// valid Unicode.
133#[must_use]
134pub fn read_env(suffix: &str) -> Option<String> {
135    std::env::var(env_var_name(suffix))
136        .ok()
137        .or_else(|| std::env::var(format!("ULTRACOS_{suffix}")).ok())
138}
139
140// ─── Tests ────────────────────────────────────────────────────────────────────
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // GIVEN: brand constants
147    // WHEN:  compared case-insensitively
148    // THEN:  lowercase form matches uppercase form
149    #[test]
150    fn product_name_and_brand_agree_case_insensitive() {
151        assert_eq!(
152            PRODUCT_NAME.to_ascii_uppercase(),
153            PRODUCT_BRAND.to_ascii_uppercase(),
154            "PRODUCT_NAME={PRODUCT_NAME} and PRODUCT_BRAND={PRODUCT_BRAND} \
155             must differ only in casing; did a rename leave one behind?"
156        );
157    }
158
159    #[test]
160    fn env_prefix_matches_product_name_uppercase() {
161        assert_eq!(
162            ENV_PREFIX,
163            PRODUCT_NAME.to_ascii_uppercase(),
164            "ENV_PREFIX must be the uppercase form of PRODUCT_NAME"
165        );
166    }
167
168    #[test]
169    fn bundle_ns_uses_product_name() {
170        assert!(
171            BUNDLE_NS.ends_with(PRODUCT_NAME),
172            "BUNDLE_NS={BUNDLE_NS} must end with PRODUCT_NAME={PRODUCT_NAME}"
173        );
174    }
175
176    #[test]
177    fn config_dir_name_uses_product_name() {
178        assert_eq!(
179            CONFIG_DIR_NAME,
180            format!(".{PRODUCT_NAME}"),
181            "CONFIG_DIR_NAME must be the dot-prefixed PRODUCT_NAME"
182        );
183    }
184
185    #[test]
186    fn attribution_contains_brand() {
187        assert!(
188            ATTRIBUTION.contains(PRODUCT_BRAND),
189            "ATTRIBUTION must reference PRODUCT_BRAND={PRODUCT_BRAND}; \
190             got: {ATTRIBUTION}"
191        );
192    }
193
194    #[test]
195    fn attribution_contains_repo_url() {
196        assert!(
197            ATTRIBUTION.contains(REPO_URL),
198            "ATTRIBUTION must reference REPO_URL; got: {ATTRIBUTION}"
199        );
200    }
201
202    #[test]
203    fn env_var_name_joins_with_underscore() {
204        assert_eq!(
205            env_var_name("SHADOW_RATE"),
206            format!("{ENV_PREFIX}_SHADOW_RATE")
207        );
208        assert_eq!(env_var_name("BYPASS"), format!("{ENV_PREFIX}_BYPASS"));
209    }
210
211    #[test]
212    fn socket_path_uses_product_stem() {
213        let p = socket_path("shim");
214        assert_eq!(
215            p.to_string_lossy(),
216            format!("/tmp/{PRODUCT_NAME}-shim.sock")
217        );
218    }
219
220    #[test]
221    fn log_path_uses_product_stem() {
222        let p = log_path("shadow");
223        assert_eq!(
224            p.to_string_lossy(),
225            format!("/tmp/{PRODUCT_NAME}-shadow.jsonl")
226        );
227    }
228
229    #[test]
230    fn bundle_id_composes_from_ns_and_component() {
231        assert_eq!(bundle_id("shim"), format!("{BUNDLE_NS}.shim"));
232        assert_eq!(bundle_id("controller"), format!("{BUNDLE_NS}.controller"));
233        assert_eq!(bundle_id("embedder"), format!("{BUNDLE_NS}.embedder"));
234    }
235
236    // GIVEN: HOME is set to a known path
237    // WHEN:  config_dir is called
238    // THEN:  result is $HOME/<CONFIG_DIR_NAME>
239    #[test]
240    fn config_dir_joins_home_with_config_dir_name() {
241        std::env::set_var("HOME", "/tmp/branding-test-home");
242        let p = config_dir();
243        assert_eq!(
244            p.to_string_lossy(),
245            format!("/tmp/branding-test-home/{CONFIG_DIR_NAME}")
246        );
247    }
248
249    // GIVEN: an env var is set under the legacy ULTRACOS_ prefix, and the
250    //        current-prefix form is absent
251    // WHEN:  read_env is called with that suffix
252    // THEN:  the value is returned (fallback path exercised when current
253    //        prefix differs; coincident path exercised while ENV_PREFIX is
254    //        still "ULTRACOS")
255    #[test]
256    fn read_env_falls_back_to_legacy_ultracos_prefix() {
257        let suffix = "BRANDING_LEGACY_FALLBACK_TEST";
258        let legacy_key = format!("ULTRACOS_{suffix}");
259        let current_key = env_var_name(suffix);
260        // Start clean. Both removals in case siblings left state.
261        std::env::remove_var(&legacy_key);
262        std::env::remove_var(&current_key);
263        // Set only the legacy form.
264        std::env::set_var(&legacy_key, "legacy-hit");
265
266        let got = read_env(suffix);
267
268        // Clean up before assertion so a panic does not leak env state.
269        std::env::remove_var(&legacy_key);
270        std::env::remove_var(&current_key);
271        assert_eq!(got.as_deref(), Some("legacy-hit"));
272    }
273
274    #[test]
275    fn read_env_returns_none_when_unset() {
276        let key = "BRANDING_DEFINITELY_NOT_SET_XYZ";
277        std::env::remove_var(env_var_name(key));
278        std::env::remove_var(format!("ULTRACOS_{key}"));
279        assert_eq!(read_env(key), None);
280    }
281}