1use std::{fs, path::PathBuf};
2
3use common::prelude::SecretKey;
4use serde::{Deserialize, Serialize};
5
6pub const APP_NAME: &str = "jax";
7pub const CONFIG_FILE_NAME: &str = "config.toml";
8pub const DB_FILE_NAME: &str = "db.sqlite";
9pub const KEY_FILE_NAME: &str = "key.pem";
10pub const BLOBS_DIR_NAME: &str = "blobs";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AppConfig {
14 #[serde(default = "default_api_port")]
16 pub api_port: u16,
17 #[serde(default = "default_gateway_port")]
19 pub gateway_port: u16,
20 #[serde(default)]
22 pub peer_port: Option<u16>,
23 #[serde(default)]
25 pub blob_store: BlobStoreConfig,
26}
27
28fn default_api_port() -> u16 {
29 5001
30}
31
32fn default_gateway_port() -> u16 {
33 8080
34}
35
36impl Default for AppConfig {
37 fn default() -> Self {
38 Self {
39 api_port: default_api_port(),
40 gateway_port: default_gateway_port(),
41 peer_port: None,
42 blob_store: BlobStoreConfig::default(),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50#[serde(tag = "type", rename_all = "snake_case")]
51pub enum BlobStoreConfig {
52 #[default]
54 Legacy,
55
56 Filesystem {
58 path: PathBuf,
60 },
61
62 S3 {
64 url: String,
67 },
68}
69
70#[derive(Debug, Clone)]
72pub struct S3Config {
73 pub endpoint: String,
74 pub access_key: String,
75 pub secret_key: String,
76 pub bucket: String,
77}
78
79impl BlobStoreConfig {
80 pub fn parse_s3_url(url: &str) -> Result<S3Config, StateError> {
83 let url = url
85 .strip_prefix("s3://")
86 .ok_or_else(|| StateError::InvalidS3Url("URL must start with s3://".to_string()))?;
87
88 let (creds, rest) = url
90 .split_once('@')
91 .ok_or_else(|| StateError::InvalidS3Url("Missing @ separator".to_string()))?;
92
93 let (access_key, secret_key) = creds
95 .split_once(':')
96 .ok_or_else(|| StateError::InvalidS3Url("Missing : in credentials".to_string()))?;
97
98 let (endpoint, bucket) = rest
100 .split_once('/')
101 .ok_or_else(|| StateError::InvalidS3Url("Missing / before bucket".to_string()))?;
102
103 if bucket.is_empty() {
104 return Err(StateError::InvalidS3Url("Bucket name is empty".to_string()));
105 }
106
107 let protocol = if endpoint.contains("localhost") || endpoint.contains("127.0.0.1") {
109 "http"
110 } else {
111 "https"
112 };
113
114 Ok(S3Config {
115 endpoint: format!("{}://{}", protocol, endpoint),
116 access_key: access_key.to_string(),
117 secret_key: secret_key.to_string(),
118 bucket: bucket.to_string(),
119 })
120 }
121}
122
123#[derive(Debug, Clone)]
124pub struct AppState {
125 pub jax_dir: PathBuf,
127 pub db_path: PathBuf,
129 pub key_path: PathBuf,
131 pub blobs_path: PathBuf,
133 pub config_path: PathBuf,
135 pub config: AppConfig,
137}
138
139impl AppState {
140 pub fn jax_dir(custom_path: Option<PathBuf>) -> Result<PathBuf, StateError> {
142 if let Some(path) = custom_path {
143 return Ok(path);
144 }
145
146 let home = dirs::home_dir().ok_or(StateError::NoHomeDirectory)?;
148 Ok(home.join(format!(".{}", APP_NAME)))
149 }
150
151 #[allow(dead_code)]
153 pub fn exists(custom_path: Option<PathBuf>) -> Result<bool, StateError> {
154 let jax_dir = Self::jax_dir(custom_path)?;
155 Ok(jax_dir.exists())
156 }
157
158 pub fn init(
160 custom_path: Option<PathBuf>,
161 config: Option<AppConfig>,
162 ) -> Result<Self, StateError> {
163 let jax_dir = Self::jax_dir(custom_path)?;
164
165 if jax_dir.exists() {
167 return Err(StateError::AlreadyInitialized);
168 }
169
170 fs::create_dir_all(&jax_dir)?;
171
172 let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
174 fs::create_dir_all(&blobs_path)?;
175
176 let key = SecretKey::generate();
178 let key_path = jax_dir.join(KEY_FILE_NAME);
179 fs::write(&key_path, key.to_pem())?;
180
181 let config = config.unwrap_or_default();
183 let config_path = jax_dir.join(CONFIG_FILE_NAME);
184 let config_toml = toml::to_string_pretty(&config)?;
185 fs::write(&config_path, config_toml)?;
186
187 let db_path = jax_dir.join(DB_FILE_NAME);
189 fs::write(&db_path, "")?;
190
191 Ok(Self {
192 jax_dir,
193 db_path,
194 key_path,
195 blobs_path,
196 config_path,
197 config,
198 })
199 }
200
201 pub fn load(custom_path: Option<PathBuf>) -> Result<Self, StateError> {
203 let jax_dir = Self::jax_dir(custom_path)?;
204
205 if !jax_dir.exists() {
206 return Err(StateError::NotInitialized);
207 }
208
209 let db_path = jax_dir.join(DB_FILE_NAME);
211 let key_path = jax_dir.join(KEY_FILE_NAME);
212 let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
213 let config_path = jax_dir.join(CONFIG_FILE_NAME);
214
215 if !db_path.exists() {
217 return Err(StateError::MissingFile("db.sqlite".to_string()));
218 }
219 if !key_path.exists() {
220 return Err(StateError::MissingFile("key.pem".to_string()));
221 }
222 if !blobs_path.exists() {
223 return Err(StateError::MissingFile("blobs/".to_string()));
224 }
225 if !config_path.exists() {
226 return Err(StateError::MissingFile("config.toml".to_string()));
227 }
228
229 let config_toml = fs::read_to_string(&config_path)?;
231 let config: AppConfig = toml::from_str(&config_toml)?;
232
233 Ok(Self {
234 jax_dir,
235 db_path,
236 key_path,
237 blobs_path,
238 config_path,
239 config,
240 })
241 }
242
243 pub fn load_key(&self) -> Result<SecretKey, StateError> {
245 let pem = fs::read_to_string(&self.key_path)?;
246 let key = SecretKey::from_pem(&pem).map_err(|e| StateError::InvalidKey(e.to_string()))?;
247 Ok(key)
248 }
249}
250
251#[derive(Debug, thiserror::Error)]
252pub enum StateError {
253 #[error("jax directory not initialized. Run 'cli init' first")]
254 NotInitialized,
255
256 #[error("jax directory already initialized")]
257 AlreadyInitialized,
258
259 #[error("no home directory found")]
260 NoHomeDirectory,
261
262 #[error("missing required file: {0}")]
263 MissingFile(String),
264
265 #[error("invalid key: {0}")]
266 InvalidKey(String),
267
268 #[error("invalid S3 URL: {0}")]
269 InvalidS3Url(String),
270
271 #[error("IO error: {0}")]
272 Io(#[from] std::io::Error),
273
274 #[error("TOML serialization error: {0}")]
275 TomlSer(#[from] toml::ser::Error),
276
277 #[error("TOML deserialization error: {0}")]
278 TomlDe(#[from] toml::de::Error),
279}