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    identity::stable_hash64,
17    project::{
18        DATABASE_FILE_NAME, MODEL_CATALOG_CACHE_FILE_NAME, MODEL_FALLBACK_FILE_NAME,
19        MODEL_PROFILES_FILE_NAME, REPOSITORY_SHARD_DATABASE_FILE_NAME, REPOSITORY_SHARDS_DIR_NAME,
20        STORAGE_BACKENDS_DIR_NAME, VERSION_CHECK_CACHE_FILE_NAME,
21    },
22};
23
24mod repository_root;
25
26pub use crate::project::APP_DIR_NAME;
27pub use repository_root::{RepositoryRootDiscoveryError, discover_repository_root};
28
29/// Resolved runtime directories used by CLI, Web, services, and future workers.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct RuntimePaths {
32    pub config_dir: PathBuf,
33    pub data_dir: PathBuf,
34    pub state_dir: PathBuf,
35    pub cache_dir: PathBuf,
36    pub log_dir: PathBuf,
37    pub temp_dir: PathBuf,
38    pub runtime_dir: PathBuf,
39    pub service_dir: PathBuf,
40}
41
42impl RuntimePaths {
43    /// Resolves platform defaults and relay-specific overrides into absolute paths.
44    pub fn resolve(
45        environment: &PlatformEnvironment,
46        overrides: &PathEnvOverrides,
47    ) -> Result<Self, PathError> {
48        let defaults = if let Some(root) = overrides.home.as_deref() {
49            runtime_home_defaults(root)?
50        } else {
51            platform_defaults(environment)?
52        };
53
54        let resolved = Self {
55            config_dir: override_path(
56                PathPurpose::Config,
57                defaults.config_dir,
58                overrides.config_dir.as_deref(),
59            )?,
60            data_dir: override_path(
61                PathPurpose::Data,
62                defaults.data_dir,
63                overrides.data_dir.as_deref(),
64            )?,
65            state_dir: override_path(
66                PathPurpose::State,
67                defaults.state_dir,
68                overrides.state_dir.as_deref(),
69            )?,
70            cache_dir: override_path(
71                PathPurpose::Cache,
72                defaults.cache_dir,
73                overrides.cache_dir.as_deref(),
74            )?,
75            log_dir: override_path(
76                PathPurpose::Log,
77                defaults.log_dir,
78                overrides.log_dir.as_deref(),
79            )?,
80            temp_dir: override_path(
81                PathPurpose::Temp,
82                defaults.temp_dir,
83                overrides.temp_dir.as_deref(),
84            )?,
85            runtime_dir: override_path(
86                PathPurpose::Runtime,
87                defaults.runtime_dir,
88                overrides.runtime_dir.as_deref(),
89            )?,
90            service_dir: override_path(
91                PathPurpose::Service,
92                defaults.service_dir,
93                overrides.service_dir.as_deref(),
94            )?,
95        };
96
97        validate_all(&resolved)?;
98        Ok(resolved)
99    }
100
101    /// Returns the JSONL audit log owned by resident agent protocol adapters.
102    pub fn agent_audit_log_file(&self) -> PathBuf {
103        self.log_dir.join("agent-audit.jsonl")
104    }
105
106    /// Returns the default single-file SQLite database path.
107    pub fn database_file(&self) -> PathBuf {
108        self.data_dir.join(DATABASE_FILE_NAME)
109    }
110
111    /// Returns the directory containing per-repository SQLite shards.
112    pub fn repository_shards_dir(&self) -> PathBuf {
113        self.data_dir
114            .join(STORAGE_BACKENDS_DIR_NAME)
115            .join(REPOSITORY_SHARDS_DIR_NAME)
116    }
117
118    /// Returns the SQLite database path for one repository shard.
119    pub fn repository_shard_database_file(&self, repository_id: &str) -> PathBuf {
120        self.repository_shards_dir()
121            .join(repository_shard_dir_name(repository_id))
122            .join(REPOSITORY_SHARD_DATABASE_FILE_NAME)
123    }
124
125    /// Returns the model provider profile configuration file.
126    pub fn model_profiles_file(&self) -> PathBuf {
127        self.config_dir.join(MODEL_PROFILES_FILE_NAME)
128    }
129
130    /// Returns the model provider fallback-policy configuration file.
131    pub fn model_fallback_file(&self) -> PathBuf {
132        self.config_dir.join(MODEL_FALLBACK_FILE_NAME)
133    }
134
135    /// Returns the cached public model catalog file.
136    pub fn model_catalog_cache_file(&self) -> PathBuf {
137        self.cache_dir.join(MODEL_CATALOG_CACHE_FILE_NAME)
138    }
139
140    /// Returns the cached version-check result.
141    pub fn version_check_cache_file(&self) -> PathBuf {
142        self.cache_dir.join(VERSION_CHECK_CACHE_FILE_NAME)
143    }
144}
145
146/// Resolves the Windows process-list executable from typed platform inputs.
147pub fn windows_tasklist_command(system_root: Option<&std::ffi::OsStr>) -> PathBuf {
148    system_root
149        .map(PathBuf::from)
150        .map(|root| root.join("System32").join("tasklist.exe"))
151        .filter(|path| path.exists())
152        .unwrap_or_else(|| PathBuf::from("tasklist.exe"))
153}
154
155/// Returns conservative user document roots for local file indexing.
156pub fn default_user_document_roots(
157    environment: &PlatformEnvironment,
158) -> Result<Vec<PathBuf>, PathError> {
159    let home = match environment.platform {
160        PlatformKind::Windows => environment
161            .home_dir
162            .as_deref()
163            .map(|path| validate_path(PathPurpose::Home, path).map(|_| path.to_path_buf()))
164            .transpose()?,
165        _ => validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?
166            .map(Path::to_path_buf),
167    };
168    let Some(home) = home else {
169        return Ok(Vec::new());
170    };
171
172    Ok(["Documents", "Desktop", "Downloads"]
173        .into_iter()
174        .map(|child| home.join(child))
175        .collect())
176}
177
178/// Directory category attached to path validation failures.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum PathPurpose {
181    Home,
182    Config,
183    Data,
184    State,
185    Cache,
186    Log,
187    Temp,
188    Runtime,
189    Service,
190}
191
192impl fmt::Display for PathPurpose {
193    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            Self::Home => write!(formatter, "home"),
196            Self::Config => write!(formatter, "config"),
197            Self::Data => write!(formatter, "data"),
198            Self::State => write!(formatter, "state"),
199            Self::Cache => write!(formatter, "cache"),
200            Self::Log => write!(formatter, "log"),
201            Self::Temp => write!(formatter, "temp"),
202            Self::Runtime => write!(formatter, "runtime"),
203            Self::Service => write!(formatter, "service"),
204        }
205    }
206}
207
208/// Path resolution or validation error.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct PathError {
211    pub purpose: PathPurpose,
212    pub kind: PathErrorKind,
213}
214
215impl PathError {
216    fn missing_base(purpose: PathPurpose, variable: &'static str) -> Self {
217        Self {
218            purpose,
219            kind: PathErrorKind::MissingBase { variable },
220        }
221    }
222
223    fn relative(purpose: PathPurpose, path: &Path) -> Self {
224        Self {
225            purpose,
226            kind: PathErrorKind::RelativePath {
227                path: path.to_path_buf(),
228            },
229        }
230    }
231
232    fn parent_component(purpose: PathPurpose, path: &Path) -> Self {
233        Self {
234            purpose,
235            kind: PathErrorKind::ParentComponent {
236                path: path.to_path_buf(),
237            },
238        }
239    }
240}
241
242/// Detailed path error category.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum PathErrorKind {
245    MissingBase { variable: &'static str },
246    RelativePath { path: PathBuf },
247    ParentComponent { path: PathBuf },
248}
249
250impl fmt::Display for PathError {
251    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
252        match &self.kind {
253            PathErrorKind::MissingBase { variable } => write!(
254                formatter,
255                "cannot resolve {} directory because {variable} is unavailable",
256                self.purpose
257            ),
258            PathErrorKind::RelativePath { path } => write!(
259                formatter,
260                "{} directory must be absolute, got {}",
261                self.purpose,
262                path.display()
263            ),
264            PathErrorKind::ParentComponent { path } => write!(
265                formatter,
266                "{} directory must not contain '..', got {}",
267                self.purpose,
268                path.display()
269            ),
270        }
271    }
272}
273
274impl Error for PathError {}
275
276fn runtime_home_defaults(root: &Path) -> Result<RuntimePaths, PathError> {
277    validate_path(PathPurpose::Home, root)?;
278
279    Ok(RuntimePaths {
280        config_dir: root.join("config"),
281        data_dir: root.join("data"),
282        state_dir: root.join("state"),
283        cache_dir: root.join("cache"),
284        log_dir: root.join("logs"),
285        temp_dir: root.join("tmp"),
286        runtime_dir: root.join("run"),
287        service_dir: root.join("service"),
288    })
289}
290
291fn platform_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
292    match environment.platform {
293        PlatformKind::Macos => macos_defaults(environment),
294        PlatformKind::Windows => windows_defaults(environment),
295        PlatformKind::Unix | PlatformKind::Other => unix_defaults(environment),
296    }
297}
298
299fn unix_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
300    let home = validated_optional(PathPurpose::Home, environment.home_dir.as_deref())?;
301    let config_base = base_or_home_child(
302        PathPurpose::Config,
303        environment.xdg_config_home.as_deref(),
304        home,
305        ".config",
306        PathBuf::from("/etc"),
307    )?;
308    let data_base = base_or_home_child(
309        PathPurpose::Data,
310        environment.xdg_data_home.as_deref(),
311        home,
312        ".local/share",
313        PathBuf::from("/var/lib"),
314    )?;
315    let state_base = base_or_home_child(
316        PathPurpose::State,
317        environment.xdg_state_home.as_deref(),
318        home,
319        ".local/state",
320        PathBuf::from("/var/lib"),
321    )?;
322    let cache_base = base_or_home_child(
323        PathPurpose::Cache,
324        environment.xdg_cache_home.as_deref(),
325        home,
326        ".cache",
327        PathBuf::from("/var/cache"),
328    )?;
329    let temp_base = optional_or_default(
330        PathPurpose::Temp,
331        environment.temp_dir.as_deref(),
332        PathBuf::from("/tmp"),
333    )?;
334    let state_dir = state_base.join(APP_DIR_NAME);
335    let runtime_dir = if let Some(runtime_base) =
336        validated_optional(PathPurpose::Runtime, environment.xdg_runtime_dir.as_deref())?
337    {
338        runtime_base.join(APP_DIR_NAME)
339    } else {
340        state_dir.join("run")
341    };
342
343    Ok(RuntimePaths {
344        config_dir: config_base.join(APP_DIR_NAME),
345        data_dir: data_base.join(APP_DIR_NAME),
346        state_dir: state_dir.clone(),
347        cache_dir: cache_base.join(APP_DIR_NAME),
348        log_dir: state_dir.join("logs"),
349        temp_dir: temp_base.join(APP_DIR_NAME),
350        runtime_dir,
351        service_dir: config_base.join(APP_DIR_NAME).join("service"),
352    })
353}
354
355fn macos_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
356    let home = required_base(
357        PathPurpose::Home,
358        environment.home_dir.as_deref(),
359        HOME_REQUIRED,
360    )?;
361    let application_support = home.join("Library").join("Application Support");
362    let state_dir = application_support.join(APP_DIR_NAME).join("state");
363
364    Ok(RuntimePaths {
365        config_dir: application_support.join(APP_DIR_NAME).join("config"),
366        data_dir: application_support.join(APP_DIR_NAME).join("data"),
367        state_dir: state_dir.clone(),
368        cache_dir: home.join("Library").join("Caches").join(APP_DIR_NAME),
369        log_dir: home.join("Library").join("Logs").join(APP_DIR_NAME),
370        temp_dir: optional_or_default(
371            PathPurpose::Temp,
372            environment.temp_dir.as_deref(),
373            PathBuf::from("/tmp"),
374        )?
375        .join(APP_DIR_NAME),
376        runtime_dir: state_dir.join("run"),
377        service_dir: home.join("Library").join("LaunchAgents"),
378    })
379}
380
381fn windows_defaults(environment: &PlatformEnvironment) -> Result<RuntimePaths, PathError> {
382    let config_base = environment
383        .app_data
384        .as_deref()
385        .map(|path| validate_path(PathPurpose::Config, path).map(|_| path.to_path_buf()))
386        .transpose()?
387        .or_else(|| {
388            environment
389                .home_dir
390                .as_ref()
391                .map(|home| home.join("AppData/Roaming"))
392        })
393        .ok_or_else(|| PathError::missing_base(PathPurpose::Config, "APPDATA or HOME"))?;
394    let local_base = environment
395        .local_app_data
396        .as_deref()
397        .map(|path| validate_path(PathPurpose::Data, path).map(|_| path.to_path_buf()))
398        .transpose()?
399        .or_else(|| {
400            environment
401                .home_dir
402                .as_ref()
403                .map(|home| home.join("AppData/Local"))
404        })
405        .ok_or_else(|| PathError::missing_base(PathPurpose::Data, "LOCALAPPDATA or HOME"))?;
406    let root = local_base.join(APP_DIR_NAME);
407    let temp_dir = match environment.temp_dir.as_deref() {
408        Some(path) => {
409            validate_path(PathPurpose::Temp, path)?;
410            path.join(APP_DIR_NAME)
411        }
412        None => root.join("tmp"),
413    };
414
415    Ok(RuntimePaths {
416        config_dir: config_base.join(APP_DIR_NAME),
417        data_dir: root.join("data"),
418        state_dir: root.join("state"),
419        cache_dir: root.join("cache"),
420        log_dir: root.join("logs"),
421        temp_dir,
422        runtime_dir: root.join("run"),
423        service_dir: config_base.join(APP_DIR_NAME).join("service"),
424    })
425}
426
427const HOME_REQUIRED: &str = "HOME";
428
429fn base_or_home_child(
430    purpose: PathPurpose,
431    configured: Option<&Path>,
432    home: Option<&Path>,
433    home_child: &str,
434    fallback_base: PathBuf,
435) -> Result<PathBuf, PathError> {
436    if let Some(path) = configured {
437        validate_path(purpose, path)?;
438        return Ok(path.to_path_buf());
439    }
440
441    if let Some(path) = home {
442        validate_path(purpose, path)?;
443        return Ok(path.join(home_child));
444    }
445
446    validate_path(purpose, &fallback_base)?;
447    Ok(fallback_base)
448}
449
450fn required_base(
451    purpose: PathPurpose,
452    value: Option<&Path>,
453    variable: &'static str,
454) -> Result<PathBuf, PathError> {
455    value
456        .map(|path| validate_path(purpose, path).map(|_| path.to_path_buf()))
457        .transpose()?
458        .ok_or_else(|| PathError::missing_base(purpose, variable))
459}
460
461fn validated_optional(
462    purpose: PathPurpose,
463    value: Option<&Path>,
464) -> Result<Option<&Path>, PathError> {
465    if let Some(path) = value {
466        validate_path(purpose, path)?;
467    }
468
469    Ok(value)
470}
471
472fn optional_or_default(
473    purpose: PathPurpose,
474    value: Option<&Path>,
475    default: PathBuf,
476) -> Result<PathBuf, PathError> {
477    match value {
478        Some(path) => {
479            validate_path(purpose, path)?;
480            Ok(path.to_path_buf())
481        }
482        None => {
483            validate_path(purpose, &default)?;
484            Ok(default)
485        }
486    }
487}
488
489fn override_path(
490    purpose: PathPurpose,
491    default: PathBuf,
492    override_value: Option<&Path>,
493) -> Result<PathBuf, PathError> {
494    if let Some(path) = override_value {
495        validate_path(purpose, path)?;
496        Ok(path.to_path_buf())
497    } else {
498        Ok(default)
499    }
500}
501
502fn validate_all(paths: &RuntimePaths) -> Result<(), PathError> {
503    validate_path(PathPurpose::Config, &paths.config_dir)?;
504    validate_path(PathPurpose::Data, &paths.data_dir)?;
505    validate_path(PathPurpose::State, &paths.state_dir)?;
506    validate_path(PathPurpose::Cache, &paths.cache_dir)?;
507    validate_path(PathPurpose::Log, &paths.log_dir)?;
508    validate_path(PathPurpose::Temp, &paths.temp_dir)?;
509    validate_path(PathPurpose::Runtime, &paths.runtime_dir)?;
510    validate_path(PathPurpose::Service, &paths.service_dir)
511}
512
513fn validate_path(purpose: PathPurpose, path: &Path) -> Result<(), PathError> {
514    if !path.is_absolute() {
515        return Err(PathError::relative(purpose, path));
516    }
517
518    if path
519        .components()
520        .any(|component| matches!(component, Component::ParentDir))
521    {
522        return Err(PathError::parent_component(purpose, path));
523    }
524
525    Ok(())
526}
527
528fn repository_shard_dir_name(repository_id: &str) -> String {
529    let mut sanitized = String::with_capacity(repository_id.len().min(48) + 17);
530    for character in repository_id.chars().take(48) {
531        if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
532            sanitized.push(character);
533        } else {
534            sanitized.push('_');
535        }
536    }
537    if sanitized.is_empty() {
538        sanitized.push_str("repository");
539    }
540
541    format!(
542        "{sanitized}-{:016x}",
543        stable_hash64(repository_id.as_bytes())
544    )
545}
546
547#[cfg(test)]
548#[path = "mod_tests.rs"]
549mod tests;