Skip to main content

systemprompt_files/config/
mod.rs

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