settings_loader 1.0.0

Opinionated configuration settings load mechanism for Rust applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# Architectural Improvements for settings-loader-rs

This document proposes architectural changes to modernize `settings-loader-rs` based on patterns observed in `spark-turtle` and contemporary configuration library design.

---

## Current Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│                      SettingsLoader Trait                       │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ config crate │  │ LoadingOpts  │  │ Serde Deserialize    │  │
│  │ (read-only)  │  │ (paths/env)  │  │ (type conversion)    │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                    Application Settings Struct                   │
│                    (fully materialized, typed)                   │
└─────────────────────────────────────────────────────────────────┘
```

### Limitations (As of v0.15.0)

1. **One-way data flow**: Load only, no write-back
   - **Solution (Phase 4)**: LayerEditor for bidirectional editing
2. **Single output**: One merged struct, no per-scope access
   - **Solution (Phase 3)**: MultiScopeLoader for explicit scope management
3. **Opaque merging**: Cannot track which source provided which value
   - **Solution (Phase 2)**: SourceMap for source provenance tracking
4. **No runtime introspection**: Keys/types not discoverable
   - **Solution (Phase 5)**: ConfigSchema trait for metadata access
5. **Single scope**: No user-global vs project-local distinction
   - **Solution (Phase 3)**: ConfigScope enum with path resolution

---

## Proposed Architecture

### Core Design: Wrapped Architecture with Provenance

The proposed architecture wraps the config crate with additional layers rather than replacing it:

```
┌────────────────────────────────────────────────┐
│  Application Layer                             │
│  let (s, src) = load_with_provenance()?        │
└────────────────┬────────────────────────────┘
        ┌────────┴─────────┐
        ▼                  ▼
    LayerBuilder    MultiScopeLoader  LayerEditor
    (Phase 1)       (Phase 3)          (Phase 4)
        │                  │                │
        └────────┬─────────┴────────────────┘
        ┌────────▼────────────────┐
        │ SourceMetadata +        │
        │ Provenance Tracking     │
        │ (Phase 2 - NEW)         │
        └────────┬────────────────┘
        ┌────────▼────────────────┐
        │ Config Crate            │
        │ (Merge + Serde)         │
        │ (UNCHANGED)             │
        └────────┬────────────────┘
        ┌────────▼────────────────┐
        │ Typed Settings          │
        │ (Deserialized)          │
        └────────────────────────┘

    ConfigSchema (Phase 5) is independent,
    works alongside this flow.
```

**Key principle**: Each layer preserves the layer below, adds new capability on top.

### New Trait Hierarchy

```rust
// Base trait (unchanged, preserved from v0.15.0)
pub trait SettingsLoader: Sized + DeserializeOwned {
    type Options: LoadingOptions;
    fn load(options: &Self::Options) -> Result<Self>;
}

// NEW: Phase 2 - Source Provenance
pub fn load_with_provenance<T: DeserializeOwned>(
    sources: Vec<ConfigSource>,
) -> Result<(T, SourceMap)>;

pub struct SourceMetadata {
    pub id: String,
    pub source_type: SourceType,
    pub path: Option<PathBuf>,
    pub scope: Option<ConfigScope>,
}

pub struct SourceMap {
    entries: HashMap<String, (SourceMetadata, Value)>,
}

// NEW: Phase 1 - Explicit Layering
pub struct LayerBuilder {
    layers: Vec<(LayerName, ConfigSource)>,
}

// NEW: Phase 4 - Bidirectional Editing
pub struct LayerEditor {
    scope: ConfigScope,
    backend: EditorBackend,
}

// NEW: Phase 3 - Multi-Scope
pub enum ConfigScope {
    System,
    UserGlobal,
    ProjectLocal,
    Runtime,
}

// NEW: Phase 5 - Introspection (Optional)
pub trait SettingsIntrospection {
    fn schema(&self) -> ConfigSchema;
}

pub struct ConfigSchema {
    pub name: String,
    pub settings: Vec<SettingMetadata>,
    pub groups: Vec<SettingGroup>,
}
```

---

## Component Deep Dives

### 0. Source Provenance (NEW)

Tracks which source provided each configuration value. This is the key addition that enables:
- Layer-scoped editing (know which layer to modify)
- Multi-scope path resolution (know which scope each value came from)
- Source visualization (show users "where did this setting come from?")

```rust
pub struct SourceMetadata {
    /// Unique identifier (e.g., "file:config.yml", "env:APP_")
    pub id: String,
    /// Type of source
    pub source_type: SourceType,
    /// Optional file path
    pub path: Option<PathBuf>,
    /// Optional scope (for multi-scope configs)
    pub scope: Option<ConfigScope>,
}

