systemprompt_files/config/
mod.rs1mod 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}