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