Skip to main content

codewhale_paths/
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
8use std::ffi::OsString;
9use std::fmt;
10use std::path::PathBuf;
11
12/// Canonical Codewhale app directory name under the user home.
13pub const CODEWHALE_APP_DIR: &str = ".codewhale";
14
15/// Legacy DeepSeek-branded directory retained for compatibility reads.
16pub 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)]
20pub struct PathOverrideError {
21    variable: &'static str,
22    path: PathBuf,
23    kind: PathOverrideErrorKind,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum PathOverrideErrorKind {
28    Relative,
29    HomeUnavailable,
30}
31
32impl 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
51impl 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.
60pub 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]
66pub 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]
76pub 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]
87pub 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)]
95fn 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))]
102fn 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`.
110pub 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.
119pub 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.
130pub 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.
137pub 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]
183pub fn legacy_deepseek_home() -> Option<PathBuf> {
184    user_home().map(|home| home.join(LEGACY_APP_DIR))
185}
186
187fn path_env(name: &str) -> Option<PathBuf> {
188    std::env::var_os(name).and_then(normalize_path_value)
189}
190
191fn 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)]
205mod 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}