| 1 | //! Canonical user-scoped runtime path resolution for Codewhale. |
| 2 | //! |
| 3 | //! This leaf crate owns only the environment and platform-home decision. File |
| 4 | //! migration and per-subsystem fallback remain with the crate that owns those |
| 5 | //! files. |
| 6 | #![deny(missing_docs)] |
| 7 | |
| 8 | use std::ffi::OsString; |
| 9 | use std::fmt; |
| 10 | use std::path::PathBuf; |
| 11 | |
| 12 | /// Canonical Codewhale app directory name under the user home. |
| 13 | pub const CODEWHALE_APP_DIR: &str = ".codewhale"; |
| 14 | |
| 15 | /// Legacy DeepSeek-branded directory retained for compatibility reads. |
| 16 | pub const LEGACY_APP_DIR: &str = ".deepseek"; |
| 17 | |
| 18 | /// An environment-provided runtime path was not safe to use as a global path. |
| 19 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 20 | pub struct PathOverrideError { |
| 21 | variable: &'static str, |
| 22 | path: PathBuf, |
| 23 | kind: PathOverrideErrorKind, |
| 24 | } |
| 25 | |
| 26 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 27 | enum PathOverrideErrorKind { |
| 28 | Relative, |
| 29 | HomeUnavailable, |
| 30 | } |
| 31 | |
| 32 | impl fmt::Display for PathOverrideError { |
| 33 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 34 | match self.kind { |
| 35 | PathOverrideErrorKind::Relative => write!( |
| 36 | formatter, |
| 37 | "{} must be an absolute path, got {}", |
| 38 | self.variable, |
| 39 | self.path.display() |
| 40 | ), |
| 41 | PathOverrideErrorKind::HomeUnavailable => write!( |
| 42 | formatter, |
| 43 | "{} uses '~', but the user home directory could not be resolved: {}", |
| 44 | self.variable, |
| 45 | self.path.display() |
| 46 | ), |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | impl std::error::Error for PathOverrideError {} |
| 52 | |
| 53 | /// Return the explicit Codewhale home override, if one is configured. |
| 54 | /// |
| 55 | /// Unicode values are trimmed so whitespace-only values are treated as unset, |
| 56 | /// matching the existing config and secret-store contract. Non-Unicode path |
| 57 | /// values are preserved on platforms that support them instead of silently |
| 58 | /// dropping an otherwise valid filesystem path. A leading `~` is expanded; |
| 59 | /// every other relative value is rejected. |
| 60 | pub fn codewhale_home_override() -> Result<Option<PathBuf>, PathOverrideError> { |
| 61 | absolute_path_env("CODEWHALE_HOME") |
| 62 | } |
| 63 | |
| 64 | /// Whether `CODEWHALE_HOME` establishes an explicit isolation boundary. |
| 65 | #[must_use] |
| 66 | pub fn codewhale_home_is_explicit() -> bool { |
| 67 | path_env("CODEWHALE_HOME").is_some() |
| 68 | } |
| 69 | |
| 70 | /// Return the legacy `DEEPSEEK_HOME` compatibility override, if configured. |
| 71 | /// |
| 72 | /// New state must use [`codewhale_home`]. This resolver exists only for readers |
| 73 | /// whose persisted format still explicitly supports the legacy environment |
| 74 | /// alias. |
| 75 | #[must_use] |
| 76 | pub fn legacy_deepseek_home_override() -> Option<PathBuf> { |
| 77 | path_env("DEEPSEEK_HOME") |
| 78 | } |
| 79 | |
| 80 | /// Resolve the user's platform home, preferring `HOME` before `USERPROFILE`. |
| 81 | /// |
| 82 | /// The explicit environment order makes CLI, state, config, and secret paths |
| 83 | /// deterministic in hermetic shells. On Windows, `HOMEDRIVE` plus `HOMEPATH` |
| 84 | /// remains a compatibility fallback before the platform resolver. The platform |
| 85 | /// resolver remains last for ordinary desktop launches without those variables. |
| 86 | #[must_use] |
| 87 | pub fn user_home() -> Option<PathBuf> { |
| 88 | path_env("HOME") |
| 89 | .or_else(|| path_env("USERPROFILE")) |
| 90 | .or_else(windows_home_from_environment) |
| 91 | .or_else(dirs::home_dir) |
| 92 | } |
| 93 | |
| 94 | #[cfg(windows)] |
| 95 | fn windows_home_from_environment() -> Option<PathBuf> { |
| 96 | let mut path = path_env("HOMEDRIVE")?; |
| 97 | path.push(path_env("HOMEPATH")?); |
| 98 | (!path.as_os_str().is_empty()).then_some(path) |
| 99 | } |
| 100 | |
| 101 | #[cfg(not(windows))] |
| 102 | fn windows_home_from_environment() -> Option<PathBuf> { |
| 103 | None |
| 104 | } |
| 105 | |
| 106 | /// Resolve the canonical Codewhale runtime home. |
| 107 | /// |
| 108 | /// A valid explicit `CODEWHALE_HOME` is returned after `~` expansion. Otherwise |
| 109 | /// this is `<user home>/.codewhale`. |
| 110 | pub fn codewhale_home() -> Result<Option<PathBuf>, PathOverrideError> { |
| 111 | Ok(codewhale_home_override()?.or_else(|| user_home().map(|home| home.join(CODEWHALE_APP_DIR)))) |
| 112 | } |
| 113 | |
| 114 | /// Return the explicit config-file override, preferring the Codewhale name. |
| 115 | /// |
| 116 | /// `~` is expanded through the canonical user-home resolver before the path is |
| 117 | /// validated. All other relative paths are rejected so a process working in a |
| 118 | /// repository can never turn a global config override into a repo-local file. |
| 119 | pub fn config_path_override() -> Result<Option<PathBuf>, PathOverrideError> { |
| 120 | if let Some(path) = absolute_path_env("CODEWHALE_CONFIG_PATH")? { |
| 121 | return Ok(Some(path)); |
| 122 | } |
| 123 | absolute_path_env("DEEPSEEK_CONFIG_PATH") |
| 124 | } |
| 125 | |
| 126 | /// Read an optional path environment variable and require a global path. |
| 127 | /// |
| 128 | /// Empty and whitespace-only values are treated as unset. A leading `~` path |
| 129 | /// is expanded first; any path still relative after expansion is rejected. |
| 130 | pub fn absolute_path_env(variable: &'static str) -> Result<Option<PathBuf>, PathOverrideError> { |
| 131 | path_env(variable) |
| 132 | .map(|path| validate_absolute_path(variable, path)) |
| 133 | .transpose() |
| 134 | } |
| 135 | |
| 136 | /// Expand a leading `~` and reject a path that is not absolute. |
| 137 | pub fn validate_absolute_path( |
| 138 | variable: &'static str, |
| 139 | path: PathBuf, |
| 140 | ) -> Result<PathBuf, PathOverrideError> { |
| 141 | let original = path.clone(); |
| 142 | let path = match path.to_str() { |
| 143 | Some("~") => user_home().ok_or_else(|| PathOverrideError { |
| 144 | variable, |
| 145 | path: original.clone(), |
| 146 | kind: PathOverrideErrorKind::HomeUnavailable, |
| 147 | })?, |
| 148 | Some(value) |
| 149 | if value |
| 150 | .strip_prefix('~') |
| 151 | .is_some_and(|suffix| suffix.starts_with('/') || suffix.starts_with('\\')) => |
| 152 | { |
| 153 | let mut home = user_home().ok_or_else(|| PathOverrideError { |
| 154 | variable, |
| 155 | path: original.clone(), |
| 156 | kind: PathOverrideErrorKind::HomeUnavailable, |
| 157 | })?; |
| 158 | let suffix = value[1..].trim_start_matches(['/', '\\']); |
| 159 | if !suffix.is_empty() { |
| 160 | home.push(suffix); |
| 161 | } |
| 162 | home |
| 163 | } |
| 164 | _ => path, |
| 165 | }; |
| 166 | |
| 167 | if path.is_absolute() { |
| 168 | Ok(path) |
| 169 | } else { |
| 170 | Err(PathOverrideError { |
| 171 | variable, |
| 172 | path: original, |
| 173 | kind: PathOverrideErrorKind::Relative, |
| 174 | }) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /// Resolve the ambient legacy DeepSeek home used for compatibility reads. |
| 179 | /// |
| 180 | /// This never follows `CODEWHALE_HOME`: callers must suppress legacy fallback |
| 181 | /// whenever [`codewhale_home_is_explicit`] is true. |
| 182 | #[must_use] |
| 183 | pub fn legacy_deepseek_home() -> Option<PathBuf> { |
| 184 | user_home().map(|home| home.join(LEGACY_APP_DIR)) |
| 185 | } |
| 186 | |
| 187 | fn path_env(name: &str) -> Option<PathBuf> { |
| 188 | std::env::var_os(name).and_then(normalize_path_value) |
| 189 | } |
| 190 | |
| 191 | fn normalize_path_value(value: OsString) -> Option<PathBuf> { |
| 192 | if value.is_empty() { |
| 193 | return None; |
| 194 | } |
| 195 | match value.to_str() { |
| 196 | Some(value) => { |
| 197 | let value = value.trim(); |
| 198 | (!value.is_empty()).then(|| PathBuf::from(value)) |
| 199 | } |
| 200 | None => Some(PathBuf::from(value)), |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | #[cfg(test)] |
| 205 | mod tests { |
| 206 | use super::*; |
| 207 | |
| 208 | #[test] |
| 209 | fn unicode_path_values_are_trimmed_and_whitespace_is_unset() { |
| 210 | assert_eq!( |
| 211 | normalize_path_value(OsString::from(" /tmp/codewhale ")), |
| 212 | Some(PathBuf::from("/tmp/codewhale")) |
| 213 | ); |
| 214 | assert_eq!(normalize_path_value(OsString::from(" \t\n ")), None); |
| 215 | assert_eq!(normalize_path_value(OsString::new()), None); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn relative_global_overrides_are_rejected_with_the_variable_name() { |
| 220 | let error = validate_absolute_path( |
| 221 | "CODEWHALE_CONFIG_PATH", |
| 222 | PathBuf::from(".codewhale/config.toml"), |
| 223 | ) |
| 224 | .expect_err("relative global config path must fail closed"); |
| 225 | let message = error.to_string(); |
| 226 | assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}"); |
| 227 | assert!(message.contains(".codewhale/config.toml"), "{message}"); |
| 228 | assert!(message.contains("absolute"), "{message}"); |
| 229 | } |
| 230 | |
| 231 | #[test] |
| 232 | fn absolute_global_overrides_are_preserved() { |
| 233 | let path = if cfg!(windows) { |
| 234 | PathBuf::from(r"C:\codewhale\config.toml") |
| 235 | } else { |
| 236 | PathBuf::from("/tmp/codewhale/config.toml") |
| 237 | }; |
| 238 | assert_eq!( |
| 239 | validate_absolute_path("CODEWHALE_CONFIG_PATH", path.clone()), |
| 240 | Ok(path) |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | #[cfg(unix)] |
| 245 | #[test] |
| 246 | fn unix_non_unicode_path_values_are_preserved() { |
| 247 | use std::os::unix::ffi::OsStringExt; |
| 248 | |
| 249 | let value = OsString::from_vec(b"codewhale-\xff-home".to_vec()); |
| 250 | assert_eq!( |
| 251 | normalize_path_value(value.clone()), |
| 252 | Some(PathBuf::from(value)) |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | #[cfg(windows)] |
| 257 | #[test] |
| 258 | fn windows_non_unicode_path_values_are_preserved() { |
| 259 | use std::os::windows::ffi::OsStringExt; |
| 260 | |
| 261 | let value = OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xd800]); |
| 262 | assert_eq!( |
| 263 | normalize_path_value(value.clone()), |
| 264 | Some(PathBuf::from(value)) |
| 265 | ); |
| 266 | } |
| 267 | } |
| 268 |