1use std::{
9 error::Error,
10 fmt,
11 path::{Component, Path, PathBuf},
12};
13
14use crate::{
15 env::{PathEnvOverrides, PlatformEnvironment, PlatformKind},
16 project::{
17 DATABASE_FILE_NAME, MODEL_CATALOG_CACHE_FILE_NAME, MODEL_FALLBACK_FILE_NAME,
18 MODEL_PROFILES_FILE_NAME, REPOSITORY_SHARD_DATABASE_FILE_NAME, REPOSITORY_SHARDS_DIR_NAME,
19 STORAGE_BACKENDS_DIR_NAME, VERSION_CHECK_CACHE_FILE_NAME,
20 },
21};
22
23mod repository_root;
24
25pub use crate::project::APP_DIR_NAME;
26pub use repository_root::{RepositoryRootDiscoveryError, discover_repository_root};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RuntimePaths {
31 pub config_dir: PathBuf,
32 pub data_dir: PathBuf,
33 pub state_dir: PathBuf,
34 pub cache_dir: PathBuf,
35 pub log_dir: PathBuf,
36 pub temp_dir: PathBuf,
37 pub runtime_dir: PathBuf,
38 pub service_dir: PathBuf,
39}
40
41impl RuntimePaths {
42 pub fn resolve(
44 environment: &PlatformEnvironment,
45 overrides: &PathEnvOverrides,
46 ) -> Result<Self, PathError> {
47 let defaults = if let Some(root) = overrides.home.as_deref() {
48 runtime_home_defaults(root)?
49 } else {
50 platform_defaults(environment)?
51 };
52
53 let resolved = Self {
54 config_dir: override_path(
55 PathPurpose::Config,
56 defaults.config_dir,
57 overrides.config_dir.as_deref(),
58 )?,
59 data_dir: override_path(
60 PathPurpose::Data,
61 defaults.data_dir,
62 overrides.data_dir.as_deref(),
63 )?,
64 state_dir: override_path(
65 PathPurpose::State,
66 defaults.state_dir,
67 overrides.state_dir.as_deref(),
68 )?,
69 cache_dir: override_path(
70 PathPurpose::Cache,
71 defaults.cache_dir,
72 overrides.cache_dir.as_deref(),
73 )?,
74 log_dir: override_path(
75 PathPurpose::Log,
76 defaults.log_dir,
77 overrides.log_dir.as_deref(),
78 )?,
79 temp_dir: override_path(
80 PathPurpose::Temp,
81 defaults.temp_dir,
82 overrides.temp_dir.as_deref(),
83 )?,
84 runtime_dir: override_path(
85 PathPurpose::Runtime,
86 defaults.runtime_dir,
87 overrides.runtime_dir.as_deref(),
88 )?,
89 service_dir: override_path(
90 PathPurpose::Service,
91 defaults.service_dir,
92 overrides.service_dir.as_deref(),
93 )?,
94 };
95
96 validate_all(&resolved)?;
97 Ok(resolved)
98 }
99
100 pub fn agent_audit_log_file(&self) -> PathBuf {
102 self.log_dir.join("agent-audit.jsonl")
103 }
104
105 pub fn database_file(&self) -> PathBuf {
107 self.data_dir.join(DATABASE_FILE_NAME)
108 }
109
110 pub fn repository_shards_dir(&self) -> PathBuf {
112 self.data_dir
113 .join(STORAGE_BACKENDS_DIR_NAME)
114 .join(REPOSITORY_SHARDS_DIR_NAME)
115 }
116
117 pub fn repository_shard_database_file(&self, repository_id: &str) -> PathBuf {
119 self.repository_shards_dir()
120 .join(repository_shard_dir_name(repository_id))
121 .join(REPOSITORY_SHARD_DATABASE_FILE_NAME)
122 }
123
124 pub fn model_profiles_file(&self) -> PathBuf {
126 self.config_dir.join(MODEL_PROFILES_FILE_NAME)
127 }
128
129 pub fn model_fallback_file(&self) -> PathBuf {
131 self.config_dir.join(MODEL_FALLBACK_FILE_NAME)
132 }
133
134 pub fn model_catalog_cache_file(&self) -> PathBuf {
136 self.cache_dir.join(MODEL_CATALOG_CACHE_FILE_NAME)
137 }
138
139 pub fn version_check_cache_file(&self) -> PathBuf {
141 self.cache_dir.join(VERSION_CHECK_CACHE_FILE_NAME)
142 }
143}
144
145pub fn windows_tasklist_command(system_root: Option<&std::ffi::OsStr>) -> PathBuf {
147 system_root
148 .map(PathBuf::from)
149 .map(|root| root.join("System32").join("tasklist.exe"))
150 .filter(|path| path.exists())
151 .unwrap_or_else(|| PathBuf::from("tasklist.exe"))
152}
153
154pub fn default_user_document_roots(
156 environment: &PlatformEnvironment,
157) -> Result<Vec<PathBuf>, PathError> {
158 let home = match environment.platform {
159 PlatformKind::Windows => environment
160 .home_dir
161 .as_deref()
162 .map(|path| validate_path(PathPurpose::Home, path).map(|_| path.to_path_buf()))
163 .transpose()?,
164 _ => validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?
165 .map(Path::to_path_buf),
166 };
167 let Some(home) = home else {
168 return Ok(Vec::new());
169 };
170
171 Ok(["Documents", "Desktop", "Downloads"]
172 .into_iter()
173 .map(|child| home.join(child))
174 .collect())
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum PathPurpose {
180 Home,
181 Config,
182 Data,
183 State,
184 Cache,
185 Log,
186 Temp,
187 Runtime,
188 Service,
189}
190
191impl fmt::Display for PathPurpose {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 match self {
194 Self::Home => write!(formatter, "home"),
195 Self::Config => write!(formatter, "config"),
196 Self::Data => write!(formatter, "data"),
197 Self::State => write!(formatter, "state"),
198 Self::Cache => write!(formatter, "cache"),
199 Self::Log => write!(formatter, "log"),
200 Self::Temp => write!(formatter, "temp"),
201 Self::Runtime => write!(formatter, "runtime"),
202 Self::Service => write!(formatter, "service"),
203 }
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct PathError {
210 pub purpose: PathPurpose,
211 pub kind: PathErrorKind,
212}
213
214impl PathError {
215 fn missing_base(purpose: PathPurpose, variable: &'static str) -> Self {
216 Self {
217 purpose,
218 kind: PathErrorKind::MissingBase { variable },
219 }
220 }
221
222 fn relative(purpose: PathPurpose, path: &Path) -> Self {
223 Self {
224 purpose,
225 kind: PathErrorKind::RelativePath {
226 path: path.to_path_buf(),
227 },
228 }
229 }
230
231 fn parent_component(purpose: PathPurpose, path: &Path) -> Self {
232 Self {
233 purpose,
234 kind: PathErrorKind::ParentComponent {
235 path: path.to_path_buf(),
236 },
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum PathErrorKind {
244 MissingBase { variable: &'static str },
245 RelativePath { path: PathBuf },
246 ParentComponent { path: PathBuf },
247}
248
249impl fmt::Display for PathError {
250 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251 match &self.kind {
252 PathErrorKind::MissingBase { variable } => write!(
253 formatter,
254 "cannot resolve {} directory because {variable} is unavailable",
255 self.purpose
256 ),
257 PathErrorKind::RelativePath { path } => write!(
258 formatter,
259 "{} directory must be absolute, got {}",
260 self.purpose,
261 path.display()
262 ),
263 PathErrorKind::ParentComponent { path } => write!(
264 formatter,
265 "{} directory must not contain '..', got {}",
266 self.purpose,
267 path.display()
268 ),
269 }
270 }
271}
272
273impl Error for PathError {}
274
275fn runtime_home_defaults(root: &Path) -> Result<RuntimePaths, PathError> {
276 validate_path(PathPurpose::Home, root)?;
277
278 Ok(RuntimePaths {
279 config_dir: root.join("config"),
280 data_dir: root.join("data"),
281 state_dir: root.join("state"),
282 cache_dir: root.join("cache"),
283 log_dir: root.join("logs"),
284 temp_dir: root.join("tmp"),
285 runtime_dir: root.join("run"),
286 service_dir: root.join("service"),
287 })
288}
289
290fn platform_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
291 match environment.platform {
292 PlatformKind::Macos => macos_defaults(environment),
293 PlatformKind::Windows => windows_defaults(environment),
294 PlatformKind::Unix | PlatformKind::Other => unix_defaults(environment),
295 }
296}
297
298fn unix_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
299 let home = validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?;
300 let config_base = base_or_home_child(
301 PathPurpose::Config,
302 environment.xdg_config_home.as_deref(),
303 home,
304 ".config",
305 PathBuf::from("/etc"),
306 )?;
307 let data_base = base_or_home_child(
308 PathPurpose::Data,
309 environment.xdg_data_home.as_deref(),
310 home,
311 ".local/share",
312 PathBuf::from("/var/lib"),
313 )?;
314 let state_base = base_or_home_child(
315 PathPurpose::State,
316 environment.xdg_state_home.as_deref(),
317 home,
318 ".local/state",
319 PathBuf::from("/var/lib"),
320 )?;
321 let cache_base = base_or_home_child(
322 PathPurpose::Cache,
323 environment.xdg_cache_home.as_deref(),
324 home,
325 ".cache",
326 PathBuf::from("/var/cache"),
327 )?;
328 let temp_base = optional_or_default(
329 PathPurpose::Temp,
330 environment.temp_dir.as_deref(),
331 PathBuf::from("/tmp"),
332 )?;
333 let state_dir = state_base.join(APP_DIR_NAME);
334 let runtime_dir = if let Some(runtime_base) =
335 validated_optional(PathPurpose::Runtime, environment.xdg_runtime_dir.as_deref())?
336 {
337 runtime_base.join(APP_DIR_NAME)
338 } else {
339 state_dir.join("run")
340 };
341
342 Ok(RuntimePaths {
343 config_dir: config_base.join(APP_DIR_NAME),
344 data_dir: data_base.join(APP_DIR_NAME),
345 state_dir: state_dir.clone(),
346 cache_dir: cache_base.join(APP_DIR_NAME),
347 log_dir: state_dir.join("logs"),
348 temp_dir: temp_base.join(APP_DIR_NAME),
349 runtime_dir,
350 service_dir: config_base.join(APP_DIR_NAME).join("service"),
351 })
352}
353
354fn macos_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
355 let home = required_base(
356 PathPurpose::Home,
357 environment.home_dir.as_deref(),
358 HOME_REQUIRED,
359 )?;
360 let application_support = home.join("Library").join("Application Support");
361 let state_dir = application_support.join(APP_DIR_NAME).join("state");
362
363 Ok(RuntimePaths {
364 config_dir: application_support.join(APP_DIR_NAME).join("config"),
365 data_dir: application_support.join(APP_DIR_NAME).join("data"),
366 state_dir: state_dir.clone(),
367 cache_dir: home.join("Library").join("Caches").join(APP_DIR_NAME),
368 log_dir: home.join("Library").join("Logs").join(APP_DIR_NAME),
369 temp_dir: optional_or_default(
370 PathPurpose::Temp,
371 environment.temp_dir.as_deref(),
372 PathBuf::from("/tmp"),
373 )?
374 .join(APP_DIR_NAME),
375 runtime_dir: state_dir.join("run"),
376 service_dir: home.join("Library").join("LaunchAgents"),
377 })
378}
379
380fn windows_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
381 let config_base = environment
382 .app_data
383 .as_deref()
384 .map(|path| validate_path(PathPurpose::Config, path).map(|_| path.to_path_buf()))
385 .transpose()?
386 .or_else(|| {
387 environment
388 .home_dir
389 .as_ref()
390 .map(|home| home.join("AppData/Roaming"))
391 })
392 .ok_or_else(|| PathError::missing_base(PathPurpose::Config, "APPDATA or HOME"))?;
393 let local_base = environment
394 .local_app_data
395 .as_deref()
396 .map(|path| validate_path(PathPurpose::Data, path).map(|_| path.to_path_buf()))
397 .transpose()?
398 .or_else(|| {
399 environment
400 .home_dir
401 .as_ref()
402 .map(|home| home.join("AppData/Local"))
403 })
404 .ok_or_else(|| PathError::missing_base(PathPurpose::Data, "LOCALAPPDATA or HOME"))?;
405 let root = local_base.join(APP_DIR_NAME);
406 let temp_dir = match environment.temp_dir.as_deref() {
407 Some(path) => {
408 validate_path(PathPurpose::Temp, path)?;
409 path.join(APP_DIR_NAME)
410 }
411 None => root.join("tmp"),
412 };
413
414 Ok(RuntimePaths {
415 config_dir: config_base.join(APP_DIR_NAME),
416 data_dir: root.join("data"),
417 state_dir: root.join("state"),
418 cache_dir: root.join("cache"),
419 log_dir: root.join("logs"),
420 temp_dir,
421 runtime_dir: root.join("run"),
422 service_dir: config_base.join(APP_DIR_NAME).join("service"),
423 })
424}
425
426const HOME_REQUIRED: &str = "HOME";
427
428fn base_or_home_child(
429 purpose: PathPurpose,
430 configured: Option<&Path>,
431 home: Option<&Path>,
432 home_child: &str,
433 fallback_base: PathBuf,
434) -> Result<PathBuf, PathError> {
435 if let Some(path) = configured {
436 validate_path(purpose, path)?;
437 return Ok(path.to_path_buf());
438 }
439
440 if let Some(path) = home {
441 validate_path(purpose, path)?;
442 return Ok(path.join(home_child));
443 }
444
445 validate_path(purpose, &fallback_base)?;
446 Ok(fallback_base)
447}
448
449fn required_base(
450 purpose: PathPurpose,
451 value: Option<&Path>,
452 variable: &'static str,
453) -> Result<PathBuf, PathError> {
454 value
455 .map(|path| validate_path(purpose, path).map(|_| path.to_path_buf()))
456 .transpose()?
457 .ok_or_else(|| PathError::missing_base(purpose, variable))
458}
459
460fn validated_optional(
461 purpose: PathPurpose,
462 value: Option<&Path>,
463) -> Result<Option<&Path>, PathError> {
464 if let Some(path) = value {
465 validate_path(purpose, path)?;
466 }
467
468 Ok(value)
469}
470
471fn optional_or_default(
472 purpose: PathPurpose,
473 value: Option<&Path>,
474 default: PathBuf,
475) -> Result<PathBuf, PathError> {
476 match value {
477 Some(path) => {
478 validate_path(purpose, path)?;
479 Ok(path.to_path_buf())
480 }
481 None => {
482 validate_path(purpose, &default)?;
483 Ok(default)
484 }
485 }
486}
487
488fn override_path(
489 purpose: PathPurpose,
490 default: PathBuf,
491 override_value: Option<&Path>,
492) -> Result<PathBuf, PathError> {
493 if let Some(path) = override_value {
494 validate_path(purpose, path)?;
495 Ok(path.to_path_buf())
496 } else {
497 Ok(default)
498 }
499}
500
501fn validate_all(paths: &RuntimePaths) -> Result<(), PathError> {
502 validate_path(PathPurpose::Config, &paths.config_dir)?;
503 validate_path(PathPurpose::Data, &paths.data_dir)?;
504 validate_path(PathPurpose::State, &paths.state_dir)?;
505 validate_path(PathPurpose::Cache, &paths.cache_dir)?;
506 validate_path(PathPurpose::Log, &paths.log_dir)?;
507 validate_path(PathPurpose::Temp, &paths.temp_dir)?;
508 validate_path(PathPurpose::Runtime, &paths.runtime_dir)?;
509 validate_path(PathPurpose::Service, &paths.service_dir)
510}
511
512fn validate_path(purpose: PathPurpose, path: &Path) -> Result<(), PathError> {
513 if !path.is_absolute() {
514 return Err(PathError::relative(purpose, path));
515 }
516
517 if path
518 .components()
519 .any(|component| matches!(component, Component::ParentDir))
520 {
521 return Err(PathError::parent_component(purpose, path));
522 }
523
524 Ok(())
525}
526
527fn repository_shard_dir_name(repository_id: &str) -> String {
528 let mut sanitized = String::with_capacity(repository_id.len().min(48) + 17);
529 for character in repository_id.chars().take(48) {
530 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
531 sanitized.push(character);
532 } else {
533 sanitized.push('_');
534 }
535 }
536 if sanitized.is_empty() {
537 sanitized.push_str("repository");
538 }
539
540 format!(
541 "{sanitized}-{:016x}",
542 stable_hash64(repository_id.as_bytes())
543 )
544}
545
546fn stable_hash64(bytes: &[u8]) -> u64 {
547 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
548 const FNV_PRIME: u64 = 0x100000001b3;
549
550 let mut hash = FNV_OFFSET_BASIS;
551 for byte in bytes {
552 hash ^= u64::from(*byte);
553 hash = hash.wrapping_mul(FNV_PRIME);
554 }
555
556 hash
557}
558
559#[cfg(test)]
560#[path = "mod_tests.rs"]
561mod tests;