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