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
23mod repository_root;
24
25pub use crate::project::APP_DIR_NAME;
26pub use repository_root::{RepositoryRootDiscoveryError, discover_repository_root};
27
28/// Resolved runtime directories used by CLI, Web, services, and future workers.
29#[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    /// Resolves platform defaults and relay-specific overrides into absolute paths.
43    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    /// Returns the JSONL audit log owned by resident agent protocol adapters.
101    pub fn agent_audit_log_file(&self) -> PathBuf {
102        self.log_dir.join("agent-audit.jsonl")
103    }
104
105    /// Returns the default single-file SQLite database path.
106    pub fn database_file(&self) -> PathBuf {
107        self.data_dir.join(DATABASE_FILE_NAME)
108    }
109
110    /// Returns the directory containing per-repository SQLite shards.
111    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    /// Returns the SQLite database path for one repository shard.
118    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    /// Returns the model provider profile configuration file.
125    pub fn model_profiles_file(&self) -> PathBuf {
126        self.config_dir.join(MODEL_PROFILES_FILE_NAME)
127    }
128
129    /// Returns the model provider fallback-policy configuration file.
130    pub fn model_fallback_file(&self) -> PathBuf {
131        self.config_dir.join(MODEL_FALLBACK_FILE_NAME)
132    }
133
134    /// Returns the cached public model catalog file.
135    pub fn model_catalog_cache_file(&self) -> PathBuf {
136        self.cache_dir.join(MODEL_CATALOG_CACHE_FILE_NAME)
137    }
138
139    /// Returns the cached version-check result.
140    pub fn version_check_cache_file(&self) -> PathBuf {
141        self.cache_dir.join(VERSION_CHECK_CACHE_FILE_NAME)
142    }
143}
144
145/// Returns conservative user document roots for local file indexing.
146pub 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/// Directory category attached to path validation failures.
169#[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/// Path resolution or validation error.
199#[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/// Detailed path error category.
233#[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}