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(¤t_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(¤t_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}