Compare commits
3 Commits
chore/road
...
feat/confi
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a57361356 | |||
| d0d4f15122 | |||
| 93b83b8161 |
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -3670,8 +3670,10 @@ dependencies = [
|
|||||||
name = "harmony_config"
|
name = "harmony_config"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"directories",
|
"directories",
|
||||||
|
"env_logger",
|
||||||
"harmony_config_derive",
|
"harmony_config_derive",
|
||||||
"harmony_secret",
|
"harmony_secret",
|
||||||
"inquire 0.7.5",
|
"inquire 0.7.5",
|
||||||
@@ -3681,6 +3683,7 @@ dependencies = [
|
|||||||
"schemars 0.8.22",
|
"schemars 0.8.22",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -92,3 +92,4 @@ reqwest = { version = "0.12", features = [
|
|||||||
], default-features = false }
|
], default-features = false }
|
||||||
assertor = "0.0.4"
|
assertor = "0.0.4"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|||||||
@@ -6,172 +6,165 @@ Make `harmony_config` production-ready with a seamless first-run experience: clo
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
`harmony_config` exists with:
|
`harmony_config` now has:
|
||||||
|
|
||||||
- `Config` trait + `#[derive(Config)]` macro
|
- `Config` trait + `#[derive(Config)]` macro
|
||||||
- `ConfigManager` with ordered source chain
|
- `ConfigManager` with ordered source chain
|
||||||
- Four `ConfigSource` implementations:
|
- Five `ConfigSource` implementations:
|
||||||
- `EnvSource` — reads `HARMONY_CONFIG_{KEY}` env vars
|
- `EnvSource` — reads `HARMONY_CONFIG_{KEY}` env vars
|
||||||
- `LocalFileSource` — reads/writes `{key}.json` files from a directory
|
- `LocalFileSource` — reads/writes `{key}.json` files from a directory
|
||||||
- `PromptSource` — **stub** (returns `None` / no-ops on set)
|
- `SqliteSource` — **NEW** reads/writes to SQLite database
|
||||||
|
- `PromptSource` — returns `None` / no-op on set (placeholder for TUI integration)
|
||||||
- `StoreSource<S: SecretStore>` — wraps any `harmony_secret::SecretStore` backend
|
- `StoreSource<S: SecretStore>` — wraps any `harmony_secret::SecretStore` backend
|
||||||
- 12 unit tests (mock source, env, local file)
|
- 24 unit tests (mock source, env, local file, sqlite, prompt, integration)
|
||||||
- Global `CONFIG_MANAGER` static with `init()`, `get()`, `get_or_prompt()`, `set()`
|
- Global `CONFIG_MANAGER` static with `init()`, `get()`, `get_or_prompt()`, `set()`
|
||||||
|
- Two examples: `basic` and `prompting` in `harmony_config/examples/`
|
||||||
- **Zero workspace consumers** — nothing calls `harmony_config` yet
|
- **Zero workspace consumers** — nothing calls `harmony_config` yet
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
### 1.1 Add `SqliteSource` as the default zero-setup backend
|
### 1.1 Add `SqliteSource` as the default zero-setup backend ✅
|
||||||
|
|
||||||
Replace `LocalFileSource` (JSON files scattered in a directory) with a single SQLite database as the default local backend. `sqlx` with SQLite is already a workspace dependency.
|
**Status**: Implemented
|
||||||
|
|
||||||
```rust
|
**Implementation Details**:
|
||||||
// harmony_config/src/source/sqlite.rs
|
|
||||||
pub struct SqliteSource {
|
|
||||||
pool: SqlitePool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteSource {
|
- Database location: `~/.local/share/harmony/config/config.db` (directory is auto-created)
|
||||||
/// Opens or creates the database at the given path.
|
- Schema: `config(key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')))`
|
||||||
/// Creates the `config` table if it doesn't exist.
|
- Uses `sqlx` with SQLite runtime
|
||||||
pub async fn open(path: PathBuf) -> Result<Self, ConfigError>
|
- `SqliteSource::open(path)` - opens/creates database at given path
|
||||||
|
- `SqliteSource::default()` - uses default Harmony data directory
|
||||||
|
|
||||||
/// Uses the default Harmony data directory:
|
**Files**:
|
||||||
/// ~/.local/share/harmony/config.db (Linux)
|
- `harmony_config/src/source/sqlite.rs` - new file
|
||||||
pub async fn default() -> Result<Self, ConfigError>
|
- `harmony_config/Cargo.toml` - added `sqlx = { workspace = true, features = ["runtime-tokio", "sqlite"] }`
|
||||||
}
|
- `Cargo.toml` - added `anyhow = "1.0"` to workspace dependencies
|
||||||
|
|
||||||
#[async_trait]
|
**Tests** (all passing):
|
||||||
impl ConfigSource for SqliteSource {
|
|
||||||
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError>
|
|
||||||
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Schema:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests**:
|
|
||||||
- `test_sqlite_set_and_get` — round-trip a `TestConfig` struct
|
- `test_sqlite_set_and_get` — round-trip a `TestConfig` struct
|
||||||
- `test_sqlite_get_returns_none_when_missing` — key not in DB
|
- `test_sqlite_get_returns_none_when_missing` — key not in DB
|
||||||
- `test_sqlite_overwrites_on_set` — set twice, get returns latest
|
- `test_sqlite_overwrites_on_set` — set twice, get returns latest
|
||||||
- `test_sqlite_concurrent_access` — two tasks writing different keys simultaneously
|
- `test_sqlite_concurrent_access` — two tasks writing different keys simultaneously
|
||||||
- All tests use `tempfile::NamedTempFile` for the DB path
|
|
||||||
|
|
||||||
### 1.1.1 Add Config example to show exact DX and confirm functionality
|
### 1.1.1 Add Config example to show exact DX and confirm functionality ✅
|
||||||
|
|
||||||
Create `harmony_config/examples` that show how to use config crate with various backends.
|
**Status**: Implemented
|
||||||
|
|
||||||
Show how to use the derive macros, how to store secrets in a local backend or a zitadel + openbao backend, how to fetch them from environment variables, etc. Explicitely outline the dependencies for examples with dependencies in a comment at the top. Explain how to configure zitadel + openbao for this backend. The local backend should have zero dependency, zero setup, storing its config/secrets with sane defaults.
|
**Examples created**:
|
||||||
|
|
||||||
Also show that a Config with default values will not prompt for values with defaults.
|
1. `harmony_config/examples/basic.rs` - demonstrates:
|
||||||
|
- Zero-setup SQLite backend (auto-creates directory)
|
||||||
|
- Using the `#[derive(Config)]` macro
|
||||||
|
- Environment variable override (`HARMONY_CONFIG_TestConfig` overrides SQLite)
|
||||||
|
- Direct set/get operations
|
||||||
|
- Persistence verification
|
||||||
|
|
||||||
### 1.2 Make `PromptSource` functional
|
2. `harmony_config/examples/prompting.rs` - demonstrates:
|
||||||
|
- Config with no defaults (requires user input via `inquire`)
|
||||||
|
- `get()` flow: env > sqlite > prompt fallback
|
||||||
|
- `get_or_prompt()` for interactive configuration
|
||||||
|
- Full resolution chain
|
||||||
|
- Persistence of prompted values
|
||||||
|
|
||||||
Currently `PromptSource::get()` returns `None` and `set()` is a no-op. Wire it to `interactive_parse::InteractiveParseObj`:
|
### 1.2 Make `PromptSource` functional ✅
|
||||||
|
|
||||||
|
**Status**: Implemented with design improvement
|
||||||
|
|
||||||
|
**Key Finding - Bug Fixed During Implementation**:
|
||||||
|
|
||||||
|
The original design had a critical bug in `get_or_prompt()`:
|
||||||
```rust
|
```rust
|
||||||
#[async_trait]
|
// OLD (BUGGY) - breaks on first source where set() returns Ok(())
|
||||||
impl ConfigSource for PromptSource {
|
|
||||||
async fn get(&self, _key: &str) -> Result<Option<serde_json::Value>, ConfigError> {
|
|
||||||
// PromptSource never "has" a value — it's always a fallback.
|
|
||||||
// The actual prompting happens in ConfigManager::get_or_prompt().
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set(&self, _key: &str, _value: &serde_json::Value) -> Result<(), ConfigError> {
|
|
||||||
// Prompt source doesn't persist. Other sources in the chain do.
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The prompting logic is already in `ConfigManager::get_or_prompt()` via `T::parse_to_obj()`. The `PromptSource` struct exists mainly to hold the `PROMPT_MUTEX` and potentially a custom writer for TUI integration later.
|
|
||||||
|
|
||||||
**Key fix**: Ensure `get_or_prompt()` persists the prompted value to the **first writable source** (SQLite), not to all sources. Current code tries all sources — this is wrong for prompt-then-persist because you don't want to write prompted values to env vars.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
|
|
||||||
match self.get::<T>().await {
|
|
||||||
Ok(config) => Ok(config),
|
|
||||||
Err(ConfigError::NotFound { .. }) => {
|
|
||||||
let config = T::parse_to_obj()
|
|
||||||
.map_err(|e| ConfigError::PromptError(e.to_string()))?;
|
|
||||||
let value = serde_json::to_value(&config)
|
|
||||||
.map_err(|e| ConfigError::Serialization { key: T::KEY.to_string(), source: e })?;
|
|
||||||
|
|
||||||
// Persist to the first source that accepts writes (skip EnvSource)
|
|
||||||
for source in &self.sources {
|
for source in &self.sources {
|
||||||
if source.set(T::KEY, &value).await.is_ok() {
|
if source.set(T::KEY, &value).await.is_ok() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(config)
|
```
|
||||||
|
|
||||||
|
Since `EnvSource.set()` returns `Ok(())` (successfully sets env var), the loop would break immediately and never write to `SqliteSource`. Prompted values were never persisted!
|
||||||
|
|
||||||
|
**Solution - Added `should_persist()` method to ConfigSource trait**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ConfigSource: Send + Sync {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError>;
|
||||||
|
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>;
|
||||||
|
fn should_persist(&self) -> bool {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `EnvSource::should_persist()` returns `false` - shouldn't persist prompted values to env vars
|
||||||
|
- `PromptSource::should_persist()` returns `false` - doesn't persist anyway
|
||||||
|
- `get_or_prompt()` now skips sources where `should_persist()` is `false`
|
||||||
|
|
||||||
|
**Updated `get_or_prompt()`**:
|
||||||
|
```rust
|
||||||
|
for source in &self.sources {
|
||||||
|
if !source.should_persist() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if source.set(T::KEY, &value).await.is_ok() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Tests**:
|
**Tests**:
|
||||||
- `test_get_or_prompt_persists_to_first_writable_source` — mock source chain where first source is read-only, second is writable. Verify prompted value lands in second source.
|
- `test_prompt_source_always_returns_none`
|
||||||
|
- `test_prompt_source_set_is_noop`
|
||||||
|
- `test_prompt_source_does_not_persist`
|
||||||
|
- `test_full_chain_with_prompt_source_falls_through_to_prompt`
|
||||||
|
|
||||||
### 1.3 Integration test: full resolution chain
|
### 1.3 Integration test: full resolution chain ✅
|
||||||
|
|
||||||
Test the complete priority chain: env > sqlite > prompt.
|
**Status**: Implemented
|
||||||
|
|
||||||
```rust
|
**Tests**:
|
||||||
#[tokio::test]
|
- `test_full_resolution_chain_sqlite_fallback` — env not set, sqlite has value, get() returns sqlite
|
||||||
async fn test_full_resolution_chain() {
|
- `test_full_resolution_chain_env_overrides_sqlite` — env set, sqlite has value, get() returns env
|
||||||
// 1. No env var, no SQLite entry → prompting would happen
|
- `test_branch_switching_scenario_deserialization_error` — old struct shape in sqlite returns Deserialization error
|
||||||
// (test with mock/pre-seeded source instead of real stdin)
|
|
||||||
// 2. Set in SQLite → get() returns SQLite value
|
|
||||||
// 3. Set env var → get() returns env value (overrides SQLite)
|
|
||||||
// 4. Remove env var → get() falls back to SQLite
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
### 1.4 Validate Zitadel + OpenBao integration path ⏳
|
||||||
async fn test_branch_switching_scenario() {
|
|
||||||
// Simulate: struct shape changes between branches.
|
|
||||||
// Old value in SQLite doesn't match new struct.
|
|
||||||
// get() should return Deserialization error.
|
|
||||||
// get_or_prompt() should re-prompt and overwrite.
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 Validate Zitadel + OpenBao integration path
|
**Status**: Not yet implemented
|
||||||
|
|
||||||
This is not about building the full OIDC flow yet. It's about validating that the architecture supports it by adding `StoreSource<OpenbaoSecretStore>` to the source chain.
|
Remaining work:
|
||||||
|
- Validate that `ConfigManager::new(vec![EnvSource, SqliteSource, StoreSource<Openbao>])` compiles
|
||||||
|
- When OpenBao is unreachable, chain falls through to SQLite gracefully
|
||||||
|
- Document target Zitadel OIDC flow as ADR
|
||||||
|
|
||||||
**Validate**:
|
### 1.5 UX validation checklist ⏳
|
||||||
- `ConfigManager::new(vec![EnvSource, SqliteSource, StoreSource<Openbao>])` compiles and works
|
|
||||||
- When OpenBao is unreachable, the chain falls through to SQLite gracefully (no panic)
|
|
||||||
- When OpenBao has the value, it's returned and SQLite is not queried
|
|
||||||
|
|
||||||
**Document** the target Zitadel OIDC flow as an ADR (RFC 8628 device authorization grant), but don't implement it yet. The `StoreSource` wrapping OpenBao with JWT auth is the integration point — Zitadel provides the JWT, OpenBao validates it.
|
**Status**: Partially complete - manual verification needed
|
||||||
|
|
||||||
### 1.5 UX validation checklist
|
- [ ] `cargo run --example postgresql` with no env vars → prompts for nothing
|
||||||
|
|
||||||
Before this phase is done, manually verify:
|
|
||||||
|
|
||||||
- [ ] `cargo run --example postgresql` with no env vars → prompts for nothing (postgresql doesn't use secrets yet, but the config system initializes cleanly)
|
|
||||||
- [ ] An example that uses `SecretManager` today (e.g., `brocade_snmp_server`) → when migrated to `harmony_config`, first run prompts, second run reads from SQLite
|
- [ ] An example that uses `SecretManager` today (e.g., `brocade_snmp_server`) → when migrated to `harmony_config`, first run prompts, second run reads from SQLite
|
||||||
- [ ] Setting `HARMONY_CONFIG_BrocadeSwitchAuth='{"host":"...","user":"...","password":"..."}'` → skips prompt, uses env value
|
- [ ] Setting `HARMONY_CONFIG_BrocadeSwitchAuth='{"host":"...","user":"...","password":"..."}'` → skips prompt, uses env value
|
||||||
- [ ] Deleting `~/.local/share/harmony/config.db` → re-prompts on next run
|
- [ ] Deleting `~/.local/share/harmony/config/` directory → re-prompts on next run
|
||||||
|
|
||||||
## Deliverables
|
## Deliverables
|
||||||
|
|
||||||
- [ ] `SqliteSource` implementation with tests
|
- [x] `SqliteSource` implementation with tests
|
||||||
- [ ] Functional `PromptSource` (or validated that current `get_or_prompt` flow is correct)
|
- [x] Functional `PromptSource` with `should_persist()` design
|
||||||
- [ ] Fix `get_or_prompt` to persist to first writable source, not all sources
|
- [x] Fix `get_or_prompt` to persist to first writable source (via `should_persist()`), not all sources
|
||||||
- [ ] Integration tests for full resolution chain
|
- [x] Integration tests for full resolution chain
|
||||||
- [ ] Branch-switching deserialization failure test
|
- [x] Branch-switching deserialization failure test
|
||||||
- [ ] `StoreSource<OpenbaoSecretStore>` integration validated (compiles, graceful fallback)
|
- [ ] `StoreSource<OpenbaoSecretStore>` integration validated (compiles, graceful fallback)
|
||||||
- [ ] ADR for Zitadel OIDC target architecture
|
- [ ] ADR for Zitadel OIDC target architecture
|
||||||
|
- [ ] Update docs to reflect final implementation and behavior
|
||||||
|
|
||||||
|
## Key Implementation Notes
|
||||||
|
|
||||||
|
1. **SQLite path**: `~/.local/share/harmony/config/config.db` (not `~/.local/share/harmony/config.db`)
|
||||||
|
|
||||||
|
2. **Auto-create directory**: `SqliteSource::open()` creates parent directories if they don't exist
|
||||||
|
|
||||||
|
3. **Default path**: `SqliteSource::default()` uses `directories::ProjectDirs` to find the correct data directory
|
||||||
|
|
||||||
|
4. **Env var precedence**: Environment variables always take precedence over SQLite in the resolution chain
|
||||||
|
|
||||||
|
5. **Testing**: All tests use `tempfile::NamedTempFile` for temporary database paths, ensuring test isolation
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ interactive-parse = "0.1.5"
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
inquire.workspace = true
|
inquire.workspace = true
|
||||||
|
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite"] }
|
||||||
|
anyhow.workspace = true
|
||||||
|
env_logger.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions.workspace = true
|
pretty_assertions.workspace = true
|
||||||
|
|||||||
88
harmony_config/examples/basic.rs
Normal file
88
harmony_config/examples/basic.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! Basic example showing harmony_config with SQLite backend
|
||||||
|
//!
|
||||||
|
//! This example demonstrates:
|
||||||
|
//! - Zero-setup SQLite backend (no configuration needed)
|
||||||
|
//! - Using the `#[derive(Config)]` macro
|
||||||
|
//! - Environment variable override (HARMONY_CONFIG_TestConfig overrides SQLite)
|
||||||
|
//! - Direct set/get operations (prompting requires a TTY)
|
||||||
|
//!
|
||||||
|
//! Run with:
|
||||||
|
//! - `cargo run --example basic` - creates/reads config from SQLite
|
||||||
|
//! - `HARMONY_CONFIG_TestConfig='{"name":"from_env","count":42}' cargo run --example basic` - uses env var
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use harmony_config::{Config, ConfigManager, EnvSource, SqliteSource};
|
||||||
|
use log::info;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Config)]
|
||||||
|
struct TestConfig {
|
||||||
|
name: String,
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TestConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "default_name".to_string(),
|
||||||
|
count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let sqlite = SqliteSource::default().await?;
|
||||||
|
let manager = ConfigManager::new(vec![
|
||||||
|
Arc::new(EnvSource),
|
||||||
|
Arc::new(sqlite),
|
||||||
|
]);
|
||||||
|
|
||||||
|
info!("1. Attempting to get TestConfig (expect NotFound on first run)...");
|
||||||
|
match manager.get::<TestConfig>().await {
|
||||||
|
Ok(config) => {
|
||||||
|
info!(" Found config: {:?}", config);
|
||||||
|
}
|
||||||
|
Err(harmony_config::ConfigError::NotFound { .. }) => {
|
||||||
|
info!(" NotFound - as expected on first run");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!(" Error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("\n2. Setting config directly...");
|
||||||
|
let config = TestConfig {
|
||||||
|
name: "from_code".to_string(),
|
||||||
|
count: 42,
|
||||||
|
};
|
||||||
|
manager.set(&config).await?;
|
||||||
|
info!(" Set config: {:?}", config);
|
||||||
|
|
||||||
|
info!("\n3. Getting config back from SQLite...");
|
||||||
|
let retrieved: TestConfig = manager.get().await?;
|
||||||
|
info!(" Retrieved: {:?}", retrieved);
|
||||||
|
|
||||||
|
info!("\n4. Using env override...");
|
||||||
|
info!(" Env var HARMONY_CONFIG_TestConfig overrides SQLite");
|
||||||
|
let env_config = TestConfig {
|
||||||
|
name: "from_env".to_string(),
|
||||||
|
count: 99,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("HARMONY_CONFIG_TestConfig", serde_json::to_string(&env_config)?);
|
||||||
|
}
|
||||||
|
let from_env: TestConfig = manager.get().await?;
|
||||||
|
info!(" Got from env: {:?}", from_env);
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("HARMONY_CONFIG_TestConfig");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("\nDone! Config persisted at ~/.local/share/harmony/config/config.db");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
69
harmony_config/examples/prompting.rs
Normal file
69
harmony_config/examples/prompting.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
//! Example demonstrating configuration prompting with harmony_config
|
||||||
|
//!
|
||||||
|
//! This example shows how to use `get_or_prompt()` to interactively
|
||||||
|
//! ask the user for configuration values when none are found.
|
||||||
|
//!
|
||||||
|
//! **Note**: This example requires a TTY to work properly since it uses
|
||||||
|
//! interactive prompting via `inquire`. Run in a terminal.
|
||||||
|
//!
|
||||||
|
//! Run with:
|
||||||
|
//! - `cargo run --example prompting` - will prompt for values interactively
|
||||||
|
//! - If config exists in SQLite, it will be used directly without prompting
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use harmony_config::{Config, ConfigManager, EnvSource, PromptSource, SqliteSource};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Config)]
|
||||||
|
struct UserConfig {
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
theme: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let sqlite = SqliteSource::default().await?;
|
||||||
|
let manager = ConfigManager::new(vec![
|
||||||
|
Arc::new(EnvSource),
|
||||||
|
Arc::new(sqlite),
|
||||||
|
Arc::new(PromptSource::new()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
println!("UserConfig Setup");
|
||||||
|
println!("=================\n");
|
||||||
|
|
||||||
|
println!("Attempting to get UserConfig (env > sqlite > prompt)...\n");
|
||||||
|
|
||||||
|
match manager.get::<UserConfig>().await {
|
||||||
|
Ok(config) => {
|
||||||
|
println!("Found existing config:");
|
||||||
|
println!(" Username: {}", config.username);
|
||||||
|
println!(" Email: {}", config.email);
|
||||||
|
println!(" Theme: {}", config.theme);
|
||||||
|
println!("\nNo prompting needed - using stored config.");
|
||||||
|
}
|
||||||
|
Err(harmony_config::ConfigError::NotFound { .. }) => {
|
||||||
|
println!("No config found in env or SQLite.");
|
||||||
|
println!("Calling get_or_prompt() to interactively request config...\n");
|
||||||
|
|
||||||
|
let config: UserConfig = manager.get_or_prompt().await?;
|
||||||
|
println!("\nConfig received and saved to SQLite:");
|
||||||
|
println!(" Username: {}", config.username);
|
||||||
|
println!(" Email: {}", config.email);
|
||||||
|
println!(" Theme: {}", config.theme);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nConfig is persisted at ~/.local/share/harmony/config/config.db");
|
||||||
|
println!("On next run, the stored config will be used without prompting.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ pub use harmony_config_derive::Config;
|
|||||||
pub use source::env::EnvSource;
|
pub use source::env::EnvSource;
|
||||||
pub use source::local_file::LocalFileSource;
|
pub use source::local_file::LocalFileSource;
|
||||||
pub use source::prompt::PromptSource;
|
pub use source::prompt::PromptSource;
|
||||||
|
pub use source::sqlite::SqliteSource;
|
||||||
pub use source::store::StoreSource;
|
pub use source::store::StoreSource;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -51,6 +52,9 @@ pub enum ConfigError {
|
|||||||
|
|
||||||
#[error("I/O error: {0}")]
|
#[error("I/O error: {0}")]
|
||||||
IoError(#[from] std::io::Error),
|
IoError(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("SQLite error: {0}")]
|
||||||
|
SqliteError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Config: Serialize + DeserializeOwned + JsonSchema + InteractiveParseObj + Sized {
|
pub trait Config: Serialize + DeserializeOwned + JsonSchema + InteractiveParseObj + Sized {
|
||||||
@@ -62,6 +66,10 @@ pub trait ConfigSource: Send + Sync {
|
|||||||
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError>;
|
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError>;
|
||||||
|
|
||||||
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>;
|
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>;
|
||||||
|
|
||||||
|
fn should_persist(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ConfigManager {
|
pub struct ConfigManager {
|
||||||
@@ -97,20 +105,19 @@ impl ConfigManager {
|
|||||||
let config =
|
let config =
|
||||||
T::parse_to_obj().map_err(|e| ConfigError::PromptError(e.to_string()))?;
|
T::parse_to_obj().map_err(|e| ConfigError::PromptError(e.to_string()))?;
|
||||||
|
|
||||||
for source in &self.sources {
|
let value = serde_json::to_value(&config).map_err(|e| {
|
||||||
if let Err(e) = source
|
|
||||||
.set(
|
|
||||||
T::KEY,
|
|
||||||
&serde_json::to_value(&config).map_err(|e| {
|
|
||||||
ConfigError::Serialization {
|
ConfigError::Serialization {
|
||||||
key: T::KEY.to_string(),
|
key: T::KEY.to_string(),
|
||||||
source: e,
|
source: e,
|
||||||
}
|
}
|
||||||
})?,
|
})?;
|
||||||
)
|
|
||||||
.await
|
for source in &self.sources {
|
||||||
{
|
if !source.should_persist() {
|
||||||
debug!("Failed to save config to source: {e}");
|
continue;
|
||||||
|
}
|
||||||
|
if source.set(T::KEY, &value).await.is_ok() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,4 +467,261 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(parsed, config);
|
assert_eq!(parsed, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sqlite_set_and_get() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let path = temp_file.path().to_path_buf();
|
||||||
|
let source = SqliteSource::open(path).await.unwrap();
|
||||||
|
|
||||||
|
let config = TestConfig {
|
||||||
|
name: "sqlite_test".to_string(),
|
||||||
|
count: 42,
|
||||||
|
};
|
||||||
|
|
||||||
|
source
|
||||||
|
.set("TestConfig", &serde_json::to_value(&config).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = source.get("TestConfig").await.unwrap().unwrap();
|
||||||
|
let parsed: TestConfig = serde_json::from_value(result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sqlite_get_returns_none_when_missing() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let path = temp_file.path().to_path_buf();
|
||||||
|
let source = SqliteSource::open(path).await.unwrap();
|
||||||
|
|
||||||
|
let result = source.get("NonExistentConfig").await.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sqlite_overwrites_on_set() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let path = temp_file.path().to_path_buf();
|
||||||
|
let source = SqliteSource::open(path).await.unwrap();
|
||||||
|
|
||||||
|
let config1 = TestConfig {
|
||||||
|
name: "first".to_string(),
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
let config2 = TestConfig {
|
||||||
|
name: "second".to_string(),
|
||||||
|
count: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
source
|
||||||
|
.set("TestConfig", &serde_json::to_value(&config1).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
source
|
||||||
|
.set("TestConfig", &serde_json::to_value(&config2).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = source.get("TestConfig").await.unwrap().unwrap();
|
||||||
|
let parsed: TestConfig = serde_json::from_value(result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed, config2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sqlite_concurrent_access() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let path = temp_file.path().to_path_buf();
|
||||||
|
let source = SqliteSource::open(path).await.unwrap();
|
||||||
|
|
||||||
|
let source = Arc::new(source);
|
||||||
|
|
||||||
|
let config1 = TestConfig {
|
||||||
|
name: "task1".to_string(),
|
||||||
|
count: 100,
|
||||||
|
};
|
||||||
|
let config2 = TestConfig {
|
||||||
|
name: "task2".to_string(),
|
||||||
|
count: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (r1, r2) = tokio::join!(
|
||||||
|
async {
|
||||||
|
source
|
||||||
|
.set("key1", &serde_json::to_value(&config1).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
source.get("key1").await.unwrap().unwrap()
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
source
|
||||||
|
.set("key2", &serde_json::to_value(&config2).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
source.get("key2").await.unwrap().unwrap()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed1: TestConfig = serde_json::from_value(r1).unwrap();
|
||||||
|
let parsed2: TestConfig = serde_json::from_value(r2).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed1, config1);
|
||||||
|
assert_eq!(parsed2, config2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_or_prompt_persists_to_first_writable_source() {
|
||||||
|
let source1 = Arc::new(MockSource::new());
|
||||||
|
let source2 = Arc::new(MockSource::new());
|
||||||
|
let manager = ConfigManager::new(vec![source1.clone(), source2.clone()]);
|
||||||
|
|
||||||
|
let result: Result<TestConfig, ConfigError> = manager.get_or_prompt().await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
assert_eq!(source1.set_call_count(), 0);
|
||||||
|
assert_eq!(source2.set_call_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_full_resolution_chain_sqlite_fallback() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let sqlite = SqliteSource::open(temp_file.path().to_path_buf()).await.unwrap();
|
||||||
|
let sqlite = Arc::new(sqlite);
|
||||||
|
|
||||||
|
let manager = ConfigManager::new(vec![sqlite.clone()]);
|
||||||
|
|
||||||
|
let config = TestConfig {
|
||||||
|
name: "from_sqlite".to_string(),
|
||||||
|
count: 42,
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlite
|
||||||
|
.set("TestConfig", &serde_json::to_value(&config).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result: TestConfig = manager.get().await.unwrap();
|
||||||
|
assert_eq!(result, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_full_resolution_chain_env_overrides_sqlite() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let sqlite = SqliteSource::open(temp_file.path().to_path_buf()).await.unwrap();
|
||||||
|
let sqlite = Arc::new(sqlite);
|
||||||
|
let env_source = Arc::new(EnvSource);
|
||||||
|
|
||||||
|
let manager = ConfigManager::new(vec![env_source.clone(), sqlite.clone()]);
|
||||||
|
|
||||||
|
let sqlite_config = TestConfig {
|
||||||
|
name: "from_sqlite".to_string(),
|
||||||
|
count: 42,
|
||||||
|
};
|
||||||
|
let env_config = TestConfig {
|
||||||
|
name: "from_env".to_string(),
|
||||||
|
count: 99,
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlite
|
||||||
|
.set("TestConfig", &serde_json::to_value(&sqlite_config).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let env_key = format!("HARMONY_CONFIG_{}", "TestConfig");
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(&env_key, serde_json::to_string(&env_config).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: TestConfig = manager.get().await.unwrap();
|
||||||
|
assert_eq!(result.name, "from_env");
|
||||||
|
assert_eq!(result.count, 99);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var(&env_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_branch_switching_scenario_deserialization_error() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let sqlite = SqliteSource::open(temp_file.path().to_path_buf()).await.unwrap();
|
||||||
|
let sqlite = Arc::new(sqlite);
|
||||||
|
|
||||||
|
let manager = ConfigManager::new(vec![sqlite.clone()]);
|
||||||
|
|
||||||
|
let old_config = serde_json::json!({
|
||||||
|
"name": "old_config",
|
||||||
|
"count": "not_a_number"
|
||||||
|
});
|
||||||
|
sqlite.set("TestConfig", &old_config).await.unwrap();
|
||||||
|
|
||||||
|
let result: Result<TestConfig, ConfigError> = manager.get().await;
|
||||||
|
assert!(matches!(result, Err(ConfigError::Deserialization { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_prompt_source_always_returns_none() {
|
||||||
|
let source = PromptSource::new();
|
||||||
|
let result = source.get("AnyKey").await.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_prompt_source_set_is_noop() {
|
||||||
|
let source = PromptSource::new();
|
||||||
|
let result = source
|
||||||
|
.set("AnyKey", &serde_json::json!({"test": "value"}))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_prompt_source_does_not_persist() {
|
||||||
|
let source = PromptSource::new();
|
||||||
|
source
|
||||||
|
.set("TestConfig", &serde_json::json!({"name": "test", "count": 42}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = source.get("TestConfig").await.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_full_chain_with_prompt_source_falls_through_to_prompt() {
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let sqlite = SqliteSource::open(temp_file.path().to_path_buf()).await.unwrap();
|
||||||
|
let sqlite = Arc::new(sqlite);
|
||||||
|
|
||||||
|
let source1 = Arc::new(MockSource::new());
|
||||||
|
let prompt_source = Arc::new(PromptSource::new());
|
||||||
|
|
||||||
|
let manager = ConfigManager::new(vec![
|
||||||
|
source1.clone(),
|
||||||
|
sqlite.clone(),
|
||||||
|
prompt_source.clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result: Result<TestConfig, ConfigError> = manager.get().await;
|
||||||
|
assert!(matches!(result, Err(ConfigError::NotFound { .. })));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,8 @@ impl ConfigSource for EnvSource {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_persist(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod env;
|
pub mod env;
|
||||||
pub mod local_file;
|
pub mod local_file;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
pub mod sqlite;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|||||||
@@ -36,4 +36,8 @@ impl ConfigSource for PromptSource {
|
|||||||
async fn set(&self, _key: &str, _value: &serde_json::Value) -> Result<(), ConfigError> {
|
async fn set(&self, _key: &str, _value: &serde_json::Value) -> Result<(), ConfigError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_persist(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
harmony_config/src/source/sqlite.rs
Normal file
85
harmony_config/src/source/sqlite.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use crate::{ConfigError, ConfigSource};
|
||||||
|
|
||||||
|
pub struct SqliteSource {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteSource {
|
||||||
|
pub async fn open(path: PathBuf) -> Result<Self, ConfigError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).await.map_err(|e| {
|
||||||
|
ConfigError::SqliteError(format!("Failed to create config directory: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_url = format!("sqlite:{}?mode=rwc", path.display());
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect(&database_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::SqliteError(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::SqliteError(format!("Failed to create table: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn default() -> Result<Self, ConfigError> {
|
||||||
|
let path = crate::default_config_dir()
|
||||||
|
.ok_or_else(|| ConfigError::SqliteError("Could not determine default config directory".into()))?
|
||||||
|
.join("config.db");
|
||||||
|
Self::open(path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ConfigSource for SqliteSource {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM config WHERE key = ?")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::SqliteError(format!("Failed to query database: {}", e)))?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some((value,)) => {
|
||||||
|
let json_value: serde_json::Value = serde_json::from_str(&value)
|
||||||
|
.map_err(|e| ConfigError::Deserialization {
|
||||||
|
key: key.to_string(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
Ok(Some(json_value))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError> {
|
||||||
|
let json_string = serde_json::to_string(value).map_err(|e| ConfigError::Serialization {
|
||||||
|
key: key.to_string(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
sqlx::query("INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))")
|
||||||
|
.bind(key)
|
||||||
|
.bind(&json_string)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::SqliteError(format!("Failed to insert/update database: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user