pub enum SourceType {
    Default,
    File,
    Environment,
    Override,
}

pub struct SourceMap {
    /// Maps setting key to its origin
    entries: HashMap<String, (SourceMetadata, Value)>,
}

impl SourceMap {
    pub fn source_of(&self, key: &str) -> Option<&SourceMetadata>;
    pub fn all_from(&self, source_type: SourceType) -> Vec<(&str, Value)>;
}
```

**Usage**:
```rust
let (settings, sources) = load_with_provenance::<AppSettings>()?;

match sources.source_of("database.host") {
    Some(meta) => println!("from {:?}: {:?}", meta.source_type, meta.path),
    None => println!("using default"),
}
```

**How it works**:
1. Each source (File, Environment, etc.) is wrapped with SourceMetadata
2. As config crate merges sources, we track them in parallel
3. Returns both the deserialized struct AND the SourceMap
4. Config crate's merge is completely unchanged
5. Provenance tracking is orthogonal (doesn't interfere with serde)
```

### 1. Layer Editor

Enables editing individual configuration layers without affecting others.

```rust
pub trait LayerEditor: Send + Sync {
    /// Get a value from this layer only
    fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T>;
    
    /// Set a value in this layer
    fn set<T: Serialize>(&mut self, key: &str, value: T) -> Result<()>;
    
    /// Remove a value from this layer (falls through to lower layers)
    fn unset(&mut self, key: &str) -> Result<()>;
    
    /// List keys modified in this layer
    fn keys(&self) -> Vec<String>;
    
    /// Persist changes
    fn save(&self) -> Result<()>;
    
    /// Check if layer has unsaved changes
    fn is_dirty(&self) -> bool;
}
```

#### Format-Specific Implementations

```rust
pub enum EditorBackend {
    /// Uses toml_edit for comment preservation
    Toml(TomlEditor),
    /// Standard serde_json (no comment preservation)
    Json(JsonEditor),
    /// serde_yaml (limited comment preservation)  
    Yaml(YamlEditor),
}

impl EditorBackend {
    pub fn from_path(path: &Path) -> Result<Self> {
        match path.extension().and_then(|e| e.to_str()) {
            Some("toml") => Ok(Self::Toml(TomlEditor::load(path)?)),
            Some("json") => Ok(Self::Json(JsonEditor::load(path)?)),
            Some("yaml" | "yml") => Ok(Self::Yaml(YamlEditor::load(path)?)),
            _ => Err(EditorError::UnsupportedFormat),
        }
    }
}
```

### 2. Configuration Schema

Runtime-accessible schema for validation and UI generation.

```rust
#[derive(Debug, Clone)]
pub struct ConfigSchema {
    pub settings: Vec<SettingMetadata>,
    pub groups: Vec<SettingGroup>,
}

#[derive(Debug, Clone)]
pub struct SettingMetadata {
    pub key: String,
    pub label: String,
    pub description: String,
    pub setting_type: SettingType,
    pub default: Option<serde_json::Value>,
    pub constraints: Vec<Constraint>,
    pub visibility: Visibility,
}

#[derive(Debug, Clone)]
pub enum SettingType {
    String { pattern: Option<String> },
    Integer { min: Option<i64>, max: Option<i64> },
    Float { min: Option<f64>, max: Option<f64> },
    Boolean,
    Enum { variants: Vec<EnumVariant> },
    Array { item_type: Box<SettingType> },
    Duration,
    Path { must_exist: bool },
    Url { schemes: Vec<String> },
    Secret,  // Masked in UIs
}

#[derive(Debug, Clone)]
pub struct SettingGroup {
    pub name: String,
    pub description: String,
    pub settings: Vec<String>,  // Keys in this group
}
```

### 3. Source Map / Provenance

Track where each setting value originated.

```rust
#[derive(Debug, Clone)]
pub struct SourceMap {
    sources: HashMap<String, SettingSource>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingSource {
    Default,
    File { path: PathBuf, scope: ConfigScope },
    Environment { var_name: String },
    CliOverride,
    Computed,  // Derived from other settings
}

impl SourceMap {
    pub fn source_of(&self, key: &str) -> Option<&SettingSource>;
    pub fn all_from_scope(&self, scope: ConfigScope) -> Vec<&str>;
    pub fn overridden_keys(&self) -> Vec<(&str, &SettingSource)>;
}
```

### 4. Multi-Scope Configuration

