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/// Resolves the Windows process-list executable from typed platform inputs.
146pub fn windows_tasklist_command(system_root: Option<&std::ffi::OsStr>) -> PathBuf {
147    system_root
148        .map(PathBuf::from)
149        .map(|root| root.join("System32").join("tasklist.exe"))
150        .filter(|path| path.exists())
151        .unwrap_or_else(|| PathBuf::from("tasklist.exe"))
152}
153
154/// Returns conservative user document roots for local file indexing.
155pub fn default_user_document_roots(
156    environment: &PlatformEnvironment,
157) -> Result<Vec<PathBuf>, PathError> {
158    let home = match environment.platform {
159        PlatformKind::Windows => environment
160            .home_dir
161            .as_deref()
162            .map(|path| validate_path(PathPurpose::Home, path).map(|_| path.to_path_buf()))
163            .transpose()?,
164        _ => validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?
165            .map(Path::to_path_buf),
166    };
167    let Some(home) = home else {
168        return Ok(Vec::new());
169    };
170
171    Ok(["Documents", "Desktop", "Downloads"]
172        .into_iter()
173        .map(|child| home.join(child))
174        .collect())
175}
176
177/// Directory category attached to path validation failures.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum PathPurpose {
180    Home,
181    Config,
182    Data,
183    State,
184    Cache,
185    Log,
186    Temp,
187    Runtime,
188    Service,
189}
190
191impl fmt::Display for PathPurpose {
192    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            Self::Home => write!(formatter, "home"),
195            Self::Config => write!(formatter, "config"),
196            Self::Data => write!(formatter, "data"),
197            Self::State => write!(formatter, "state"),
198            Self::Cache => write!(formatter, "cache"),
199            Self::Log => write!(formatter, "log"),
200            Self::Temp => write!(formatter, "temp"),
201            Self::Runtime => write!(formatter, "runtime"),
202            Self::Service => write!(formatter, "service"),
203        }
204    }
205}
206
207/// Path resolution or validation error.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct PathError {
210    pub purpose: PathPurpose,
211    pub kind: PathErrorKind,
212}
213
214impl PathError {
215    fn missing_base(purpose: PathPurpose, variable: &'static str) -> Self {
216        Self {
217            purpose,
218            kind: PathErrorKind::MissingBase { variable },
219        }
220    }
221
222    fn relative(purpose: PathPurpose, path: &Path) -> Self {
223        Self {
224            purpose,
225            kind: PathErrorKind::RelativePath {
226                path: path.to_path_buf(),
227            },
228        }
229    }
230
231    fn parent_component(purpose: PathPurpose, path: &Path) -> Self {
232        Self {
233            purpose,
234            kind: PathErrorKind::ParentComponent {
235                path: path.to_path_buf(),
236            },
237        }
238    }
239}
240
241/// Detailed path error category.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum PathErrorKind {
244    MissingBase { variable: &'static str },
245    RelativePath { path: PathBuf },
246    ParentComponent { path: PathBuf },
247}
248
249impl fmt::Display for PathError {
250    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251        match &self.kind {
252            PathErrorKind::MissingBase { variable } => write!(
253                formatter,
254                "cannot resolve {} directory because {variable} is unavailable",
255                self.purpose
256            ),
257            PathErrorKind::RelativePath { path } => write!(
258                formatter,
259                "{} directory must be absolute, got {}",
260                self.purpose,
261                path.display()
262            ),
263            PathErrorKind::ParentComponent { path } => write!(
264                formatter,
265                "{} directory must not contain '..', got {}",
266                self.purpose,
267                path.display()
268            ),
269        }
270    }
271}
272
273impl Error for PathError {}
274
275fn runtime_home_defaults(root: &Path) -> Result<RuntimePaths, PathError> {
276    validate_path(PathPurpose::Home, root)?;
277
278    Ok(RuntimePaths {
279        config_dir: root.join("config"),
280        data_dir: root.join("data"),
281        state_dir: root.join("state"),
282        cache_dir: root.join("cache"),
283        log_dir: root.join("logs"),
284        temp_dir: root.join("tmp"),
285        runtime_dir: root.join("run"),
286        service_dir: root.join("service"),
287    })
288}
289
290fn platform_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
291    match environment.platform {
292        PlatformKind::Macos => macos_defaults(environment),
293        PlatformKind::Windows => windows_defaults(environment),
294        PlatformKind::Unix | PlatformKind::Other => unix_defaults(environment),
295    }
296}
297
298fn unix_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
299    let home = validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?;
300    let config_base = base_or_home_child(
301        PathPurpose::Config,
302        environment.xdg_config_home.as_deref(),
303        home,
304        ".config",
305        PathBuf::from("/etc"),
306    )?;
307    let data_base = base_or_home_child(
308        PathPurpose::Data,
309        environment.xdg_data_home.as_deref(),
310        home,
311        ".local/share",
312        PathBuf::from("/var/lib"),
313    )?;
314    let state_base = base_or_home_child(
315        PathPurpose::State,
316        environment.xdg_state_home.as_deref(),
317        home,
318        ".local/state",
319        PathBuf::from("/var/lib"),
320    )?;
321    let cache_base = base_or_home_child(
322        PathPurpose::Cache,
323        environment.xdg_cache_home.as_deref(),
324        home,
325        ".cache",
326        PathBuf::from("/var/cache"),
327    )?;
328    let temp_base = optional_or_default(
329        PathPurpose::Temp,
330        environment.temp_dir.as_deref(),
331        PathBuf::from("/tmp"),
332    )?;
333    let state_dir = state_base.join(APP_DIR_NAME);
334    let runtime_dir = if let Some(runtime_base) =
335        validated_optional(PathPurpose::Runtime, environment.xdg_runtime_dir.as_deref())?
336    {
337        runtime_base.join(APP_DIR_NAME)
338    } else {
339        state_dir.join("run")
340    };
341
342    Ok(RuntimePaths {
343        config_dir: config_base.join(APP_DIR_NAME),
344        data_dir: data_base.join(APP_DIR_NAME),
345        state_dir: state_dir.clone(),
346        cache_dir: cache_base.join(APP_DIR_NAME),
347        log_dir: state_dir.join("logs"),
348        temp_dir: temp_base.join(APP_DIR_NAME),
349        runtime_dir,
350        service_dir: config_base.join(APP_DIR_NAME).join("service"),
351    })
352}
353
354fn macos_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
355    let home = required_base(
356        PathPurpose::Home,
357        environment.home_dir.as_deref(),
358        HOME_REQUIRED,
359    )?;
360    let application_support = home.join("Library").join("Application Support");
361    let state_dir = application_support.join(APP_DIR_NAME).join("state");
362
363    Ok(RuntimePaths {
364        config_dir: application_support.join(APP_DIR_NAME).join("config"),
365        data_dir: application_support.join(APP_DIR_NAME).join("data"),
366        state_dir: state_dir.clone(),
367        cache_dir: home.join("Library").join("Caches").join(APP_DIR_NAME),
368        log_dir: home.join("Library").join("Logs").join(APP_DIR_NAME),
369        temp_dir: optional_or_default(
370            PathPurpose::Temp,
371            environment.temp_dir.as_deref(),
372            PathBuf::from("/tmp"),
373        )?
374        .join(APP_DIR_NAME),
375        runtime_dir: state_dir.join("run"),
376        service_dir: home.join("Library").join("LaunchAgents"),
377    })
378}
379
380fn windows_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
381    let config_base = environment
382        .app_data
383        .as_deref()
384        .map(|path| validate_path(PathPurpose::Config, path).map(|_| path.to_path_buf()))
385        .transpose()?
386        .or_else(|| {
387            environment
388                .home_dir
389                .as_ref()
390                .map(|home| home.join("AppData/Roaming"))
391        })
392        .ok_or_else(|| PathError::missing_base(PathPurpose::Config, "APPDATA or HOME"))?;
393    let local_base = environment
394        .local_app_data
395        .as_deref()
396        .map(|path| validate_path(PathPurpose::Data, path).map(|_| path.to_path_buf()))
397        .transpose()?
398        .or_else(|| {
399            environment
400                .home_dir
401                .as_ref()
402                .map(|home| home.join("AppData/Local"))
403        })
404        .ok_or_else(|| PathError::missing_base(PathPurpose::Data, "LOCALAPPDATA or HOME"))?;
405    let root = local_base.join(APP_DIR_NAME);
406    let temp_dir = match environment.temp_dir.as_deref() {
407        Some(path) => {
408            validate_path(PathPurpose::Temp, path)?;
409            path.join(APP_DIR_NAME)
410        }
411        None => root.join("tmp"),
412    };
413
414    Ok(RuntimePaths {
415        config_dir: config_base.join(APP_DIR_NAME),
416        data_dir: root.join("data"),
417        state_dir: root.join("state"),
418        cache_dir: root.join("cache"),
419        log_dir: root.join("logs"),
420        temp_dir,
421        runtime_dir: root.join("run"),
422        service_dir: config_base.join(APP_DIR_NAME).join("service"),
423    })
424}
425
426const HOME_REQUIRED: &str = "HOME";
427
428fn base_or_home_child(
429    purpose: PathPurpose,
430    configured: Option<&Path>,
431    home: Option<&Path>,
432    home_child: &str,
433    fallback_base: PathBuf,
434) -> Result<PathBuf, PathError> {
435    if let Some(path) = configured {
436        validate_path(purpose, path)?;
437        return Ok(path.to_path_buf());
438    }
439
440    if let Some(path) = home {
441        validate_path(purpose, path)?;
442        return Ok(path.join(home_child));
443    }
444
445    validate_path(purpose, &fallback_base)?;
446    Ok(fallback_base)
447}
448
449fn required_base(
450    purpose: PathPurpose,
451    value: Option<&Path>,
452    variable: &'static str,
453) -> Result<PathBuf, PathError> {
454    value
455        .map(|path| validate_path(purpose, path).map(|_| path.to_path_buf()))
456        .transpose()?
457        .ok_or_else(|| PathError::missing_base(purpose, variable))
458}
459
460fn validated_optional(
461    purpose: PathPurpose,
462    value: Option<&Path>,
463) -> Result<Option<&Path>, PathError> {
464    if let Some(path) = value {
465        validate_path(purpose, path)?;
466    }
467
468    Ok(value)
469}
470
471fn optional_or_default(
472    purpose: PathPurpose,
473    value: Option<&Path>,
474    default: PathBuf,
475) -> Result<PathBuf, PathError> {
476    match value {
477        Some(path) => {
478            validate_path(purpose, path)?;
479            Ok(path.to_path_buf())
480        }
481        None => {
482            validate_path(purpose, &default)?;
483            Ok(default)
484        }
485    }
486}
487
488fn override_path(
489    purpose: PathPurpose,
490    default: PathBuf,
491    override_value: Option<&Path>,
492) -> Result<PathBuf, PathError> {
493    if let Some(path) = override_value {
494        validate_path(purpose, path)?;
495        Ok(path.to_path_buf())
496    } else {
497        Ok(default)
498    }
499}
500
501fn validate_all(paths: &RuntimePaths) -> Result<(), PathError> {
502    validate_path(PathPurpose::Config, &paths.config_dir)?;
503    validate_path(PathPurpose::Data, &paths.data_dir)?;
504    validate_path(PathPurpose::State, &paths.state_dir)?;
505    validate_path(PathPurpose::Cache, &paths.cache_dir)?;
506    validate_path(PathPurpose::Log, &paths.log_dir)?;
507    validate_path(PathPurpose::Temp, &paths.temp_dir)?;
508    validate_path(PathPurpose::Runtime, &paths.runtime_dir)?;
509    validate_path(PathPurpose::Service, &paths.service_dir)
510}
511
512fn validate_path(purpose: PathPurpose, path: &Path) -> Result<(), PathError> {
513    if !path.is_absolute() {
514        return Err(PathError::relative(purpose, path));
515    }
516
517    if path
518        .components()
519        .any(|component| matches!(component, Component::ParentDir))
520    {
521        return Err(PathError::parent_component(purpose, path));
522    }
523
524    Ok(())
525}
526
527fn repository_shard_dir_name(repository_id: &str) -> String {
528    let mut sanitized = String::with_capacity(repository_id.len().min(48) + 17);
529    for character in repository_id.chars().take(48) {
530        if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
531            sanitized.push(character);
532        } else {
533            sanitized.push('_');
534        }
535    }
536    if sanitized.is_empty() {
537        sanitized.push_str("repository");
538    }
539
540    format!(
541        "{sanitized}-{:016x}",
542        stable_hash64(repository_id.as_bytes())
543    )
544}
545
546fn stable_hash64(bytes: &[u8]) -> u64 {
547    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
548    const FNV_PRIME: u64 = 0x100000001b3;
549
550    let mut hash = FNV_OFFSET_BASIS;
551    for byte in bytes {
552        hash ^= u64::from(*byte);
553        hash = hash.wrapping_mul(FNV_PRIME);
554    }
555
556    hash
557}
558
559#[cfg(test)]
560#[path = "mod_tests.rs"]
561mod tests;