relay_knowledge/application/runtime/
file_index.rs1use std::{
2 error::Error,
3 fmt,
4 path::{Component, PathBuf},
5 time::Duration,
6};
7
8use crate::{
9 env::{EnvironmentConfig, PlatformKind},
10 identity::stable_hash64,
11 paths::{PathError, default_user_document_roots},
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct FileIndexRuntimeConfig {
17 pub enabled: bool,
18 pub roots: Vec<FileIndexRootConfig>,
19 pub excludes: Vec<String>,
20 pub max_depth: usize,
21 pub max_file_bytes: u64,
22 pub scan_interval: Duration,
23 pub scan_timeout: Duration,
24 pub max_files_per_root: usize,
25 pub query_timeout: Duration,
26}
27
28impl FileIndexRuntimeConfig {
29 pub const DEFAULT_MAX_DEPTH: usize = 32;
30 pub const DEFAULT_MAX_FILE_BYTES: u64 = 512 * 1024 * 1024;
31 pub const DEFAULT_SCAN_INTERVAL: Duration = Duration::from_secs(900);
32 pub const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_secs(300);
33 pub const DEFAULT_MAX_FILES_PER_ROOT: usize = 50_000;
34 pub const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_millis(750);
35
36 pub fn from_environment(
37 environment: &EnvironmentConfig,
38 ) -> Result<Self, FileIndexRuntimeConfigError> {
39 let mut roots = default_user_document_roots(&environment.platform)
40 .map_err(FileIndexRuntimeConfigError::Paths)?
41 .into_iter()
42 .map(|path| FileIndexRootConfig::new("user-documents", path))
43 .collect::<Vec<_>>();
44 for root in split_semicolon(environment.file_index.roots.as_deref())? {
45 roots.push(file_index_root_from_environment(
46 "local-files",
47 root,
48 environment.platform.platform,
49 )?);
50 }
51 roots.sort_by(|left, right| {
52 left.scope_id
53 .cmp(&right.scope_id)
54 .then(left.root_id.cmp(&right.root_id))
55 });
56 roots.dedup_by(|left, right| {
57 left.scope_id == right.scope_id && left.root_id == right.root_id
58 });
59
60 Ok(Self {
61 enabled: environment.file_index.enabled.unwrap_or(false),
62 roots,
63 excludes: split_semicolon(environment.file_index.excludes.as_deref())?,
64 max_depth: environment
65 .file_index
66 .max_depth
67 .unwrap_or(Self::DEFAULT_MAX_DEPTH),
68 max_file_bytes: environment
69 .file_index
70 .max_file_bytes
71 .unwrap_or(Self::DEFAULT_MAX_FILE_BYTES),
72 scan_interval: Duration::from_millis(
73 environment
74 .file_index
75 .scan_interval_ms
76 .unwrap_or(duration_millis(Self::DEFAULT_SCAN_INTERVAL)),
77 ),
78 scan_timeout: Duration::from_millis(
79 environment
80 .file_index
81 .scan_timeout_ms
82 .unwrap_or(duration_millis(Self::DEFAULT_SCAN_TIMEOUT)),
83 ),
84 max_files_per_root: environment
85 .file_index
86 .max_files_per_root
87 .unwrap_or(Self::DEFAULT_MAX_FILES_PER_ROOT),
88 query_timeout: Duration::from_millis(
89 environment
90 .file_index
91 .query_timeout_ms
92 .unwrap_or(duration_millis(Self::DEFAULT_QUERY_TIMEOUT)),
93 ),
94 })
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct FileIndexRootConfig {
101 pub scope_id: String,
102 pub root_id: String,
103 pub root_path: PathBuf,
104}
105
106impl FileIndexRootConfig {
107 pub fn new(scope_id: impl Into<String>, root_path: PathBuf) -> Self {
108 let root_path = normalize_file_index_root_path(root_path);
109 let root_id = format!(
110 "root-{:016x}",
111 stable_hash64(root_path.to_string_lossy().as_bytes())
112 );
113
114 Self {
115 scope_id: scope_id.into(),
116 root_id,
117 root_path,
118 }
119 }
120}
121
122fn normalize_file_index_root_path(root_path: PathBuf) -> PathBuf {
123 if let Ok(canonical) = std::fs::canonicalize(&root_path) {
124 return canonical;
125 }
126
127 let mut normalized = PathBuf::new();
128 for component in root_path.components() {
129 match component {
130 Component::CurDir => {}
131 Component::ParentDir => normalized.push(".."),
132 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
133 Component::RootDir => normalized.push(component.as_os_str()),
134 Component::Normal(value) => normalized.push(value),
135 }
136 }
137
138 if normalized.as_os_str().is_empty() {
139 root_path
140 } else {
141 normalized
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum FileIndexRuntimeConfigError {
148 EmptyListValue,
149 RelativeRoot(String),
150 Paths(PathError),
151}
152
153impl fmt::Display for FileIndexRuntimeConfigError {
154 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 Self::EmptyListValue => {
157 write!(formatter, "file index lists must not contain empty values")
158 }
159 Self::RelativeRoot(path) => {
160 write!(
161 formatter,
162 "file index root '{path}' must be an absolute path"
163 )
164 }
165 Self::Paths(error) => write!(formatter, "{error}"),
166 }
167 }
168}
169
170impl Error for FileIndexRuntimeConfigError {}
171
172fn split_semicolon(value: Option<&str>) -> Result<Vec<String>, FileIndexRuntimeConfigError> {
173 value
174 .map(|items| {
175 items
176 .split(';')
177 .map(str::trim)
178 .map(|item| {
179 if item.is_empty() {
180 Err(FileIndexRuntimeConfigError::EmptyListValue)
181 } else {
182 Ok(item.to_owned())
183 }
184 })
185 .collect()
186 })
187 .unwrap_or_else(|| Ok(Vec::new()))
188}
189
190fn file_index_root_from_environment(
191 scope_id: &'static str,
192 root: String,
193 platform: PlatformKind,
194) -> Result<FileIndexRootConfig, FileIndexRuntimeConfigError> {
195 if !is_absolute_file_index_root(&root, platform) {
196 return Err(FileIndexRuntimeConfigError::RelativeRoot(root));
197 }
198
199 Ok(FileIndexRootConfig::new(scope_id, PathBuf::from(root)))
200}
201
202fn is_absolute_file_index_root(root: &str, platform: PlatformKind) -> bool {
203 match platform {
204 PlatformKind::Windows => is_absolute_windows_path(root),
205 _ => PathBuf::from(root).is_absolute(),
206 }
207}
208
209fn is_absolute_windows_path(root: &str) -> bool {
210 let bytes = root.as_bytes();
211 let drive_rooted = bytes.len() >= 3
212 && bytes[0].is_ascii_alphabetic()
213 && bytes[1] == b':'
214 && matches!(bytes[2], b'\\' | b'/');
215 if drive_rooted {
216 return true;
217 }
218
219 if !(root.starts_with("\\\\") || root.starts_with("//")) {
220 return false;
221 }
222 root[2..]
223 .split(['\\', '/'])
224 .filter(|component| !component.is_empty())
225 .take(2)
226 .count()
227 == 2
228}
229
230fn duration_millis(duration: Duration) -> u64 {
231 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
232}
233
234#[cfg(test)]
235#[path = "file_index_tests.rs"]
236mod tests;