1#![deny(missing_docs)]
7
8use std::ffi::OsString;
9use std::fmt;
10use std::path::PathBuf;
11
12pub const CODEWHALE_APP_DIR: &str = ".codewhale";
14
15pub const LEGACY_APP_DIR: &str = ".deepseek";
17
18#[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
53pub fn codewhale_home_override() -> Result<Option<PathBuf>, PathOverrideError> {
61 absolute_path_env("CODEWHALE_HOME")
62}
63
64#[must_use]
66pub fn codewhale_home_is_explicit() -> bool {
67 path_env("CODEWHALE_HOME").is_some()
68}
69
70#[must_use]
76pub fn legacy_deepseek_home_override() -> Option<PathBuf> {
77 path_env("DEEPSEEK_HOME")
78}
79
80#[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
106pub 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
114pub 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
126pub 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
136pub 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#[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}