feat(settings): Settings1 стаб + атомарный стор + seed дефолтов
Store (load_or_seed/get/set/reset/list, durable-write) + SettingsService #[interface] + bin. v0: строковые значения (variant на проводе), сам сеет дефолты. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Alexander <akotenev2003@gmail.com>
This commit is contained in:
Generated
+34
@@ -715,6 +715,19 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -798,6 +811,21 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shturman-settings"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
"shturman-common",
|
||||
"shturman-ipc",
|
||||
"shturman-sdk",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -1251,6 +1279,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.2.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"crates/shturman-ipc",
|
||||
"crates/shturman-sdk",
|
||||
"crates/core/shturman-firstboot",
|
||||
"crates/core/shturman-settings",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "shturman-settings"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shturman-ipc = { path = "../../shturman-ipc" }
|
||||
shturman-common = { path = "../../shturman-common" }
|
||||
zbus.workspace = true
|
||||
tokio.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
shturman-sdk = { path = "../../shturman-sdk" }
|
||||
@@ -0,0 +1,7 @@
|
||||
//! `ru.shturman.Settings1` — стаб конфигурации (домен Settings/State, architecture §3).
|
||||
|
||||
pub mod service;
|
||||
pub mod store;
|
||||
|
||||
pub use service::SettingsService;
|
||||
pub use store::Store;
|
||||
@@ -0,0 +1,19 @@
|
||||
//! `ru.shturman.Settings1` — сервис. На шину выводит systemd (План 5); порядок — после `data.mount`/firstboot.
|
||||
|
||||
use shturman_common::{init_tracing, Layout};
|
||||
use shturman_ipc::{connect, names};
|
||||
use shturman_settings::{store::Store, SettingsService};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
init_tracing("shturman-settings");
|
||||
let store = Store::load_or_seed(Layout::from_env())?;
|
||||
let conn = connect().await?;
|
||||
conn.object_server()
|
||||
.at(names::settings::PATH, SettingsService::new(store))
|
||||
.await?;
|
||||
conn.request_name(names::settings::NAME).await?;
|
||||
tracing::info!("ru.shturman.Settings1 на шине");
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! `ru.shturman.Settings1` — server-стаб. Хранит строки (v0); namespace-изоляция по `sender` — позже (v3).
|
||||
|
||||
use crate::store::Store;
|
||||
use shturman_ipc::Error;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use zbus::interface;
|
||||
use zbus::object_server::SignalContext;
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
pub struct SettingsService {
|
||||
store: Arc<Mutex<Store>>,
|
||||
}
|
||||
|
||||
impl SettingsService {
|
||||
pub fn new(store: Store) -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(store)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[interface(name = "ru.shturman.Settings1")]
|
||||
impl SettingsService {
|
||||
async fn get(&self, key: &str) -> Result<OwnedValue, Error> {
|
||||
let store = self.store.lock().await;
|
||||
match store.get(key) {
|
||||
Some(v) => OwnedValue::try_from(Value::from(v.to_string()))
|
||||
.map_err(|e| Error::InvalidArgument(format!("value convert: {e}"))),
|
||||
None => Err(Error::InvalidArgument(format!("unknown key: {key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
key: String,
|
||||
value: Value<'_>,
|
||||
#[zbus(signal_context)] ctx: SignalContext<'_>,
|
||||
) -> Result<(), Error> {
|
||||
let s = match value {
|
||||
Value::Str(s) => s.as_str().to_string(),
|
||||
_ => {
|
||||
return Err(Error::InvalidArgument(
|
||||
"v0 поддерживает только строковые значения".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
self.store
|
||||
.lock()
|
||||
.await
|
||||
.set(&key, &s)
|
||||
.map_err(|e| Error::InvalidArgument(format!("persist: {e}")))?;
|
||||
let _ = Self::changed(&ctx, &key, &Value::from(s)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list(&self, prefix: &str) -> Vec<String> {
|
||||
self.store.lock().await.list(prefix)
|
||||
}
|
||||
|
||||
async fn reset(
|
||||
&self,
|
||||
key: String,
|
||||
#[zbus(signal_context)] ctx: SignalContext<'_>,
|
||||
) -> Result<(), Error> {
|
||||
let new = self
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.reset(&key)
|
||||
.map_err(|e| Error::InvalidArgument(format!("persist: {e}")))?;
|
||||
let _ = Self::changed(&ctx, &key, &Value::from(new.unwrap_or_default())).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[zbus(signal)]
|
||||
async fn changed(ctx: &SignalContext<'_>, key: &str, value: &Value<'_>) -> zbus::Result<()>;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Стор настроек: `/data/settings/settings.json` (строковые значения в v0), durable-write.
|
||||
//! Единственный писатель поддерева `/data/settings/` (architecture §3).
|
||||
|
||||
use shturman_common::{write_atomic, Layout};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
|
||||
/// Дефолты v0 (data-model §2: канон единиц; конвертация — на презентации).
|
||||
const DEFAULTS: &[(&str, &str)] = &[("ui.theme", "auto"), ("ui.units", "metric")];
|
||||
|
||||
fn defaults_map() -> BTreeMap<String, String> {
|
||||
DEFAULTS
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn serialize(map: &BTreeMap<String, String>) -> String {
|
||||
serde_json::to_string_pretty(map).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// Key-value стор настроек (строки v0).
|
||||
pub struct Store {
|
||||
layout: Layout,
|
||||
map: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Загрузить из `settings.json`; если файла нет — посеять дефолты (durable-write).
|
||||
/// Битый JSON → дефолты в памяти (перезапишутся при следующем `set`).
|
||||
pub fn load_or_seed(layout: Layout) -> io::Result<Self> {
|
||||
std::fs::create_dir_all(layout.settings())?;
|
||||
let path = layout.settings().join("settings.json");
|
||||
let map = if path.exists() {
|
||||
serde_json::from_str(&std::fs::read_to_string(&path)?)
|
||||
.unwrap_or_else(|_| defaults_map())
|
||||
} else {
|
||||
let d = defaults_map();
|
||||
write_atomic(&path, serialize(&d).as_bytes())?;
|
||||
d
|
||||
};
|
||||
Ok(Self { layout, map })
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: &str, value: &str) -> io::Result<()> {
|
||||
self.map.insert(key.to_string(), value.to_string());
|
||||
self.persist()
|
||||
}
|
||||
|
||||
/// Сбросить ключ к дефолту (если есть) или удалить. Возвращает значение после сброса (для `Changed`).
|
||||
pub fn reset(&mut self, key: &str) -> io::Result<Option<String>> {
|
||||
let default = DEFAULTS
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v.to_string());
|
||||
match &default {
|
||||
Some(v) => {
|
||||
self.map.insert(key.to_string(), v.clone());
|
||||
}
|
||||
None => {
|
||||
self.map.remove(key);
|
||||
}
|
||||
}
|
||||
self.persist()?;
|
||||
Ok(self.map.get(key).cloned())
|
||||
}
|
||||
|
||||
pub fn list(&self, prefix: &str) -> Vec<String> {
|
||||
self.map
|
||||
.keys()
|
||||
.filter(|k| k.starts_with(prefix))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn persist(&self) -> io::Result<()> {
|
||||
let path = self.layout.settings().join("settings.json");
|
||||
write_atomic(&path, serialize(&self.map).as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shturman_common::Layout;
|
||||
|
||||
fn layout() -> (tempfile::TempDir, Layout) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let l = Layout::new(d.path());
|
||||
(d, l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_defaults_when_empty() {
|
||||
let (_d, l) = layout();
|
||||
let s = Store::load_or_seed(l.clone()).unwrap();
|
||||
assert_eq!(s.get("ui.theme"), Some("auto"));
|
||||
assert_eq!(s.get("ui.units"), Some("metric"));
|
||||
assert!(l.settings().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_get_persists_across_reload() {
|
||||
let (_d, l) = layout();
|
||||
{
|
||||
let mut s = Store::load_or_seed(l.clone()).unwrap();
|
||||
s.set("ui.theme", "night").unwrap();
|
||||
}
|
||||
let s2 = Store::load_or_seed(l.clone()).unwrap();
|
||||
assert_eq!(s2.get("ui.theme"), Some("night"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restores_default() {
|
||||
let (_d, l) = layout();
|
||||
let mut s = Store::load_or_seed(l).unwrap();
|
||||
s.set("ui.theme", "night").unwrap();
|
||||
s.reset("ui.theme").unwrap();
|
||||
assert_eq!(s.get("ui.theme"), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_prefix() {
|
||||
let (_d, l) = layout();
|
||||
let mut s = Store::load_or_seed(l).unwrap();
|
||||
s.set("net.proxy", "x").unwrap();
|
||||
let ui = s.list("ui.");
|
||||
assert!(ui.contains(&"ui.theme".to_string()));
|
||||
assert!(!ui.contains(&"net.proxy".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_is_none() {
|
||||
let (_d, l) = layout();
|
||||
let s = Store::load_or_seed(l).unwrap();
|
||||
assert_eq!(s.get("nope"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user