Skip to main content

systemprompt_files/config/
mod.rs

1//! Profile-driven configuration for the files crate.
2
3mod types;
4mod validator;
5
6pub use types::{AllowedFileTypes, FilePersistenceMode, FileUploadConfig, FilesConfigYaml};
7pub use validator::FilesConfigValidator;
8
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11use systemprompt_cloud::constants::storage;
12use systemprompt_config::ProfileBootstrap;
13use systemprompt_models::AppPaths;
14
15use crate::error::{FilesError, FilesResult};
16use types::FilesConfigWrapper;
17
18static FILES_CONFIG: OnceLock<FilesConfig> = OnceLock::new();
19
20#[derive(Debug, Clone)]
21pub struct FilesConfig {
22    storage_root: PathBuf,
23    url_prefix: String,
24    upload: FileUploadConfig,
25}
26
27impl FilesConfig {
28    pub fn init(paths: &AppPaths) -> FilesResult<()> {
29        if FILES_CONFIG.get().is_some() {
30            return Ok(());
31        }
32        let config = Self::from_profile(paths)?;
33        config.validate()?;
34        if FILES_CONFIG.set(config).is_err() {
35            tracing::warn!("FilesConfig was already initialized by a concurrent caller");
36        }
37        Ok(())
38    }
39
40    pub fn get() -> FilesResult<&'static Self> {
41        FILES_CONFIG
42            .get()
43            .ok_or_else(|| FilesError::Config("FilesConfig::init() not called".into()))
44    }
45
46    pub fn get_optional() -> Option<&'static Self> {
47        FILES_CONFIG.get()
48    }
49
50    pub fn from_profile(paths: &AppPaths) -> FilesResult<Self> {
51        let profile = ProfileBootstrap::get()
52            .map_err(|e| FilesError::Config(format!("Profile not initialized: {e}")))?;
53
54        let storage_root = profile
55            .paths
56            .storage
57            .as_ref()
58            .ok_or_else(|| FilesError::Config("paths.storage not configured in profile".into()))?
59            .clone();
60
61        let yaml_config = Self::load_yaml_config(paths)?;
62
63        Ok(Self {
64            storage_root: PathBuf::from(storage_root),
65            url_prefix: yaml_config.url_prefix,
66            upload: yaml_config.upload,
67        })
68    }
69
70    pub(crate) fn load_yaml_config(paths: &AppPaths) -> FilesResult<FilesConfigYaml> {
71        let config_path = paths.system().services().join("config/files.yaml");
72
73        if !config_path.exists() {
74            return Ok(FilesConfigYaml::default());
75        }
76
77        let content = std::fs::read_to_string(&config_path).map_err(|e| {
78            FilesError::Config(format!(
79                "Failed to read files.yaml ({}): {e}",
80                config_path.display()
81            ))
82        })?;
83
84        let wrapper: FilesConfigWrapper = serde_yaml::from_str(&content).map_err(|e| {
85            FilesError::Config(format!(
86                "Failed to parse files.yaml ({}): {e}",
87                config_path.display()
88            ))
89        })?;
90
91        Ok(wrapper.files)
92    }
93
94    pub const fn upload(&self) -> &FileUploadConfig {
95        &self.upload
96    }
97
98    pub fn validate(&self) -> FilesResult<()> {
99        if !self.storage_root.is_absolute() {
100            return Err(FilesError::Config(format!(
101                "paths.storage must be absolute, got: {}",
102                self.storage_root.display()
103            )));
104        }
105        Ok(())
106    }
107
108    pub fn ensure_storage_structure(&self) -> Vec<String> {
109        let mut errors = Vec::new();
110
111        if !self.storage_root.exists() {
112            if let Err(e) = std::fs::create_dir_all(&self.storage_root) {
113                errors.push(format!(
114                    "Failed to create storage root {}: {}",
115                    self.storage_root.display(),
116                    e
117                ));
118                return errors;
119            }
120        }
121
122        for dir in [self.files(), self.images()] {
123            if !dir.exists() {
124                if let Err(e) = std::fs::create_dir_all(&dir) {
125                    errors.push(format!("Failed to create {}: {}", dir.display(), e));
126                }
127            }
128        }
129
130        errors
131    }
132
133    pub fn storage(&self) -> &Path {
134        &self.storage_root
135    }
136
137    pub fn generated_images(&self) -> PathBuf {
138        self.storage_root.join(storage::GENERATED)
139    }
140
141    pub fn content_images(&self, source: &str) -> PathBuf {
142        self.storage_root.join(storage::IMAGES).join(source)
143    }
144
145    pub fn images(&self) -> PathBuf {
146        self.storage_root.join(storage::IMAGES)
147    }
148
149    pub fn files(&self) -> PathBuf {
150        self.storage_root.join(storage::FILES)
151    }
152
153    pub fn audio(&self) -> PathBuf {
154        self.storage_root.join(storage::AUDIO)
155    }
156
157    pub fn video(&self) -> PathBuf {
158        self.storage_root.join(storage::VIDEO)
159    }
160
161    pub fn documents(&self) -> PathBuf {
162        self.storage_root.join(storage::DOCUMENTS)
163    }
164
165    pub fn uploads(&self) -> PathBuf {
166        self.storage_root.join(storage::UPLOADS)
167    }
168
169    pub fn url_prefix(&self) -> &str {
170        &self.url_prefix
171    }
172
173    pub fn public_url(&self, relative_path: &str) -> String {
174        let path = relative_path.trim_start_matches('/');
175        format!("{}/{}", self.url_prefix, path)
176    }
177
178    pub fn image_url(&self, relative_to_images: &str) -> String {
179        let path = relative_to_images.trim_start_matches('/');
180        format!("{}/images/{}", self.url_prefix, path)
181    }
182
183    pub fn generated_image_url(&self, filename: &str) -> String {
184        let name = filename.trim_start_matches('/');
185        format!("{}/images/generated/{}", self.url_prefix, name)
186    }
187
188    pub fn content_image_url(&self, source: &str, filename: &str) -> String {
189        let name = filename.trim_start_matches('/');
190        format!("{}/images/{}/{}", self.url_prefix, source, name)
191    }
192
193    pub fn file_url(&self, relative_to_files: &str) -> String {
194        let path = relative_to_files.trim_start_matches('/');
195        format!("{}/files/{}", self.url_prefix, path)
196    }
197
198    pub fn audio_url(&self, filename: &str) -> String {
199        let name = filename.trim_start_matches('/');
200        format!("{}/files/audio/{}", self.url_prefix, name)
201    }
202
203    pub fn video_url(&self, filename: &str) -> String {
204        let name = filename.trim_start_matches('/');
205        format!("{}/files/video/{}", self.url_prefix, name)
206    }
207
208    pub fn document_url(&self, filename: &str) -> String {
209        let name = filename.trim_start_matches('/');
210        format!("{}/files/documents/{}", self.url_prefix, name)
211    }
212
213    pub fn upload_url(&self, filename: &str) -> String {
214        let name = filename.trim_start_matches('/');
215        format!("{}/files/uploads/{}", self.url_prefix, name)
216    }
217}