Compare commits

..

3 Commits

Author SHA1 Message Date
6a57361356 chore: Update config roadmap
Some checks failed
Run Check Script / check (pull_request) Failing after 12s
2026-03-22 19:04:16 -04:00
d0d4f15122 feat(config): Example prompting
Some checks failed
Run Check Script / check (pull_request) Failing after 14s
2026-03-22 18:18:57 -04:00
93b83b8161 feat(config): Sqlite storage and example 2026-03-22 17:43:12 -04:00
11 changed files with 640 additions and 125 deletions

3
Cargo.lock generated
View File

@@ -3670,8 +3670,10 @@ dependencies = [
name = "harmony_config"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"directories",
"env_logger",
"harmony_config_derive",
"harmony_secret",
"inquire 0.7.5",
@@ -3681,6 +3683,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",

View File

@@ -92,3 +92,4 @@ reqwest = { version = "0.12", features = [
], default-features = false }
assertor = "0.0.4"
tokio-test = "0.4"
anyhow = "1.0"

View File

@@ -6,172 +6,165 @@ Make `harmony_config` production-ready with a seamless first-run experience: clo
## Current State
`harmony_config` exists with:
`harmony_config` now has:
- `Config` trait + `#[derive(Config)]` macro
- `ConfigManager` with ordered source chain
- Four `ConfigSource` implementations:
- Five `ConfigSource` implementations:
- `EnvSource` — reads `HARMONY_CONFIG_{KEY}` env vars
- `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
- 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()`
- Two examples: `basic` and `prompting` in `harmony_config/examples/`
- **Zero workspace consumers** — nothing calls `harmony_config` yet
## 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
// harmony_config/src/source/sqlite.rs
pub struct SqliteSource {
pool: SqlitePool,
}
**Implementation Details**:
impl SqliteSource {
/// Opens or creates the database at the given path.
/// Creates the `config` table if it doesn't exist.
pub async fn open(path: PathBuf) -> Result<Self, ConfigError>
- Database location: `~/.local/share/harmony/config/config.db` (directory is auto-created)
- Schema: `config(key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')))`
- Uses `sqlx` with SQLite runtime
- `SqliteSource::open(path)` - opens/creates database at given path
- `SqliteSource::default()` - uses default Harmony data directory
/// Uses the default Harmony data directory:
/// ~/.local/share/harmony/config.db (Linux)
pub async fn default() -> Result<Self, ConfigError>
}
**Files**:
- `harmony_config/src/source/sqlite.rs` - new file
- `harmony_config/Cargo.toml` - added `sqlx = { workspace = true, features = ["runtime-tokio", "sqlite"] }`
- `Cargo.toml` - added `anyhow = "1.0"` to workspace dependencies
#[async_trait]
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**:
**Tests** (all passing):
- `test_sqlite_set_and_get` — round-trip a `TestConfig` struct
- `test_sqlite_get_returns_none_when_missing` — key not in DB
- `test_sqlite_overwrites_on_set` — set twice, get returns latest
- `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
#[async_trait]
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(())
// OLD (BUGGY) - breaks on first source where set() returns Ok(())
for source in &self.sources {
if source.set(T::KEY, &value).await.is_ok() {
break;
}
}
```
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.
Since `EnvSource.set()` returns `Ok(())` (successfully sets env var), the loop would break immediately and never write to `SqliteSource`. Prompted values were never persisted!
**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.
**Solution - Added `should_persist()` method to ConfigSource trait**:
```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 })?;
#[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
}
}
```
// Persist to the first source that accepts writes (skip EnvSource)
for source in &self.sources {
if source.set(T::KEY, &value).await.is_ok() {
break;
}
}
Ok(config)
}
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**:
- `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
#[tokio::test]
async fn test_full_resolution_chain() {
// 1. No env var, no SQLite entry → prompting would happen
// (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
}
**Tests**:
- `test_full_resolution_chain_sqlite_fallback` — env not set, sqlite has value, get() returns sqlite
- `test_full_resolution_chain_env_overrides_sqlite` — env set, sqlite has value, get() returns env
- `test_branch_switching_scenario_deserialization_error` — old struct shape in sqlite returns Deserialization error
#[tokio::test]
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 ⏳
### 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**:
- `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
### 1.5 UX validation checklist ⏳
**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
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)
- [ ] `cargo run --example postgresql` with no env vars → prompts for nothing
- [ ] 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
- [ ] Deleting `~/.local/share/harmony/config.db` → re-prompts on next run
- [ ] Deleting `~/.local/share/harmony/config/` directory → re-prompts on next run
## Deliverables
- [ ] `SqliteSource` implementation with tests
- [ ] Functional `PromptSource` (or validated that current `get_or_prompt` flow is correct)
- [ ] Fix `get_or_prompt` to persist to first writable source, not all sources
- [ ] Integration tests for full resolution chain
- [ ] Branch-switching deserialization failure test
- [x] `SqliteSource` implementation with tests
- [x] Functional `PromptSource` with `should_persist()` design
- [x] Fix `get_or_prompt` to persist to first writable source (via `should_persist()`), not all sources
- [x] Integration tests for full resolution chain
- [x] Branch-switching deserialization failure test
- [ ] `StoreSource<OpenbaoSecretStore>` integration validated (compiles, graceful fallback)
- [ ] 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

View File

@@ -18,6 +18,9 @@ interactive-parse = "0.1.5"
log.workspace = true
directories.workspace = true
inquire.workspace = true
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite"] }
anyhow.workspace = true
env_logger.workspace = true
[dev-dependencies]
pretty_assertions.workspace = true

View 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(())
}

View 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(())
}

View File

@@ -15,6 +15,7 @@ pub use harmony_config_derive::Config;
pub use source::env::EnvSource;
pub use source::local_file::LocalFileSource;
pub use source::prompt::PromptSource;
pub use source::sqlite::SqliteSource;
pub use source::store::StoreSource;
#[derive(Debug, Error)]
@@ -51,6 +52,9 @@ pub enum ConfigError {
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("SQLite error: {0}")]
SqliteError(String),
}
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 set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>;
fn should_persist(&self) -> bool {
true
}
}
pub struct ConfigManager {
@@ -97,20 +105,19 @@ impl ConfigManager {
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,
}
})?;
for source in &self.sources {
if let Err(e) = source
.set(
T::KEY,
&serde_json::to_value(&config).map_err(|e| {
ConfigError::Serialization {
key: T::KEY.to_string(),
source: e,
}
})?,
)
.await
{
debug!("Failed to save config to source: {e}");
if !source.should_persist() {
continue;
}
if source.set(T::KEY, &value).await.is_ok() {
break;
}
}
@@ -460,4 +467,261 @@ mod tests {
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 { .. })));
}
}

View File

@@ -42,4 +42,8 @@ impl ConfigSource for EnvSource {
}
Ok(())
}
fn should_persist(&self) -> bool {
false
}
}

View File

@@ -1,4 +1,5 @@
pub mod env;
pub mod local_file;
pub mod prompt;
pub mod sqlite;
pub mod store;

View File

@@ -36,4 +36,8 @@ impl ConfigSource for PromptSource {
async fn set(&self, _key: &str, _value: &serde_json::Value) -> Result<(), ConfigError> {
Ok(())
}
fn should_persist(&self) -> bool {
false
}
}

View 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(())
}
}