Skip to main content

relay_knowledge/paths/
mod.rs

1//! Platform path resolution for relay-knowledge runtime state.
2//!
3//! The module owns all default and override rules for config, data, state,
4//! cache, log, temp, runtime, and service directories. It never reads the
5//! process environment directly; callers pass the typed environment snapshot
6//! produced by `env`.
7
8use 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/// Resolved runtime directories used by CLI, Web, services, and future workers.
26#[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    /// Resolves platform defaults and relay-specific overrides into absolute paths.
40    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    /// Returns the JSONL audit log owned by resident agent protocol adapters.
98    pub fn agent_audit_log_file(&self) -> PathBuf {
99        self.log_dir.join("agent-audit.jsonl")
100    }
101
102    /// Returns the default single-file SQLite database path.
103    pub fn database_file(&self) -> PathBuf {
104        self.data_dir.join(DATABASE_FILE_NAME)
105    }
106
107    /// Returns the directory containing per-repository SQLite shards.
108    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    /// Returns the SQLite database path for one repository shard.
115    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    /// Returns the model provider profile configuration file.
122    pub fn model_profiles_file(&self) -> PathBuf {
123        self.config_dir.join(MODEL_PROFILES_FILE_NAME)
124    }
125
126    /// Returns the model provider fallback-policy configuration file.
127    pub fn model_fallback_file(&self) -> PathBuf {
128        self.config_dir.join(MODEL_FALLBACK_FILE_NAME)
129    }
130
131    /// Returns the cached public model catalog file.
132    pub fn model_catalog_cache_file(&self) -> PathBuf {
133        self.cache_dir.join(MODEL_CATALOG_CACHE_FILE_NAME)
134    }
135
136    /// Returns the cached version-check result.
137    pub fn version_check_cache_file(&self) -> PathBuf {
138        self.cache_dir.join(VERSION_CHECK_CACHE_FILE_NAME)
139    }
140}
141
142/// Returns conservative user document roots for local file indexing.
143pub 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/// Directory category attached to path validation failures.
166#[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/// Path resolution or validation error.
196#[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/// Detailed path error category.
230#[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}