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