Ashiedu's picture
Sync unified workbench
0490201 verified
# T-012: TOML Config System β€” Hot-Reload via `notify`, Typed Config Structs
**Type:** Task | **Phase:** 0 | **Autonomy:** `agent:autonomous` | **Stack:** `stack:rust`
**Version:** v0.1 | **Iteration:** iter-1 | **Effort:** S (half-day)
> ⚠️ **Scope:** Create `config.toml` schema, typed Rust structs, and a hot-reload watcher. Config is read once at startup and re-read when the file changes. No IPC command needed β€” config changes log a message and update shared state. Camera config (`camera-config.toml`) and controller map (`controller-map.toml`) have their own files managed by T-031 and T-023; this task owns the **application** config only.
---
## Prerequisites
- [ ] T-001 merged β€” workspace exists
- [ ] T-011 merged β€” `tracing` initialised (config loader uses `tracing::info!`)
## New Workspace Dependencies
```toml
# Add to [workspace.dependencies]
notify = { version = "*", features = ["macos_kqueue"] }
toml = { version = "*" }
# Add to kansas/Cargo.toml [dependencies]
notify = { workspace = true }
toml = { workspace = true }
```
## Acceptance Criteria
- [ ] `config.toml` created at repo root with all fields from the schema below and sensible defaults
- [ ] `kansas/src/config.rs` β€” `AppConfig` struct, `load()`, `watch()` functions
- [ ] On startup, `AppConfig::load()` reads `config.toml`; missing file uses defaults (does not crash)
- [ ] `notify` watcher fires when `config.toml` is saved; updated config logged at INFO level
- [ ] `AppConfig` stored as `tauri::State<Arc<Mutex<AppConfig>>>` β€” readable from any command handler
- [ ] `cargo check --workspace` β€” zero errors; `cargo clippy -- -D warnings` β€” zero warnings
## `config.toml` Schema
```toml
[audio]
sample_rate = 48000
buffer_frames = 512
enable_input = true
wasapi_exclusive = false # true = lower latency, no other apps share device
[transport]
default_bpm = 128.0
default_time_sig_num = 4
default_time_sig_den = 4
[models]
models_dir = "models" # relative to workspace root
directml_device_id = 0 # GPU index for DirectML
[rerun]
enabled = false # set true to enable telemetry sink (T-121)
address = "127.0.0.1:9876"
[ui]
theme = "dark"
window_width = 1280
window_height = 800
```
## `kansas/src/config.rs`
```rust
use std::{
path::Path,
sync::{Arc, Mutex},
time::Duration,
};
use notify::{Event, RecursiveMode, Watcher};
use serde::Deserialize;
const CONFIG_PATH: &str = "config.toml";
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub audio: AudioConfig,
pub transport: TransportConfig,
pub models: ModelsConfig,
pub rerun: RerunConfig,
pub ui: UiConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AudioConfig {
pub sample_rate: u32,
pub buffer_frames: u32,
pub enable_input: bool,
pub wasapi_exclusive: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TransportConfig {
pub default_bpm: f32,
pub default_time_sig_num: u8,
pub default_time_sig_den: u8,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ModelsConfig {
pub models_dir: String,
pub directml_device_id: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct RerunConfig {
pub enabled: bool,
pub address: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct UiConfig {
pub theme: String,
pub window_width: u32,
pub window_height: u32,
}
impl Default for AppConfig {
fn default() -> Self {
toml::from_str("").unwrap_or_else(|_| Self {
audio: AudioConfig::default(),
transport: TransportConfig::default(),
models: ModelsConfig::default(),
rerun: RerunConfig::default(),
ui: UiConfig::default(),
})
}
}
impl Default for AudioConfig {
fn default() -> Self { Self { sample_rate: 48_000, buffer_frames: 512, enable_input: true, wasapi_exclusive: false } }
}
impl Default for TransportConfig {
fn default() -> Self { Self { default_bpm: 128.0, default_time_sig_num: 4, default_time_sig_den: 4 } }
}
impl Default for ModelsConfig {
fn default() -> Self { Self { models_dir: "models".into(), directml_device_id: 0 } }
}
impl Default for RerunConfig {
fn default() -> Self { Self { enabled: false, address: "127.0.0.1:9876".into() } }
}
impl Default for UiConfig {
fn default() -> Self { Self { theme: "dark".into(), window_width: 1280, window_height: 800 } }
}
pub fn load() -> AppConfig {
match std::fs::read_to_string(CONFIG_PATH) {
Ok(text) => toml::from_str(&text).unwrap_or_else(|e| {
tracing::warn!("[config] Parse error, using defaults: {e}");
AppConfig::default()
}),
Err(_) => {
tracing::info!("[config] config.toml not found β€” using defaults");
AppConfig::default()
}
}
}
/// Start a background watcher that reloads config on file change.
pub fn watch(config: Arc<Mutex<AppConfig>>) {
std::thread::Builder::new()
.name("synesthesia-config-watcher".into())
.spawn(move || {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
if let Ok(event) = res { let _ = tx.send(event); }
}).expect("config watcher init failed");
watcher.watch(Path::new(CONFIG_PATH), RecursiveMode::NonRecursive).ok();
tracing::info!("[config] Watching {CONFIG_PATH} for changes");
for _event in rx {
let updated = load();
tracing::info!("[config] Reloaded β€” bpm={}", updated.transport.default_bpm);
if let Ok(mut cfg) = config.lock() { *cfg = updated; }
}
})
.ok();
}
```
Register in `main.rs`:
```rust
use std::sync::{Arc, Mutex};
mod config;
fn main() {
logging::setup();
let cfg = Arc::new(Mutex::new(config::load()));
config::watch(Arc::clone(&cfg));
tauri::Builder::default()
.manage(cfg)
// ...
```
---
## GitHub CLI
```bash
gh issue create \
--title "T-012: TOML config system β€” hot-reload via notify, typed config structs" \
--label "type:task,stack:rust,agent:autonomous,priority:high,status:ready,day:1" \
--body-file T-012.md
```
**Parent:** GENESIS | **Blocks:** T-031 (camera config), T-023 (controller-map) | **Blocked By:** T-001, T-011