Skip to main content

tauri_plugin_configurate/
models.rs

1use serde::{Deserialize, Serialize};
2use tauri::path::BaseDirectory;
3
4use crate::dotpath;
5use crate::error::{Error, Result};
6
7pub const DEFAULT_SQLITE_DB_NAME: &str = "configurate.db";
8pub const DEFAULT_SQLITE_TABLE_NAME: &str = "configurate_configs";
9
10/// Supported storage file formats (legacy input compatibility).
11#[derive(Debug, Clone, Deserialize, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum StorageFormat {
14    Json,
15    #[serde(alias = "yml")]
16    Yaml,
17    Binary,
18}
19
20/// Supported provider kinds for the normalized runtime model.
21#[derive(Debug, Clone, Deserialize, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ProviderKind {
24    Json,
25    Yml,
26    Binary,
27    Sqlite,
28}
29
30/// Provider payload sent from the guest side.
31#[derive(Debug, Clone, Deserialize, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct ProviderPayload {
34    pub kind: ProviderKind,
35    pub encryption_key: Option<String>,
36    pub db_name: Option<String>,
37    pub table_name: Option<String>,
38}
39
40/// Optional path options sent from the guest side.
41#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct PathOptions {
44    pub dir_name: Option<String>,
45    pub current_path: Option<String>,
46}
47
48/// A single keyring entry containing the keyring id and its plaintext value.
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct KeyringEntry {
51    /// Unique keyring id as declared in the TS schema via `keyring(T, { id })`.
52    pub id: String,
53    /// Dot-separated path to this field inside the config object (e.g. `"database.password"`).
54    pub dotpath: String,
55    /// Plaintext value to store in the OS keyring.
56    pub value: String,
57}
58
59/// Options required to access the OS keyring.
60/// The final key stored in the OS keyring uses:
61/// - service = `{service}`
62/// - user    = `{account}/{id}`
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct KeyringOptions {
65    /// The keyring service name (e.g. your app name).
66    pub service: String,
67    /// The keyring account name (e.g. "default").
68    pub account: String,
69}
70
71/// Value type inferred from `defineConfig` for SQLite column materialization.
72#[derive(Debug, Clone, Deserialize, Serialize)]
73#[serde(rename_all = "lowercase")]
74pub enum SqliteValueType {
75    String,
76    Number,
77    Boolean,
78}
79
80/// Flattened column definition for SQLite persistence.
81#[derive(Debug, Clone, Deserialize, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub struct SqliteColumn {
84    pub column_name: String,
85    pub dotpath: String,
86    pub value_type: SqliteValueType,
87    #[serde(default)]
88    pub is_keyring: bool,
89}
90
91/// Unified payload sent from TypeScript side for create/load/save/delete.
92///
93/// This struct intentionally keeps both new and legacy fields so one minor
94/// version can accept old callers while normalizing into one internal model.
95#[derive(Debug, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct ConfiguratePayload {
98    // New API
99    pub file_name: Option<String>,
100    pub base_dir: Option<BaseDirectory>,
101    pub options: Option<PathOptions>,
102    pub provider: Option<ProviderPayload>,
103    #[serde(default)]
104    pub schema_columns: Vec<SqliteColumn>,
105
106    // Legacy API
107    pub name: Option<String>,
108    pub dir: Option<BaseDirectory>,
109    pub dir_name: Option<String>,
110    pub path: Option<String>,
111    pub format: Option<StorageFormat>,
112    pub encryption_key: Option<String>,
113
114    // Common fields
115    pub data: Option<serde_json::Value>,
116    pub keyring_entries: Option<Vec<KeyringEntry>>,
117    pub keyring_options: Option<KeyringOptions>,
118    #[serde(default)]
119    pub with_unlock: bool,
120    /// Whether create/save should return the resulting config data.
121    /// Defaults to true for backward compatibility.
122    pub return_data: Option<bool>,
123}
124
125/// Normalized provider used internally after payload normalization.
126///
127/// Each variant carries only the fields that are meaningful for that provider,
128/// eliminating the spurious `db_name`/`table_name` on non-SQLite providers and
129/// the spurious `encryption_key` on non-Binary providers.
130#[derive(Debug, Clone)]
131pub enum NormalizedProvider {
132    Json,
133    Yml,
134    Binary { encryption_key: Option<String> },
135    Sqlite { db_name: String, table_name: String },
136}
137
138/// Normalized payload used internally across all commands.
139#[derive(Debug, Clone)]
140pub struct NormalizedConfiguratePayload {
141    pub file_name: String,
142    pub base_dir: BaseDirectory,
143    pub dir_name: Option<String>,
144    pub current_path: Option<String>,
145    pub provider: NormalizedProvider,
146    pub schema_columns: Vec<SqliteColumn>,
147    pub data: Option<serde_json::Value>,
148    pub keyring_entries: Option<Vec<KeyringEntry>>,
149    pub keyring_options: Option<KeyringOptions>,
150    pub with_unlock: bool,
151    pub return_data: bool,
152}
153
154impl ConfiguratePayload {
155    pub fn normalize(self) -> Result<NormalizedConfiguratePayload> {
156        let file_name = self
157            .file_name
158            .or(self.name)
159            .ok_or_else(|| Error::InvalidPayload("missing fileName/name".to_string()))?;
160
161        let base_dir = self
162            .base_dir
163            .or(self.dir)
164            .ok_or_else(|| Error::InvalidPayload("missing baseDir/dir".to_string()))?;
165
166        let (dir_name, current_path) = match self.options {
167            Some(opts) => (opts.dir_name, opts.current_path),
168            None => (self.dir_name, self.path),
169        };
170
171        let provider = match self.provider {
172            Some(provider) => {
173                // Validate early: encryptionKey is only meaningful for Binary.
174                // Previously this check was at the bottom as dead code for this branch
175                // because encryption_key was already set to None before the check.
176                let is_binary_provider = matches!(&provider.kind, ProviderKind::Binary);
177                if !is_binary_provider
178                    && (provider.encryption_key.is_some() || self.encryption_key.is_some())
179                {
180                    return Err(Error::InvalidPayload(
181                        "encryptionKey is only supported with provider.kind='binary'".to_string(),
182                    ));
183                }
184
185                match provider.kind {
186                    ProviderKind::Json => NormalizedProvider::Json,
187                    ProviderKind::Yml => NormalizedProvider::Yml,
188                    ProviderKind::Binary => NormalizedProvider::Binary {
189                        encryption_key: provider.encryption_key.or(self.encryption_key),
190                    },
191                    ProviderKind::Sqlite => NormalizedProvider::Sqlite {
192                        db_name: provider
193                            .db_name
194                            .unwrap_or_else(|| DEFAULT_SQLITE_DB_NAME.to_string()),
195                        table_name: provider
196                            .table_name
197                            .unwrap_or_else(|| DEFAULT_SQLITE_TABLE_NAME.to_string()),
198                    },
199                }
200            }
201            None => {
202                // Legacy API path: `format` + optional `encryptionKey`.
203                let format = self
204                    .format
205                    .ok_or_else(|| Error::InvalidPayload("missing provider/format".to_string()))?;
206
207                // Validate encryptionKey for the legacy path.
208                if !matches!(format, StorageFormat::Binary) && self.encryption_key.is_some() {
209                    return Err(Error::InvalidPayload(
210                        "encryptionKey is only supported with provider.kind='binary'".to_string(),
211                    ));
212                }
213
214                match format {
215                    StorageFormat::Json => NormalizedProvider::Json,
216                    StorageFormat::Yaml => NormalizedProvider::Yml,
217                    StorageFormat::Binary => NormalizedProvider::Binary {
218                        encryption_key: self.encryption_key,
219                    },
220                }
221            }
222        };
223
224        let schema_columns = self.schema_columns;
225
226        // Validate dotpaths early so callers get a clear error referencing the
227        // offending column rather than a cryptic dotpath error at write time.
228        for column in &schema_columns {
229            dotpath::validate_path(&column.dotpath).map_err(|e| {
230                Error::InvalidPayload(format!(
231                    "invalid dotpath in column '{}': {}",
232                    column.column_name, e
233                ))
234            })?;
235        }
236
237        Ok(NormalizedConfiguratePayload {
238            file_name,
239            base_dir,
240            dir_name,
241            current_path,
242            provider,
243            schema_columns,
244            data: self.data,
245            keyring_entries: self.keyring_entries,
246            keyring_options: self.keyring_options,
247            with_unlock: self.with_unlock,
248            return_data: self.return_data.unwrap_or(true),
249        })
250    }
251}
252
253/// Payload for the `unlock` command.
254#[derive(Debug, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct UnlockPayload {
257    pub data: serde_json::Value,
258    pub keyring_entries: Option<Vec<KeyringEntry>>,
259    pub keyring_options: Option<KeyringOptions>,
260}
261
262/// Single entry used by `load_all` and `save_all`.
263#[derive(Debug, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct BatchEntryPayload {
266    pub id: String,
267    pub payload: ConfiguratePayload,
268}
269
270/// Batch payload used by `load_all` and `save_all`.
271#[derive(Debug, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct BatchPayload {
274    pub entries: Vec<BatchEntryPayload>,
275}
276
277/// Per-entry successful result.
278#[derive(Debug, Serialize)]
279pub struct BatchEntrySuccess {
280    pub ok: bool,
281    pub data: serde_json::Value,
282}
283
284/// Per-entry failed result.
285#[derive(Debug, Serialize)]
286pub struct BatchEntryFailure {
287    pub ok: bool,
288    pub error: serde_json::Value,
289}
290
291/// Per-entry result envelope.
292#[derive(Debug, Serialize)]
293#[serde(untagged)]
294pub enum BatchEntryResult {
295    Success(BatchEntrySuccess),
296    Failure(BatchEntryFailure),
297}
298
299/// Top-level batch response.
300#[derive(Debug, Serialize)]
301pub struct BatchRunResult {
302    pub results: std::collections::BTreeMap<String, BatchEntryResult>,
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn base_payload() -> ConfiguratePayload {
310        ConfiguratePayload {
311            file_name: Some("app.json".to_string()),
312            base_dir: Some(BaseDirectory::AppConfig),
313            options: None,
314            provider: None,
315            schema_columns: Vec::new(),
316            name: None,
317            dir: None,
318            dir_name: None,
319            path: None,
320            format: None,
321            encryption_key: None,
322            data: None,
323            keyring_entries: None,
324            keyring_options: None,
325            with_unlock: false,
326            return_data: None,
327        }
328    }
329
330    #[test]
331    fn normalize_rejects_legacy_encryption_key_with_non_binary_provider() {
332        let mut payload = base_payload();
333        payload.provider = Some(ProviderPayload {
334            kind: ProviderKind::Json,
335            encryption_key: None,
336            db_name: None,
337            table_name: None,
338        });
339        payload.encryption_key = Some("legacy-key".to_string());
340
341        let err = payload.normalize().expect_err("expected invalid payload");
342        match err {
343            Error::InvalidPayload(msg) => {
344                assert_eq!(
345                    msg,
346                    "encryptionKey is only supported with provider.kind='binary'"
347                );
348            }
349            _ => panic!("unexpected error variant"),
350        }
351    }
352
353    #[test]
354    fn normalize_allows_legacy_encryption_key_with_binary_provider() {
355        let mut payload = base_payload();
356        payload.provider = Some(ProviderPayload {
357            kind: ProviderKind::Binary,
358            encryption_key: None,
359            db_name: None,
360            table_name: None,
361        });
362        payload.encryption_key = Some("legacy-key".to_string());
363
364        let normalized = payload.normalize().expect("expected valid payload");
365        match normalized.provider {
366            NormalizedProvider::Binary { encryption_key } => {
367                assert_eq!(encryption_key.as_deref(), Some("legacy-key"));
368            }
369            _ => panic!("unexpected provider variant"),
370        }
371    }
372}