Skip to main content

omgbase_sync/
settings.rs

1//! Settings (`spec/sync/README.md` §3): one schema at two layers — the
2//! `workspace_settings` singleton (defaults) and `repos.settings`
3//! (overrides) — resolved by a deep merge.
4
5use omgbase_store::Store;
6use rusqlite::{OptionalExtension, params};
7use serde_json::{Map, Value};
8
9use crate::error::Result;
10
11/// A settings blob: a JSON object.
12pub type Settings = Map<String, Value>;
13
14/// Unparsable or non-object text reads as `{}`.
15fn parse(text: Option<String>) -> Settings {
16    match text.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
17        Some(Value::Object(m)) => m,
18        _ => Settings::new(),
19    }
20}
21
22/// The workspace default layer (row `id = 0`).
23pub fn workspace_settings(store: &Store) -> Result<Settings> {
24    let text: Option<String> = store
25        .conn()
26        .query_row(
27            "SELECT settings FROM workspace_settings WHERE id = 0",
28            [],
29            |r| r.get(0),
30        )
31        .optional()?;
32    Ok(parse(text))
33}
34
35/// A repo's own (override) blob — not merged.
36pub fn repo_own_settings(store: &Store, repo_id: &str) -> Result<Settings> {
37    let text: Option<String> = store
38        .conn()
39        .query_row(
40            "SELECT settings FROM repos WHERE repo_id = ?1",
41            params![repo_id],
42            |r| r.get(0),
43        )
44        .optional()?;
45    Ok(parse(text))
46}
47
48/// Replace the workspace blob.
49pub fn write_workspace_settings(store: &Store, settings: &Settings) -> Result<()> {
50    store.conn().execute(
51        "INSERT INTO workspace_settings (id, settings) VALUES (0, ?1)
52         ON CONFLICT(id) DO UPDATE SET settings = excluded.settings",
53        params![Value::Object(settings.clone()).to_string()],
54    )?;
55    Ok(())
56}
57
58/// Replace a repo's blob.
59pub fn write_repo_settings(store: &Store, repo_id: &str, settings: &Settings) -> Result<()> {
60    store.conn().execute(
61        "UPDATE repos SET settings = ?1 WHERE repo_id = ?2",
62        params![Value::Object(settings.clone()).to_string(), repo_id],
63    )?;
64    Ok(())
65}
66
67/// `over` wins at the leaf; a plain object over a plain object merges
68/// recursively; anything else (scalars, arrays, `null`) replaces wholesale.
69/// Key order: `base`'s keys in place, new keys appended (JavaScript's
70/// `{ ...base }` then assignment).
71#[must_use]
72pub fn deep_merge(base: &Settings, over: &Settings) -> Settings {
73    let mut out = base.clone();
74    for (k, v) in over {
75        let merged = match (out.get(k), v) {
76            (Some(Value::Object(b)), Value::Object(o)) => Value::Object(deep_merge(b, o)),
77            _ => v.clone(),
78        };
79        out.insert(k.clone(), merged);
80    }
81    out
82}
83
84/// `deep_merge(workspace, repo_own)`; the workspace layer alone for `None`.
85pub fn resolve_settings(store: &Store, repo_id: Option<&str>) -> Result<Settings> {
86    let defaults = workspace_settings(store)?;
87    match repo_id {
88        None => Ok(defaults),
89        Some(id) => Ok(deep_merge(&defaults, &repo_own_settings(store, id)?)),
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use serde_json::json;
97
98    fn obj(v: Value) -> Settings {
99        v.as_object().cloned().unwrap()
100    }
101
102    #[test]
103    fn deep_merge_rules() {
104        let base = obj(json!({"a": 1, "e": {"m": "x", "d": 768}, "arr": [1, 2], "keep": true}));
105        let over = obj(json!({"e": {"d": 384, "new": 1}, "arr": [3], "a": null, "z": {"q": 1}}));
106        let merged = deep_merge(&base, &over);
107        assert_eq!(
108            Value::Object(merged.clone()),
109            json!({"a": null, "e": {"m": "x", "d": 384, "new": 1}, "arr": [3], "keep": true, "z": {"q": 1}})
110        );
111        assert_eq!(
112            merged.keys().collect::<Vec<_>>(),
113            ["a", "e", "arr", "keep", "z"],
114            "base order kept, new keys appended"
115        );
116        // A scalar over an object and an object over a scalar both replace.
117        assert_eq!(
118            Value::Object(deep_merge(
119                &obj(json!({"x": {"a": 1}})),
120                &obj(json!({"x": 2}))
121            )),
122            json!({"x": 2})
123        );
124        assert_eq!(
125            Value::Object(deep_merge(
126                &obj(json!({"x": 2})),
127                &obj(json!({"x": {"a": 1}}))
128            )),
129            json!({"x": {"a": 1}})
130        );
131        assert_eq!(
132            deep_merge(&Settings::new(), &Settings::new()),
133            Settings::new()
134        );
135    }
136
137    #[test]
138    fn layers_round_trip_and_bad_text_reads_as_empty() {
139        let mut s = Store::open_in_memory().unwrap();
140        let repo = s.create_repo("r").unwrap();
141        assert!(workspace_settings(&s).unwrap().is_empty());
142        assert!(repo_own_settings(&s, &repo).unwrap().is_empty());
143        write_workspace_settings(&s, &obj(json!({"embedding": {"model": "a", "dim": 1}}))).unwrap();
144        write_repo_settings(&s, &repo, &obj(json!({"embedding": {"dim": 2}}))).unwrap();
145        assert_eq!(
146            Value::Object(resolve_settings(&s, Some(&repo)).unwrap()),
147            json!({"embedding": {"model": "a", "dim": 2}})
148        );
149        assert_eq!(
150            Value::Object(resolve_settings(&s, None).unwrap()),
151            json!({"embedding": {"model": "a", "dim": 1}})
152        );
153        assert!(
154            resolve_settings(&s, Some("rp_nope"))
155                .unwrap()
156                .contains_key("embedding")
157        );
158        s.conn()
159            .execute("UPDATE workspace_settings SET settings = 'not json'", [])
160            .unwrap();
161        assert!(workspace_settings(&s).unwrap().is_empty());
162        s.conn()
163            .execute("UPDATE workspace_settings SET settings = '[1]'", [])
164            .unwrap();
165        assert!(workspace_settings(&s).unwrap().is_empty());
166        write_workspace_settings(&s, &obj(json!({"k": 1}))).unwrap();
167        assert_eq!(workspace_settings(&s).unwrap(), obj(json!({"k": 1})));
168    }
169}