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