返回 CodeWhale
lib.rs
根目录 / crates / paths / src / lib.rs
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 ///
87 /// Only an absolute candidate is a home. A relative `HOME` (`HOME=.`,
88 /// `HOME=build`) would otherwise resolve against the process's working
89 /// directory and move config, state, and secrets into whatever repository the
90 /// process runs in. [`codewhale_home`] names that case as an error.
91 #[must_use]
92 pub fn user_home() -> Option<PathBuf> {
93 // An explicit invalid home is an error, not permission to fall back to
94 // the ambient Windows profile and escape a caller's isolation boundary.
95 if let Some(home) = path_env("HOME") {
96 return home.is_absolute().then_some(home);
97 }
98 path_env("USERPROFILE")
99 .filter(|path| path.is_absolute())
100 .or_else(windows_home_from_environment)
101 .or_else(dirs::home_dir)
102 .filter(|path| path.is_absolute())
103 }
104
105 #[cfg(windows)]
106 fn windows_home_from_environment() -> Option<PathBuf> {
107 let mut path = path_env("HOMEDRIVE")?;
108 path.push(path_env("HOMEPATH")?);
109 (!path.as_os_str().is_empty()).then_some(path)
110 }
111
112 #[cfg(not(windows))]
113 fn windows_home_from_environment() -> Option<PathBuf> {
114 None
115 }
116
117 /// Resolve the canonical Codewhale runtime home.
118 ///
119 /// A valid explicit `CODEWHALE_HOME` is returned after `~` expansion. Otherwise
120 /// this is `<user home>/.codewhale`.
121 pub fn codewhale_home() -> Result<Option<PathBuf>, PathOverrideError> {
122 if let Some(home) = codewhale_home_override()? {
123 return Ok(Some(home));
124 }
125 if let Some(home) = user_home() {
126 return Ok(Some(home.join(CODEWHALE_APP_DIR)));
127 }
128 // No absolute home anywhere: when that is because `HOME` is relative, say
129 // so rather than reporting a missing home.
130 match path_env("HOME").filter(|path| !path.is_absolute()) {
131 Some(path) => Err(PathOverrideError {
132 variable: "HOME",
133 path,
134 kind: PathOverrideErrorKind::Relative,
135 }),
136 None => Ok(None),
137 }
138 }
139
140 /// Return the explicit config-file override, preferring the Codewhale name.
141 ///
142 /// `~` is expanded through the canonical user-home resolver before the path is
143 /// validated. All other relative paths are rejected so a process working in a
144 /// repository can never turn a global config override into a repo-local file.
145 pub fn config_path_override() -> Result<Option<PathBuf>, PathOverrideError> {
146 if let Some(path) = absolute_path_env("CODEWHALE_CONFIG_PATH")? {
147 return Ok(Some(path));
148 }
149 absolute_path_env("DEEPSEEK_CONFIG_PATH")
150 }
151
152 /// Read an optional path environment variable and require a global path.
153 ///
154 /// Empty and whitespace-only values are treated as unset. A leading `~` path
155 /// is expanded first; any path still relative after expansion is rejected.
156 pub fn absolute_path_env(variable: &'static str) -> Result<Option<PathBuf>, PathOverrideError> {
157 path_env(variable)
158 .map(|path| validate_absolute_path(variable, path))
159 .transpose()
160 }
161
162 /// Expand a leading `~` and reject a path that is not absolute.
163 pub fn validate_absolute_path(
164 variable: &'static str,
165 path: PathBuf,
166 ) -> Result<PathBuf, PathOverrideError> {
167 let original = path.clone();
168 let path = match path.to_str() {
169 Some("~") => user_home().ok_or_else(|| PathOverrideError {
170 variable,
171 path: original.clone(),
172 kind: PathOverrideErrorKind::HomeUnavailable,
173 })?,
174 Some(value)
175 if value
176 .strip_prefix('~')
177 .is_some_and(|suffix| suffix.starts_with('/') || suffix.starts_with('\\')) =>
178 {
179 let mut home = user_home().ok_or_else(|| PathOverrideError {
180 variable,
181 path: original.clone(),
182 kind: PathOverrideErrorKind::HomeUnavailable,
183 })?;
184 let suffix = value[1..].trim_start_matches(['/', '\\']);
185 if !suffix.is_empty() {
186 home.push(suffix);
187 }
188 home
189 }
190 _ => path,
191 };
192
193 if path.is_absolute() {
194 Ok(path)
195 } else {
196 Err(PathOverrideError {
197 variable,
198 path: original,
199 kind: PathOverrideErrorKind::Relative,
200 })
201 }
202 }
203
204 /// Resolve the ambient legacy DeepSeek home used for compatibility reads.
205 ///
206 /// This never follows `CODEWHALE_HOME`: callers must suppress legacy fallback
207 /// whenever [`codewhale_home_is_explicit`] is true.
208 #[must_use]
209 pub fn legacy_deepseek_home() -> Option<PathBuf> {
210 user_home().map(|home| home.join(LEGACY_APP_DIR))
211 }
212
213 fn path_env(name: &str) -> Option<PathBuf> {
214 std::env::var_os(name).and_then(normalize_path_value)
215 }
216
217 fn normalize_path_value(value: OsString) -> Option<PathBuf> {
218 if value.is_empty() {
219 return None;
220 }
221 match value.to_str() {
222 Some(value) => {
223 let value = value.trim();
224 (!value.is_empty()).then(|| PathBuf::from(value))
225 }
226 None => Some(PathBuf::from(value)),
227 }
228 }
229
230 #[cfg(test)]
231 mod tests {
232 use super::*;
233
234 #[test]
235 fn unicode_path_values_are_trimmed_and_whitespace_is_unset() {
236 assert_eq!(
237 normalize_path_value(OsString::from(" /tmp/codewhale ")),
238 Some(PathBuf::from("/tmp/codewhale"))
239 );
240 assert_eq!(normalize_path_value(OsString::from(" \t\n ")), None);
241 assert_eq!(normalize_path_value(OsString::new()), None);
242 }
243
244 #[test]
245 fn relative_global_overrides_are_rejected_with_the_variable_name() {
246 let error = validate_absolute_path(
247 "CODEWHALE_CONFIG_PATH",
248 PathBuf::from(".codewhale/config.toml"),
249 )
250 .expect_err("relative global config path must fail closed");
251 let message = error.to_string();
252 assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
253 assert!(message.contains(".codewhale/config.toml"), "{message}");
254 assert!(message.contains("absolute"), "{message}");
255 }
256
257 #[test]
258 fn absolute_global_overrides_are_preserved() {
259 let path = if cfg!(windows) {
260 PathBuf::from(r"C:\codewhale\config.toml")
261 } else {
262 PathBuf::from("/tmp/codewhale/config.toml")
263 };
264 assert_eq!(
265 validate_absolute_path("CODEWHALE_CONFIG_PATH", path.clone()),
266 Ok(path)
267 );
268 }
269
270 #[cfg(unix)]
271 #[test]
272 fn unix_non_unicode_path_values_are_preserved() {
273 use std::os::unix::ffi::OsStringExt;
274
275 let value = OsString::from_vec(b"codewhale-\xff-home".to_vec());
276 assert_eq!(
277 normalize_path_value(value.clone()),
278 Some(PathBuf::from(value))
279 );
280 }
281
282 #[cfg(windows)]
283 #[test]
284 fn windows_non_unicode_path_values_are_preserved() {
285 use std::os::windows::ffi::OsStringExt;
286
287 let value = OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xd800]);
288 assert_eq!(
289 normalize_path_value(value.clone()),
290 Some(PathBuf::from(value))
291 );
292 }
293 }
294
294 lines RUST