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
23pub use crate::project::APP_DIR_NAME;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RuntimePaths {
28 pub config_dir: PathBuf,
29 pub data_dir: PathBuf,
30 pub state_dir: PathBuf,
31 pub cache_dir: PathBuf,
32 pub log_dir: PathBuf,
33 pub temp_dir: PathBuf,
34 pub runtime_dir: PathBuf,
35 pub service_dir: PathBuf,
36}
37
38impl RuntimePaths {
39 pub fn resolve(
41 environment: &PlatformEnvironment,
42 overrides: &PathEnvOverrides,
43 ) -> Result<Self, PathError> {
44 let defaults = if let Some(root) = overrides.home.as_deref() {
45 runtime_home_defaults(root)?
46 } else {
47 platform_defaults(environment)?
48 };
49
50 let resolved = Self {
51 config_dir: override_path(
52 PathPurpose::Config,
53 defaults.config_dir,
54 overrides.config_dir.as_deref(),
55 )?,
56 data_dir: override_path(
57 PathPurpose::Data,
58 defaults.data_dir,
59 overrides.data_dir.as_deref(),
60 )?,
61 state_dir: override_path(
62 PathPurpose::State,
63 defaults.state_dir,
64 overrides.state_dir.as_deref(),
65 )?,
66 cache_dir: override_path(
67 PathPurpose::Cache,
68 defaults.cache_dir,
69 overrides.cache_dir.as_deref(),
70 )?,
71 log_dir: override_path(
72 PathPurpose::Log,
73 defaults.log_dir,
74 overrides.log_dir.as_deref(),
75 )?,
76 temp_dir: override_path(
77 PathPurpose::Temp,
78 defaults.temp_dir,
79 overrides.temp_dir.as_deref(),
80 )?,
81 runtime_dir: override_path(
82 PathPurpose::Runtime,
83 defaults.runtime_dir,
84 overrides.runtime_dir.as_deref(),
85 )?,
86 service_dir: override_path(
87 PathPurpose::Service,
88 defaults.service_dir,
89 overrides.service_dir.as_deref(),
90 )?,
91 };
92
93 validate_all(&resolved)?;
94 Ok(resolved)
95 }
96
97 pub fn agent_audit_log_file(&self) -> PathBuf {
99 self.log_dir.join("agent-audit.jsonl")
100 }
101
102 pub fn database_file(&self) -> PathBuf {
104 self.data_dir.join(DATABASE_FILE_NAME)
105 }
106
107 pub fn repository_shards_dir(&self) -> PathBuf {
109 self.data_dir
110 .join(STORAGE_BACKENDS_DIR_NAME)
111 .join(REPOSITORY_SHARDS_DIR_NAME)
112 }
113
114 pub fn repository_shard_database_file(&self, repository_id: &str) -> PathBuf {
116 self.repository_shards_dir()
117 .join(repository_shard_dir_name(repository_id))
118 .join(REPOSITORY_SHARD_DATABASE_FILE_NAME)
119 }
120
121 pub fn model_profiles_file(&self) -> PathBuf {
123 self.config_dir.join(MODEL_PROFILES_FILE_NAME)
124 }
125
126 pub fn model_fallback_file(&self) -> PathBuf {
128 self.config_dir.join(MODEL_FALLBACK_FILE_NAME)
129 }
130
131 pub fn model_catalog_cache_file(&self) -> PathBuf {
133 self.cache_dir.join(MODEL_CATALOG_CACHE_FILE_NAME)
134 }
135
136 pub fn version_check_cache_file(&self) -> PathBuf {
138 self.cache_dir.join(VERSION_CHECK_CACHE_FILE_NAME)
139 }
140}
141
142pub fn default_user_document_roots(
144 environment: &PlatformEnvironment,
145) -> Result<Vec<PathBuf>, PathError> {
146 let home = match environment.platform {
147 PlatformKind::Windows => environment
148 .home_dir
149 .as_deref()
150 .map(|path| validate_path(PathPurpose::Home, path).map(|_| path.to_path_buf()))
151 .transpose()?,
152 _ => validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?
153 .map(Path::to_path_buf),
154 };
155 let Some(home) = home else {
156 return Ok(Vec::new());
157 };
158
159 Ok(["Documents", "Desktop", "Downloads"]
160 .into_iter()
161 .map(|child| home.join(child))
162 .collect())
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum PathPurpose {
168 Home,
169 Config,
170 Data,
171 State,
172 Cache,
173 Log,
174 Temp,
175 Runtime,
176 Service,
177}
178
179impl fmt::Display for PathPurpose {
180 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181 match self {
182 Self::Home => write!(formatter, "home"),
183 Self::Config => write!(formatter, "config"),
184 Self::Data => write!(formatter, "data"),
185 Self::State => write!(formatter, "state"),
186 Self::Cache => write!(formatter, "cache"),
187 Self::Log => write!(formatter, "log"),
188 Self::Temp => write!(formatter, "temp"),
189 Self::Runtime => write!(formatter, "runtime"),
190 Self::Service => write!(formatter, "service"),
191 }
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct PathError {
198 pub purpose: PathPurpose,
199 pub kind: PathErrorKind,
200}
201
202impl PathError {
203 fn missing_base(purpose: PathPurpose, variable: &'static str) -> Self {
204 Self {
205 purpose,
206 kind: PathErrorKind::MissingBase { variable },
207 }
208 }
209
210 fn relative(purpose: PathPurpose, path: &Path) -> Self {
211 Self {
212 purpose,
213 kind: PathErrorKind::RelativePath {
214 path: path.to_path_buf(),
215 },
216 }
217 }
218
219 fn parent_component(purpose: PathPurpose, path: &Path) -> Self {
220 Self {
221 purpose,
222 kind: PathErrorKind::ParentComponent {
223 path: path.to_path_buf(),
224 },
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
231pub enum PathErrorKind {
232 MissingBase { variable: &'static str },
233 RelativePath { path: PathBuf },
234 ParentComponent { path: PathBuf },
235}
236
237impl fmt::Display for PathError {
238 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
239 match &self.kind {
240 PathErrorKind::MissingBase { variable } => write!(
241 formatter,
242 "cannot resolve {} directory because {variable} is unavailable",
243 self.purpose
244 ),
245 PathErrorKind::RelativePath { path } => write!(
246 formatter,
247 "{} directory must be absolute, got {}",
248 self.purpose,
249 path.display()
250 ),
251 PathErrorKind::ParentComponent { path } => write!(
252 formatter,
253 "{} directory must not contain '..', got {}",
254 self.purpose,
255 path.display()
256 ),
257 }
258 }
259}
260
261impl Error for PathError {}
262
263fn runtime_home_defaults(root: &Path) -> Result<RuntimePaths, PathError> {
264 validate_path(PathPurpose::Home, root)?;
265
266 Ok(RuntimePaths {
267 config_dir: root.join("config"),
268 data_dir: root.join("data"),
269 state_dir: root.join("state"),
270 cache_dir: root.join("cache"),
271 log_dir: root.join("logs"),
272 temp_dir: root.join("tmp"),
273 runtime_dir: root.join("run"),
274 service_dir: root.join("service"),
275 })
276}
277
278fn platform_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
279 match environment.platform {
280 PlatformKind::Macos => macos_defaults(environment),
281 PlatformKind::Windows => windows_defaults(environment),
282 PlatformKind::Unix | PlatformKind::Other => unix_defaults(environment),
283 }
284}
285
286fn unix_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
287 let home = validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?;
288 let config_base = base_or_home_child(
289 PathPurpose::Config,
290 environment.xdg_config_home.as_deref(),
291 home,
292 ".config",
293 PathBuf::from("/etc"),
294 )?;
295 let data_base = base_or_home_child(
296 PathPurpose::Data,
297 environment.xdg_data_home.as_deref(),
298 home,
299 ".local/share",
300 PathBuf::from("/var/lib"),
301 )?;
302 let state_base = base_or_home_child(
303 PathPurpose::State,
304 environment.xdg_state_home.as_deref(),
305 home,
306 ".local/state",
307 PathBuf::from("/var/lib"),
308 )?;
309 let cache_base = base_or_home_child(
310 PathPurpose::Cache,
311 environment.xdg_cache_home.as_deref(),
312 home,
313 ".cache",
314 PathBuf::from("/var/cache"),
315 )?;
316 let temp_base = optional_or_default(
317 PathPurpose::Temp,
318 environment.temp_dir.as_deref(),
319 PathBuf::from("/tmp"),
320 )?;
321 let state_dir = state_base.join(APP_DIR_NAME);
322 let runtime_dir = if let Some(runtime_base) =
323 validated_optional(PathPurpose::Runtime, environment.xdg_runtime_dir.as_deref())?
324 {
325 runtime_base.join(APP_DIR_NAME)
326 } else {
327 state_dir.join("run")
328 };
329
330 Ok(RuntimePaths {
331 config_dir: config_base.join(APP_DIR_NAME),
332 data_dir: data_base.join(APP_DIR_NAME),
333 state_dir: state_dir.clone(),
334 cache_dir: cache_base.join(APP_DIR_NAME),
335 log_dir: state_dir.join("logs"),
336 temp_dir: temp_base.join(APP_DIR_NAME),
337 runtime_dir,
338 service_dir: config_base.join(APP_DIR_NAME).join("service"),
339 })
340}
341
342fn macos_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
343 let home = required_base(
344 PathPurpose::Home,
345 environment.home_dir.as_deref(),
346 HOME_REQUIRED,
347 )?;
348 let application_support = home.join("Library").join("Application Support");
349 let state_dir = application_support.join(APP_DIR_NAME).join("state");
350
351 Ok(RuntimePaths {
352 config_dir: application_support.join(APP_DIR_NAME).join("config"),
353 data_dir: application_support.join(APP_DIR_NAME).join("data"),
354 state_dir: state_dir.clone(),
355 cache_dir: home.join("Library").join("Caches").join(APP_DIR_NAME),
356 log_dir: home.join("Library").join("Logs").join(APP_DIR_NAME),
357 temp_dir: optional_or_default(
358 PathPurpose::Temp,
359 environment.temp_dir.as_deref(),
360 PathBuf::from("/tmp"),
361 )?
362 .join(APP_DIR_NAME),
363 runtime_dir: state_dir.join("run"),
364 service_dir: home.join("Library").join("LaunchAgents"),
365 })
366}
367
368fn windows_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
369 let config_base = environment
370 .app_data
371 .as_deref()
372 .map(|path| validate_path(PathPurpose::Config, path).map(|_| path.to_path_buf()))
373 .transpose()?
374 .or_else(|| {
375 environment
376 .home_dir
377 .as_ref()
378 .map(|home| home.join("AppData/Roaming"))
379 })
380 .ok_or_else(|| PathError::missing_base(PathPurpose::Config, "APPDATA or HOME"))?;
381 let local_base = environment
382 .local_app_data
383 .as_deref()
384 .map(|path| validate_path(PathPurpose::Data, 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/Local"))
391 })
392 .ok_or_else(|| PathError::missing_base(PathPurpose::Data, "LOCALAPPDATA or HOME"))?;
393 let root = local_base.join(APP_DIR_NAME);
394 let temp_dir = match environment.temp_dir.as_deref() {
395 Some(path) => {
396 validate_path(PathPurpose::Temp, path)?;
397 path.join(APP_DIR_NAME)
398 }
399 None => root.join("tmp"),
400 };
401
402 Ok(RuntimePaths {
403 config_dir: config_base.join(APP_DIR_NAME),
404 data_dir: root.join("data"),
405 state_dir: root.join("state"),
406 cache_dir: root.join("cache"),
407 log_dir: root.join("logs"),
408 temp_dir,
409 runtime_dir: root.join("run"),
410 service_dir: config_base.join(APP_DIR_NAME).join("service"),
411 })
412}
413
414const HOME_REQUIRED: &str = "HOME";
415
416fn base_or_home_child(
417 purpose: PathPurpose,
418 configured: Option<&Path>,
419 home: Option<&Path>,
420 home_child: &str,
421 fallback_base: PathBuf,
422) -> Result<PathBuf, PathError> {
423 if let Some(path) = configured {
424 validate_path(purpose, path)?;
425 return Ok(path.to_path_buf());
426 }
427
428 if let Some(path) = home {
429 validate_path(purpose, path)?;
430 return Ok(path.join(home_child));
431 }
432
433 validate_path(purpose, &fallback_base)?;
434 Ok(fallback_base)
435}
436
437fn required_base(
438 purpose: PathPurpose,
439 value: Option<&Path>,
440 variable: &'static str,
441) -> Result<PathBuf, PathError> {
442 value
443 .map(|path| validate_path(purpose, path).map(|_| path.to_path_buf()))
444 .transpose()?
445 .ok_or_else(|| PathError::missing_base(purpose, variable))
446}
447
448fn validated_optional(
449 purpose: PathPurpose,
450 value: Option<&Path>,
451) -> Result<Option<&Path>, PathError> {
452 if let Some(path) = value {
453 validate_path(purpose, path)?;
454 }
455
456 Ok(value)
457}
458
459fn optional_or_default(
460 purpose: PathPurpose,
461 value: Option<&Path>,
462 default: PathBuf,
463) -> Result<PathBuf, PathError> {
464 match value {
465 Some(path) => {
466 validate_path(purpose, path)?;
467 Ok(path.to_path_buf())
468 }
469 None => {
470 validate_path(purpose, &default)?;
471 Ok(default)
472 }
473 }
474}
475
476fn override_path(
477 purpose: PathPurpose,
478 default: PathBuf,
479 override_value: Option<&Path>,
480) -> Result<PathBuf, PathError> {
481 if let Some(path) = override_value {
482 validate_path(purpose, path)?;
483 Ok(path.to_path_buf())
484 } else {
485 Ok(default)
486 }
487}
488
489fn validate_all(paths: &RuntimePaths) -> Result<(), PathError> {
490 validate_path(PathPurpose::Config, &paths.config_dir)?;
491 validate_path(PathPurpose::Data, &paths.data_dir)?;
492 validate_path(PathPurpose::State, &paths.state_dir)?;
493 validate_path(PathPurpose::Cache, &paths.cache_dir)?;
494 validate_path(PathPurpose::Log, &paths.log_dir)?;
495 validate_path(PathPurpose::Temp, &paths.temp_dir)?;
496 validate_path(PathPurpose::Runtime, &paths.runtime_dir)?;
497 validate_path(PathPurpose::Service, &paths.service_dir)
498}
499
500fn validate_path(purpose: PathPurpose, path: &Path) -> Result<(), PathError> {
501 if !path.is_absolute() {
502 return Err(PathError::relative(purpose, path));
503 }
504
505 if path
506 .components()
507 .any(|component| matches!(component, Component::ParentDir))
508 {
509 return Err(PathError::parent_component(purpose, path));
510 }
511
512 Ok(())
513}
514
515fn repository_shard_dir_name(repository_id: &str) -> String {
516 let mut sanitized = String::with_capacity(repository_id.len().min(48) + 17);
517 for character in repository_id.chars().take(48) {
518 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
519 sanitized.push(character);
520 } else {
521 sanitized.push('_');
522 }
523 }
524 if sanitized.is_empty() {
525 sanitized.push_str("repository");
526 }
527
528 format!(
529 "{sanitized}-{:016x}",
530 stable_hash64(repository_id.as_bytes())
531 )
532}
533
534fn stable_hash64(bytes: &[u8]) -> u64 {
535 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
536 const FNV_PRIME: u64 = 0x100000001b3;
537
538 let mut hash = FNV_OFFSET_BASIS;
539 for byte in bytes {
540 hash ^= u64::from(*byte);
541 hash = hash.wrapping_mul(FNV_PRIME);
542 }
543
544 hash
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::env::{PathEnvOverrides, PlatformEnvironment, PlatformKind};
551
552 fn unix_environment() -> PlatformEnvironment {
553 PlatformEnvironment {
554 platform: PlatformKind::Unix,
555 home_dir: Some(PathBuf::from("/home/alice")),
556 xdg_config_home: Some(PathBuf::from("/config")),
557 xdg_data_home: Some(PathBuf::from("/data")),
558 xdg_state_home: Some(PathBuf::from("/state")),
559 xdg_cache_home: Some(PathBuf::from("/cache")),
560 xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")),
561 app_data: None,
562 local_app_data: None,
563 temp_dir: Some(PathBuf::from("/tmp")),
564 }
565 }
566
567 #[test]
568 fn resolves_unix_platform_paths() {
569 let paths = RuntimePaths::resolve(&unix_environment(), &PathEnvOverrides::default())
570 .expect("paths should resolve");
571
572 assert_eq!(paths.config_dir, PathBuf::from("/config/relay-knowledge"));
573 assert_eq!(paths.data_dir, PathBuf::from("/data/relay-knowledge"));
574 assert_eq!(paths.state_dir, PathBuf::from("/state/relay-knowledge"));
575 assert_eq!(paths.cache_dir, PathBuf::from("/cache/relay-knowledge"));
576 assert_eq!(paths.log_dir, PathBuf::from("/state/relay-knowledge/logs"));
577 assert_eq!(
578 paths.runtime_dir,
579 PathBuf::from("/run/user/1000/relay-knowledge")
580 );
581 }
582
583 #[test]
584 fn runtime_home_override_keeps_state_out_of_repository_paths() {
585 let overrides = PathEnvOverrides {
586 home: Some(PathBuf::from("/srv/relay")),
587 ..PathEnvOverrides::default()
588 };
589
590 let paths =
591 RuntimePaths::resolve(&unix_environment(), &overrides).expect("paths should resolve");
592
593 assert_eq!(paths.config_dir, PathBuf::from("/srv/relay/config"));
594 assert_eq!(paths.data_dir, PathBuf::from("/srv/relay/data"));
595 assert_eq!(paths.cache_dir, PathBuf::from("/srv/relay/cache"));
596 assert_eq!(paths.log_dir, PathBuf::from("/srv/relay/logs"));
597 }
598
599 #[test]
600 fn rejects_relative_overrides() {
601 let overrides = PathEnvOverrides {
602 data_dir: Some(PathBuf::from("relative-data")),
603 ..PathEnvOverrides::default()
604 };
605
606 let error = RuntimePaths::resolve(&unix_environment(), &overrides)
607 .expect_err("relative override should fail");
608
609 assert_eq!(error.purpose, PathPurpose::Data);
610 assert_eq!(
611 error.kind,
612 PathErrorKind::RelativePath {
613 path: PathBuf::from("relative-data")
614 }
615 );
616 }
617
618 #[test]
619 fn rejects_parent_components() {
620 let overrides = PathEnvOverrides {
621 cache_dir: Some(PathBuf::from("/var/cache/../relay")),
622 ..PathEnvOverrides::default()
623 };
624
625 let error = RuntimePaths::resolve(&unix_environment(), &overrides)
626 .expect_err("parent component should fail");
627
628 assert_eq!(error.purpose, PathPurpose::Cache);
629 assert!(matches!(error.kind, PathErrorKind::ParentComponent { .. }));
630 }
631
632 #[test]
633 fn resolves_unix_service_paths_without_home() {
634 let environment = PlatformEnvironment {
635 platform: PlatformKind::Unix,
636 home_dir: None,
637 xdg_config_home: None,
638 xdg_data_home: None,
639 xdg_state_home: None,
640 xdg_cache_home: None,
641 xdg_runtime_dir: None,
642 app_data: None,
643 local_app_data: None,
644 temp_dir: None,
645 };
646
647 let paths = RuntimePaths::resolve(&environment, &PathEnvOverrides::default())
648 .expect("service defaults should resolve without HOME");
649
650 assert_eq!(paths.config_dir, PathBuf::from("/etc/relay-knowledge"));
651 assert_eq!(paths.data_dir, PathBuf::from("/var/lib/relay-knowledge"));
652 assert_eq!(paths.cache_dir, PathBuf::from("/var/cache/relay-knowledge"));
653 assert_eq!(
654 paths.runtime_dir,
655 PathBuf::from("/var/lib/relay-knowledge/run")
656 );
657 }
658
659 #[test]
660 fn windows_temp_dir_is_scoped_under_application_directory() {
661 let environment = PlatformEnvironment {
662 platform: PlatformKind::Windows,
663 home_dir: None,
664 xdg_config_home: None,
665 xdg_data_home: None,
666 xdg_state_home: None,
667 xdg_cache_home: None,
668 xdg_runtime_dir: None,
669 app_data: Some(PathBuf::from("/roaming")),
670 local_app_data: Some(PathBuf::from("/local")),
671 temp_dir: Some(PathBuf::from("/shared-temp")),
672 };
673
674 let paths = RuntimePaths::resolve(&environment, &PathEnvOverrides::default())
675 .expect("windows paths should resolve");
676
677 assert_eq!(
678 paths.temp_dir,
679 PathBuf::from("/shared-temp/relay-knowledge")
680 );
681 }
682
683 #[test]
684 fn resolves_macos_application_support_paths() {
685 let environment = PlatformEnvironment {
686 platform: PlatformKind::Macos,
687 home_dir: Some(PathBuf::from("/Users/alice")),
688 xdg_config_home: None,
689 xdg_data_home: None,
690 xdg_state_home: None,
691 xdg_cache_home: None,
692 xdg_runtime_dir: None,
693 app_data: None,
694 local_app_data: None,
695 temp_dir: None,
696 };
697
698 let paths =
699 RuntimePaths::resolve(&environment, &PathEnvOverrides::default()).expect("mac paths");
700
701 assert_eq!(
702 paths.config_dir,
703 PathBuf::from("/Users/alice/Library/Application Support/relay-knowledge/config")
704 );
705 assert_eq!(
706 paths.cache_dir,
707 PathBuf::from("/Users/alice/Library/Caches/relay-knowledge")
708 );
709 assert_eq!(
710 paths.service_dir,
711 PathBuf::from("/Users/alice/Library/LaunchAgents")
712 );
713 assert_eq!(paths.temp_dir, PathBuf::from("/tmp/relay-knowledge"));
714 }
715
716 #[test]
717 fn macos_requires_home_directory() {
718 let environment = PlatformEnvironment {
719 platform: PlatformKind::Macos,
720 home_dir: None,
721 xdg_config_home: None,
722 xdg_data_home: None,
723 xdg_state_home: None,
724 xdg_cache_home: None,
725 xdg_runtime_dir: None,
726 app_data: None,
727 local_app_data: None,
728 temp_dir: None,
729 };
730
731 let error = RuntimePaths::resolve(&environment, &PathEnvOverrides::default())
732 .expect_err("missing HOME should fail");
733
734 assert_eq!(error.purpose, PathPurpose::Home);
735 assert_eq!(
736 error.to_string(),
737 "cannot resolve home directory because HOME is unavailable"
738 );
739 }
740
741 #[test]
742 fn windows_falls_back_to_home_appdata_paths() {
743 let environment = PlatformEnvironment {
744 platform: PlatformKind::Windows,
745 home_dir: Some(PathBuf::from("/Users/Alice")),
746 xdg_config_home: None,
747 xdg_data_home: None,
748 xdg_state_home: None,
749 xdg_cache_home: None,
750 xdg_runtime_dir: None,
751 app_data: None,
752 local_app_data: None,
753 temp_dir: None,
754 };
755
756 let paths = RuntimePaths::resolve(&environment, &PathEnvOverrides::default())
757 .expect("windows fallback should resolve");
758
759 assert_eq!(
760 paths.config_dir,
761 PathBuf::from("/Users/Alice/AppData/Roaming/relay-knowledge")
762 );
763 assert_eq!(
764 paths.data_dir,
765 PathBuf::from("/Users/Alice/AppData/Local/relay-knowledge/data")
766 );
767 assert_eq!(
768 paths.temp_dir,
769 PathBuf::from("/Users/Alice/AppData/Local/relay-knowledge/tmp")
770 );
771 }
772
773 #[test]
774 fn per_directory_overrides_replace_defaults() {
775 let overrides = PathEnvOverrides {
776 config_dir: Some(PathBuf::from("/custom/config")),
777 data_dir: Some(PathBuf::from("/custom/data")),
778 state_dir: Some(PathBuf::from("/custom/state")),
779 cache_dir: Some(PathBuf::from("/custom/cache")),
780 log_dir: Some(PathBuf::from("/custom/log")),
781 temp_dir: Some(PathBuf::from("/custom/tmp")),
782 runtime_dir: Some(PathBuf::from("/custom/run")),
783 service_dir: Some(PathBuf::from("/custom/service")),
784 ..PathEnvOverrides::default()
785 };
786
787 let paths = RuntimePaths::resolve(&unix_environment(), &overrides)
788 .expect("overrides should resolve");
789
790 assert_eq!(paths.config_dir, PathBuf::from("/custom/config"));
791 assert_eq!(paths.data_dir, PathBuf::from("/custom/data"));
792 assert_eq!(paths.state_dir, PathBuf::from("/custom/state"));
793 assert_eq!(paths.cache_dir, PathBuf::from("/custom/cache"));
794 assert_eq!(paths.log_dir, PathBuf::from("/custom/log"));
795 assert_eq!(paths.temp_dir, PathBuf::from("/custom/tmp"));
796 assert_eq!(paths.runtime_dir, PathBuf::from("/custom/run"));
797 assert_eq!(paths.service_dir, PathBuf::from("/custom/service"));
798 }
799
800 #[test]
801 fn repository_shard_paths_are_safe_and_stable_under_data_dir() {
802 let overrides = PathEnvOverrides {
803 home: Some(std::env::temp_dir().join(format!(
804 "relay-knowledge-shard-paths-{}",
805 std::process::id()
806 ))),
807 ..PathEnvOverrides::default()
808 };
809 let paths =
810 RuntimePaths::resolve(&unix_environment(), &overrides).expect("paths should resolve");
811
812 let db_path = paths.repository_shard_database_file("git:/srv/repos/core");
813
814 assert!(db_path.starts_with(&paths.data_dir));
815 assert_eq!(
816 db_path.file_name().and_then(|value| value.to_str()),
817 Some(REPOSITORY_SHARD_DATABASE_FILE_NAME)
818 );
819 assert!(
820 db_path
821 .parent()
822 .and_then(|path| path.file_name())
823 .and_then(|value| value.to_str())
824 .is_some_and(|name| name.starts_with("git__srv_repos_core-"))
825 );
826 }
827}