Standard patterns for user vs. project configuration.

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigScope {
    /// System-wide defaults (read-only typically)
    System,
    /// User's global preferences (~/.config/app/)
    UserGlobal,
    /// Project-specific settings (./app.toml)
    ProjectLocal,
    /// Runtime overrides (env vars, CLI)
    Runtime,
}

/// Configuration for multi-scope resolution
pub trait MultiScopeConfig: Sized {
    /// Application identifier for path resolution
    const APP_NAME: &'static str;
    
    /// Default filename (e.g., "settings.toml")
    const CONFIG_FILENAME: &'static str = "settings.toml";
    
    /// Get platform-appropriate path for scope
    fn path_for_scope(scope: ConfigScope) -> Option<PathBuf> {
        match scope {
            ConfigScope::System => dirs::config_dir()
                .map(|d| d.join(Self::APP_NAME).join(Self::CONFIG_FILENAME)),
            ConfigScope::UserGlobal => dirs::config_dir()
                .map(|d| d.join(Self::APP_NAME).join(Self::CONFIG_FILENAME)),
            ConfigScope::ProjectLocal => Some(PathBuf::from(Self::CONFIG_FILENAME)),
            ConfigScope::Runtime => None,
        }
    }
}
```

---

## Migration Strategy

### Backward Compatibility

All changes are **additive**. Existing code continues to work:

```rust
// v0.15.0 API - still works unchanged
let settings = MySettings::load(&options)?;

// v1.0.0 API - new, opt-in
let (settings, sources) = load_with_provenance::<MySettings>()?;
```

**Preservation of config crate**:
- Config crate remains the bottom layer
- Serde deserialization unchanged
- Multi-source composition unchanged
- All merging precedence unchanged
- New layers wrap above, don't modify below

### Opt-in Features

```toml
[features]
default = []
editor = ["toml_edit"]
schema = []
provenance = []
multi-scope = ["directories"]
full = ["editor", "schema", "provenance", "multi-scope"]
```

### Implementation Order

1. **Add `editor` feature** with `LayerEditor` trait
2. **Add `provenance` feature** with source tracking
3. **Add `schema` feature** with metadata types
4. **Add `multi-scope` feature** with standard paths
5. **Add proc macro** for compile-time schema generation

---

## Example: Integrated Usage

```rust
use settings_loader::{
    SettingsLoader, SettingsEditor, SettingsIntrospection,
    ConfigScope, SettingSource, SourceMap
};

#[derive(Debug, Deserialize, SettingsSchema)]
#[settings(app = "my-app")]
pub struct AppSettings {
    #[setting(
        description = "API endpoint URL",
        default = "http://localhost:8080"
    )]
    pub api_url: String,
    
    #[setting(
        description = "Request timeout in seconds",
        default = 30,
        min = 1, max = 300
    )]
    pub timeout_secs: u64,
    
    #[setting(secret)]
    pub api_key: Option<String>,
}

// Loading with provenance
let (settings, sources) = AppSettings::load_with_provenance(&options)?;

// Check where api_url came from
match sources.source_of("api_url") {
    Some(SettingSource::Environment { var_name }) => 
        println!("api_url from env: {}", var_name),
    Some(SettingSource::File { path, scope }) =>
        println!("api_url from {:?}: {}", scope, path.display()),
    _ => println!("api_url from default"),
}

// Edit project-local settings
let mut editor = AppSettings::editor(ConfigScope::ProjectLocal, &options)?;
editor.set("timeout_secs", 60)?;
editor.save()?;

// Generate UI from schema
for meta in AppSettings::metadata() {
    println!("{}: {} (default: {:?})", 
        meta.key, meta.description, meta.default);
}
```

---

## Comparison with Alternatives

| Feature | settings-loader (proposed) | config-rs | figment |
|---------|---------------------------|-----------|---------|
| Multi-format ||||
| Env overlay ||||
| Serde integration | ✅ (config impl) |||

**Note on config-rs relationship**: settings-loader wraps and extends 
config-rs rather than replacing it. This preserves config-rs's proven 
serde integration while adding source provenance, layer editing, and 
multi-scope support on top.
| Config writing ||||
| Comment preservation | ✅ (TOML) |||
| Source tracking ||||
| Schema/metadata ||||
| Multi-scope ||||
| TUI integration ||||

---

## Next Steps

1. **RFC**: Share this document for feedback
2. **Prototype**: Implement Phase 1 (editor) in a branch
3. **Migrate turtle**: Use turtle as test case for API design
4. **Stabilize**: Iterate based on usage
5. **Release**: Version 1.0 with stable trait hierarchy