1use 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#[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 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 pub fn agent_audit_log_file(&self) -> PathBuf {
103 self.log_dir.join("agent-audit.jsonl")
104 }
105
106 pub fn database_file(&self) -> PathBuf {
108 self.data_dir.join(DATABASE_FILE_NAME)
109 }
110
111 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 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 pub fn model_profiles_file(&self) -> PathBuf {
127 self.config_dir.join(MODEL_PROFILES_FILE_NAME)
128 }
129
130 pub fn model_fallback_file(&self) -> PathBuf {
132 self.config_dir.join(MODEL_FALLBACK_FILE_NAME)
133 }
134
135 pub fn model_catalog_cache_file(&self) -> PathBuf {
137 self.cache_dir.join(MODEL_CATALOG_CACHE_FILE_NAME)
138 }
139
140 pub fn version_check_cache_file(&self) -> PathBuf {
142 self.cache_dir.join(VERSION_CHECK_CACHE_FILE_NAME)
143 }
144}
145
146pub 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
155pub 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#[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#[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#[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;