Skip to main content

sbox/config/
load.rs

1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use crate::error::SboxError;
5
6use super::model::Config;
7use super::validate::{emit_config_warnings, validate_config};
8
9#[derive(Debug, Clone)]
10pub struct LoadOptions {
11    pub workspace: Option<PathBuf>,
12    pub config: Option<PathBuf>,
13}
14
15#[derive(Debug, Clone)]
16pub struct LoadedConfig {
17    pub invocation_dir: PathBuf,
18    pub workspace_root: PathBuf,
19    pub config_path: PathBuf,
20    pub config: Config,
21}
22
23pub fn load_config(options: &LoadOptions) -> Result<LoadedConfig, SboxError> {
24    let invocation_dir =
25        std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
26
27    let config_path = match options.config.as_deref() {
28        Some(path) => absolutize_from(path, &invocation_dir),
29        None => {
30            let search_root = absolutize_path(options.workspace.as_deref(), &invocation_dir)
31                .unwrap_or_else(|| invocation_dir.clone());
32            find_default_config_path(&search_root)?
33        }
34    };
35
36    if !config_path.exists() {
37        return Err(SboxError::ConfigNotFound(config_path));
38    }
39
40    let raw = fs::read_to_string(&config_path).map_err(|source| SboxError::ConfigRead {
41        path: config_path.clone(),
42        source,
43    })?;
44
45    let mut config =
46        serde_yaml::from_str::<Config>(&raw).map_err(|source| SboxError::ConfigParse {
47            path: config_path.clone(),
48            source,
49        })?;
50
51    super::package_manager::elaborate(&mut config)?;
52    validate_config(&config)?;
53    emit_config_warnings(&config);
54
55    let config_dir = config_path
56        .parent()
57        .map(Path::to_path_buf)
58        .unwrap_or_else(|| invocation_dir.clone());
59
60    let workspace_root = match options.workspace.as_deref() {
61        Some(path) => absolutize_from(path, &invocation_dir),
62        None => {
63            let configured_root = config
64                .workspace
65                .as_ref()
66                .and_then(|workspace| workspace.root.as_deref());
67
68            absolutize_path(configured_root, &config_dir).unwrap_or(config_dir)
69        }
70    };
71
72    Ok(LoadedConfig {
73        invocation_dir,
74        workspace_root,
75        config_path,
76        config,
77    })
78}
79
80fn absolutize_path(path: Option<&Path>, base: &Path) -> Option<PathBuf> {
81    path.map(|path| absolutize_from(path, base))
82}
83
84fn absolutize_from(path: &Path, base: &Path) -> PathBuf {
85    let combined = if path.is_absolute() {
86        path.to_path_buf()
87    } else {
88        base.join(path)
89    };
90
91    normalize_path(&combined)
92}
93
94fn find_default_config_path(start: &Path) -> Result<PathBuf, SboxError> {
95    for directory in start.ancestors() {
96        let candidate = directory.join("sbox.yaml");
97        if candidate.exists() {
98            return Ok(candidate);
99        }
100    }
101
102    // Global fallback: ~/.config/sbox/sbox.yaml
103    // Allows users to set a personal "always sandbox" policy for projects that haven't opted in.
104    if let Some(home) = std::env::var_os("HOME") {
105        let global = PathBuf::from(home).join(".config/sbox/sbox.yaml");
106        if global.exists() {
107            return Ok(global);
108        }
109    }
110
111    Err(SboxError::ConfigNotFound(start.join("sbox.yaml")))
112}
113
114fn normalize_path(path: &Path) -> PathBuf {
115    let mut normalized = PathBuf::new();
116
117    for component in path.components() {
118        match component {
119            Component::CurDir => {}
120            Component::ParentDir => {
121                normalized.pop();
122            }
123            other => normalized.push(other.as_os_str()),
124        }
125    }
126
127    if normalized.as_os_str().is_empty() {
128        PathBuf::from(".")
129    } else {
130        normalized
131    }
132}