From b8f084b1e1c3107bf58bb55c232c1d08131def9c Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 24 Jun 2026 12:41:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(settings):=20Settings1=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=20+=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0=D1=80=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=81=D1=82=D0=BE=D1=80=20+=20seed=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store (load_or_seed/get/set/reset/list, durable-write) + SettingsService #[interface] + bin. v0: строковые значения (variant на проводе), сам сеет дефолты. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Alexander --- Cargo.lock | 34 +++++ Cargo.toml | 1 + crates/core/shturman-settings/Cargo.toml | 18 +++ crates/core/shturman-settings/src/lib.rs | 7 + crates/core/shturman-settings/src/main.rs | 19 +++ crates/core/shturman-settings/src/service.rs | 78 ++++++++++ crates/core/shturman-settings/src/store.rs | 142 +++++++++++++++++++ 7 files changed, 299 insertions(+) create mode 100644 crates/core/shturman-settings/Cargo.toml create mode 100644 crates/core/shturman-settings/src/lib.rs create mode 100644 crates/core/shturman-settings/src/main.rs create mode 100644 crates/core/shturman-settings/src/service.rs create mode 100644 crates/core/shturman-settings/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index 08d82f6..3ab4f4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 46ca484..deb06e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/shturman-ipc", "crates/shturman-sdk", "crates/core/shturman-firstboot", + "crates/core/shturman-settings", ] [workspace.package] diff --git a/crates/core/shturman-settings/Cargo.toml b/crates/core/shturman-settings/Cargo.toml new file mode 100644 index 0000000..a54171b --- /dev/null +++ b/crates/core/shturman-settings/Cargo.toml @@ -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" } diff --git a/crates/core/shturman-settings/src/lib.rs b/crates/core/shturman-settings/src/lib.rs new file mode 100644 index 0000000..02aba17 --- /dev/null +++ b/crates/core/shturman-settings/src/lib.rs @@ -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; diff --git a/crates/core/shturman-settings/src/main.rs b/crates/core/shturman-settings/src/main.rs new file mode 100644 index 0000000..c3bf86c --- /dev/null +++ b/crates/core/shturman-settings/src/main.rs @@ -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(()) +} diff --git a/crates/core/shturman-settings/src/service.rs b/crates/core/shturman-settings/src/service.rs new file mode 100644 index 0000000..afff53f --- /dev/null +++ b/crates/core/shturman-settings/src/service.rs @@ -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>, +} + +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 { + 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 { + 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<()>; +} diff --git a/crates/core/shturman-settings/src/store.rs b/crates/core/shturman-settings/src/store.rs new file mode 100644 index 0000000..ae4b019 --- /dev/null +++ b/crates/core/shturman-settings/src/store.rs @@ -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 { + DEFAULTS + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn serialize(map: &BTreeMap) -> String { + serde_json::to_string_pretty(map).unwrap_or_else(|_| "{}".to_string()) +} + +/// Key-value стор настроек (строки v0). +pub struct Store { + layout: Layout, + map: BTreeMap, +} + +impl Store { + /// Загрузить из `settings.json`; если файла нет — посеять дефолты (durable-write). + /// Битый JSON → дефолты в памяти (перезапишутся при следующем `set`). + pub fn load_or_seed(layout: Layout) -> io::Result { + 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> { + 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 { + 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); + } +}