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:
2026-06-24 12:41:44 +03:00
parent b7a76d78f6
commit b8f084b1e1
7 changed files with 299 additions and 0 deletions
Generated
+34
View File
@@ -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"
+1
View File
@@ -7,6 +7,7 @@ members = [
"crates/shturman-ipc",
"crates/shturman-sdk",
"crates/core/shturman-firstboot",
"crates/core/shturman-settings",
]
[workspace.package]
+18
View File
@@ -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" }
+7
View File
@@ -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;
+19
View File
@@ -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<()>;
}
+142
View File
@@ -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);
}
}