Skip to main content

omgbase_sync/
registry.rs

1//! The source registry (`spec/sync/README.md` §2): `adapters`, `sources`,
2//! `attachments` (`sync_state` is reserved — only [`delete_source`] touches
3//! it), `ensure_repo` with the `<slug>-fs` source, and the config → argv
4//! rendering an adapter is spawned with.
5
6use std::collections::BTreeMap;
7
8use omgbase_store::Store;
9use rusqlite::{OptionalExtension, params};
10use serde_json::{Map, Value};
11
12use crate::error::Result;
13
14/// The `fs` adapter as `ensure_repo` registers it.
15pub const FS_ADAPTER: &str = "fs";
16/// Its command.
17pub const FS_ADAPTER_COMMAND: &str = "omgbase-fs-adapter";
18
19/// An `adapters` row.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct AdapterRow {
22    pub name: String,
23    pub command: String,
24    pub args: Vec<String>,
25}
26
27/// A `sources` row with its JSON columns parsed.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct SourceRow {
30    pub source_id: String,
31    pub name: String,
32    pub adapter: String,
33    pub config: Map<String, Value>,
34    /// `env` values are used literally (§9: no `$VAR` indirection).
35    pub env: BTreeMap<String, String>,
36}
37
38/// Insert or update an adapter by name.
39pub fn ensure_adapter(store: &Store, name: &str, command: &str, args: &[String]) -> Result<()> {
40    store.conn().execute(
41        "INSERT INTO adapters (name, command, args) VALUES (?1, ?2, ?3)
42         ON CONFLICT(name) DO UPDATE SET command = excluded.command, args = excluded.args",
43        params![name, command, Value::from(args.to_vec()).to_string()],
44    )?;
45    Ok(())
46}
47
48/// Every adapter, by name.
49pub fn list_adapters(store: &Store) -> Result<Vec<AdapterRow>> {
50    let mut stmt = store
51        .conn()
52        .prepare("SELECT name, command, args FROM adapters ORDER BY name")?;
53    let rows = stmt.query_map([], |r| {
54        Ok((
55            r.get::<_, String>(0)?,
56            r.get::<_, String>(1)?,
57            r.get::<_, String>(2)?,
58        ))
59    })?;
60    let mut out = Vec::new();
61    for row in rows {
62        let (name, command, args) = row?;
63        let args: Vec<String> = serde_json::from_str(&args)?;
64        out.push(AdapterRow {
65            name,
66            command,
67            args,
68        });
69    }
70    Ok(out)
71}
72
73/// What [`create_source`] takes.
74#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct NewSource<'a> {
76    pub name: &'a str,
77    pub adapter: &'a str,
78    pub config: Option<&'a Map<String, Value>>,
79    pub env: Option<&'a BTreeMap<String, String>>,
80}
81
82/// Create a named source over an adapter: **mints `src`**; a taken name or
83/// an unknown adapter fails (UNIQUE / FK). Returns the id.
84pub fn create_source(store: &mut Store, spec: &NewSource<'_>) -> Result<String> {
85    let source_id = store.mint("src");
86    let config = spec
87        .config
88        .map_or_else(|| "{}".to_owned(), |c| Value::Object(c.clone()).to_string());
89    let env = spec.env.map_or_else(
90        || "{}".to_owned(),
91        |e| {
92            Value::Object(
93                e.iter()
94                    .map(|(k, v)| (k.clone(), Value::String(v.clone())))
95                    .collect(),
96            )
97            .to_string()
98        },
99    );
100    store.conn().execute(
101        "INSERT INTO sources (source_id, name, adapter, config, env) VALUES (?1, ?2, ?3, ?4, ?5)",
102        params![source_id, spec.name, spec.adapter, config, env],
103    )?;
104    Ok(source_id)
105}
106
107/// Delete a source: its attachments, its `sync_state` rows, the row.
108pub fn delete_source(store: &Store, source_id: &str) -> Result<()> {
109    let conn = store.conn();
110    conn.execute(
111        "DELETE FROM attachments WHERE source_id = ?1",
112        params![source_id],
113    )?;
114    conn.execute(
115        "DELETE FROM sync_state WHERE source_id = ?1",
116        params![source_id],
117    )?;
118    conn.execute(
119        "DELETE FROM sources WHERE source_id = ?1",
120        params![source_id],
121    )?;
122    Ok(())
123}
124
125fn source_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<(String, String, String, String, String)> {
126    Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
127}
128
129fn parse_source(row: (String, String, String, String, String)) -> Result<SourceRow> {
130    let (source_id, name, adapter, config, env) = row;
131    let config: Value = serde_json::from_str(&config)?;
132    let env: Value = serde_json::from_str(&env)?;
133    let config = match config {
134        Value::Object(m) => m,
135        _ => Map::new(),
136    };
137    let env = match env {
138        Value::Object(m) => m
139            .into_iter()
140            .map(|(k, v)| {
141                let s = match v {
142                    Value::String(s) => s,
143                    other => other.to_string(),
144                };
145                (k, s)
146            })
147            .collect(),
148        _ => BTreeMap::new(),
149    };
150    Ok(SourceRow {
151        source_id,
152        name,
153        adapter,
154        config,
155        env,
156    })
157}
158
159const SOURCE_COLUMNS: &str = "source_id, name, adapter, config, env";
160
161/// Every source, by name.
162pub fn list_sources(store: &Store) -> Result<Vec<SourceRow>> {
163    let mut stmt = store.conn().prepare(&format!(
164        "SELECT {SOURCE_COLUMNS} FROM sources ORDER BY name"
165    ))?;
166    let rows = stmt.query_map([], source_row)?;
167    rows.map(|r| parse_source(r?)).collect()
168}
169
170/// The source named `name`, if any.
171pub fn source_by_name(store: &Store, name: &str) -> Result<Option<SourceRow>> {
172    let row = store
173        .conn()
174        .query_row(
175            &format!("SELECT {SOURCE_COLUMNS} FROM sources WHERE name = ?1"),
176            params![name],
177            source_row,
178        )
179        .optional()?;
180    row.map(parse_source).transpose()
181}
182
183/// The source with `source_id`, if any.
184pub fn source_by_id(store: &Store, source_id: &str) -> Result<Option<SourceRow>> {
185    let row = store
186        .conn()
187        .query_row(
188            &format!("SELECT {SOURCE_COLUMNS} FROM sources WHERE source_id = ?1"),
189            params![source_id],
190            source_row,
191        )
192        .optional()?;
193    row.map(parse_source).transpose()
194}
195
196/// Attach a source to a repo (`INSERT OR IGNORE`).
197pub fn attach(store: &Store, repo_id: &str, source_id: &str) -> Result<()> {
198    store.conn().execute(
199        "INSERT OR IGNORE INTO attachments (repo_id, source_id) VALUES (?1, ?2)",
200        params![repo_id, source_id],
201    )?;
202    Ok(())
203}
204
205/// Detach a source from a repo.
206pub fn detach(store: &Store, repo_id: &str, source_id: &str) -> Result<()> {
207    store.conn().execute(
208        "DELETE FROM attachments WHERE repo_id = ?1 AND source_id = ?2",
209        params![repo_id, source_id],
210    )?;
211    Ok(())
212}
213
214/// The sources attached to a repo, ordered by `name`.
215pub fn sources_for_repo(store: &Store, repo_id: &str) -> Result<Vec<SourceRow>> {
216    let mut stmt = store.conn().prepare(&format!(
217        "SELECT s.{} FROM sources s JOIN attachments a ON a.source_id = s.source_id
218         WHERE a.repo_id = ?1 ORDER BY s.name",
219        SOURCE_COLUMNS.replace(", ", ", s.")
220    ))?;
221    let rows = stmt.query_map(params![repo_id], source_row)?;
222    rows.map(|r| parse_source(r?)).collect()
223}
224
225/// §2 `ensure_repo`: an existing slug returns its id; else **mint `rp`**,
226/// insert with default settings, and with a root register the filesystem
227/// source — `INSERT OR IGNORE` the `fs` adapter, find or create (**mint
228/// `src`**) `<slug>-fs` with `config {"root": <root>}`, attach it.
229pub fn ensure_repo(store: &mut Store, slug: &str, root_path: Option<&str>) -> Result<String> {
230    if let Some(id) = store.repo_by_slug(slug)? {
231        return Ok(id);
232    }
233    let repo_id = store.create_repo(slug)?;
234    if let Some(root) = root_path.filter(|r| !r.is_empty()) {
235        register_fs_source(store, &repo_id, slug, root)?;
236    }
237    Ok(repo_id)
238}
239
240/// The `<slug>-fs` source at `root`, registered idempotently and attached.
241pub fn register_fs_source(
242    store: &mut Store,
243    repo_id: &str,
244    slug: &str,
245    root: &str,
246) -> Result<String> {
247    store.conn().execute(
248        "INSERT OR IGNORE INTO adapters (name, command, args) VALUES (?1, ?2, '[]')",
249        params![FS_ADAPTER, FS_ADAPTER_COMMAND],
250    )?;
251    let name = format!("{slug}-fs");
252    let existing: Option<String> = store
253        .conn()
254        .query_row(
255            "SELECT source_id FROM sources WHERE name = ?1",
256            params![name],
257            |r| r.get(0),
258        )
259        .optional()?;
260    let source_id = match existing {
261        Some(id) => id,
262        None => {
263            let id = store.mint("src");
264            let config = serde_json::json!({ "root": root }).to_string();
265            store.conn().execute(
266                "INSERT INTO sources (source_id, name, adapter, config, env) VALUES (?1, ?2, ?3, ?4, '{}')",
267                params![id, name, FS_ADAPTER, config],
268            )?;
269            id
270        }
271    };
272    attach(store, repo_id, &source_id)?;
273    Ok(source_id)
274}
275
276/// JavaScript `Number.prototype.toString()` for a finite double.
277fn js_number(n: f64) -> String {
278    if n == 0.0 {
279        return "0".to_owned();
280    }
281    if n.is_nan() {
282        return "NaN".to_owned();
283    }
284    if n.is_infinite() {
285        return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_owned();
286    }
287    let neg = n < 0.0;
288    let m = n.abs();
289    // Shortest round-trip digits and the decimal exponent, from Rust's `{:e}`.
290    let sci = format!("{m:e}");
291    let (mantissa, exp) = sci.split_once('e').expect("{:e} has an exponent");
292    let exp: i32 = exp.parse().expect("integer exponent");
293    let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
294    let k = digits.len() as i32;
295    let n_exp = exp + 1; // JS's `n`: the position of the decimal point
296    let body = if k <= n_exp && n_exp <= 21 {
297        format!("{digits}{}", "0".repeat((n_exp - k) as usize))
298    } else if 0 < n_exp && n_exp <= 21 {
299        let (a, b) = digits.split_at(n_exp as usize);
300        format!("{a}.{b}")
301    } else if -6 < n_exp && n_exp <= 0 {
302        format!("0.{}{digits}", "0".repeat((-n_exp) as usize))
303    } else {
304        let e = n_exp - 1;
305        let sign = if e < 0 { "-" } else { "+" };
306        let (first, rest) = digits.split_at(1);
307        if rest.is_empty() {
308            format!("{first}e{sign}{}", e.abs())
309        } else {
310            format!("{first}.{rest}e{sign}{}", e.abs())
311        }
312    };
313    if neg { format!("-{body}") } else { body }
314}
315
316/// JavaScript `String(value)` for a JSON value: strings as is, numbers as JS
317/// prints them, booleans, `null` → `"null"`, arrays comma-joined (with
318/// `null` elements empty, as `Array.prototype.toString`), objects
319/// `[object Object]`.
320#[must_use]
321pub fn js_string(v: &Value) -> String {
322    match v {
323        Value::Null => "null".to_owned(),
324        Value::Bool(b) => b.to_string(),
325        Value::Number(n) => js_number(n.as_f64().unwrap_or(f64::NAN)),
326        Value::String(s) => s.clone(),
327        Value::Array(items) => items
328            .iter()
329            .map(|it| match it {
330                Value::Null => String::new(),
331                other => js_string(other),
332            })
333            .collect::<Vec<_>>()
334            .join(","),
335        Value::Object(_) => "[object Object]".to_owned(),
336    }
337}
338
339/// §2 `render_config_flags`: for each entry in the object's order, skip
340/// `null`; `true` → `--key`; `false` → nothing; else `--key`,
341/// `String(value)`.
342#[must_use]
343pub fn render_config_flags(config: &Map<String, Value>) -> Vec<String> {
344    let mut out = Vec::new();
345    for (key, value) in config {
346        match value {
347            Value::Null => {}
348            Value::Bool(true) => out.push(format!("--{key}")),
349            Value::Bool(false) => {}
350            other => {
351                out.push(format!("--{key}"));
352                out.push(js_string(other));
353            }
354        }
355    }
356    out
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use omgbase_store::SequentialMinter;
363    use serde_json::json;
364
365    fn store() -> Store {
366        Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap()
367    }
368
369    fn obj(v: Value) -> Map<String, Value> {
370        v.as_object().cloned().unwrap()
371    }
372
373    #[test]
374    fn js_numbers_print_like_javascript() {
375        for (n, want) in [
376            (1.0, "1"),
377            (-1.0, "-1"),
378            (0.0, "0"),
379            (1.5, "1.5"),
380            (100.0, "100"),
381            (0.1, "0.1"),
382            (1e21, "1e+21"),
383            (1e20, "100000000000000000000"),
384            (1.5e-7, "1.5e-7"),
385            (0.000001, "0.000001"),
386            (0.0000001, "1e-7"),
387            (123456789012.0, "123456789012"),
388            (1.7976931348623157e308, "1.7976931348623157e+308"),
389            (5e-324, "5e-324"),
390            (0.30000000000000004, "0.30000000000000004"),
391        ] {
392            assert_eq!(js_number(n), want, "{n}");
393        }
394        assert_eq!(js_string(&json!(750)), "750");
395        assert_eq!(js_string(&json!([1, "a", null, true])), "1,a,,true");
396        assert_eq!(js_string(&json!({"a": 1})), "[object Object]");
397        assert_eq!(js_string(&json!(null)), "null");
398    }
399
400    #[test]
401    fn render_flags_follow_the_reference() {
402        let flags = render_config_flags(&obj(json!({
403            "root": "/data/v", "debounce": 750, "verbose": true, "quiet": false,
404            "skip": null, "ext": [".md", ".txt"], "nested": {"a": 1}, "ratio": 0.5
405        })));
406        assert_eq!(
407            flags,
408            vec![
409                "--root",
410                "/data/v",
411                "--debounce",
412                "750",
413                "--verbose",
414                "--ext",
415                ".md,.txt",
416                "--nested",
417                "[object Object]",
418                "--ratio",
419                "0.5"
420            ]
421        );
422        assert!(render_config_flags(&Map::new()).is_empty());
423    }
424
425    #[test]
426    fn registry_round_trip() {
427        let mut s = store();
428        ensure_adapter(&s, "git", "omgbase-git-adapter", &["--x".to_owned()]).unwrap();
429        ensure_adapter(&s, "git", "git2", &[]).unwrap();
430        let adapters = list_adapters(&s).unwrap();
431        assert_eq!(adapters.len(), 1);
432        assert_eq!(adapters[0].command, "git2");
433        assert!(adapters[0].args.is_empty());
434
435        let cfg = obj(json!({"url": "https://x", "depth": 1}));
436        let env: BTreeMap<String, String> =
437            [("TOKEN".to_owned(), "t".to_owned())].into_iter().collect();
438        let id = create_source(
439            &mut s,
440            &NewSource {
441                name: "remote",
442                adapter: "git",
443                config: Some(&cfg),
444                env: Some(&env),
445            },
446        )
447        .unwrap();
448        assert_eq!(id, "src_0");
449        assert!(
450            create_source(
451                &mut s,
452                &NewSource {
453                    name: "remote",
454                    adapter: "git",
455                    ..NewSource::default()
456                }
457            )
458            .is_err(),
459            "taken name"
460        );
461        assert!(
462            create_source(
463                &mut s,
464                &NewSource {
465                    name: "other",
466                    adapter: "nope",
467                    ..NewSource::default()
468                }
469            )
470            .is_err(),
471            "unknown adapter (FK)"
472        );
473        let row = source_by_name(&s, "remote").unwrap().unwrap();
474        assert_eq!(row.config, cfg);
475        assert_eq!(row.env, env);
476        assert_eq!(source_by_id(&s, "src_0").unwrap().unwrap().name, "remote");
477        assert!(source_by_name(&s, "zzz").unwrap().is_none());
478
479        let repo = ensure_repo(&mut s, "vault", Some("/data/vault")).unwrap();
480        assert_eq!(repo, "rp_0");
481        assert_eq!(
482            ensure_repo(&mut s, "vault", Some("/other")).unwrap(),
483            "rp_0"
484        );
485        let fs = source_by_name(&s, "vault-fs").unwrap().unwrap();
486        assert_eq!(fs.adapter, "fs");
487        assert_eq!(fs.config, obj(json!({"root": "/data/vault"})));
488        assert!(fs.env.is_empty());
489        assert_eq!(
490            list_adapters(&s)
491                .unwrap()
492                .iter()
493                .map(|a| a.name.as_str())
494                .collect::<Vec<_>>(),
495            ["fs", "git"]
496        );
497
498        attach(&s, &repo, &id).unwrap();
499        attach(&s, &repo, &id).unwrap();
500        let attached = sources_for_repo(&s, &repo).unwrap();
501        assert_eq!(
502            attached.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
503            ["remote", "vault-fs"]
504        );
505        detach(&s, &repo, &id).unwrap();
506        assert_eq!(sources_for_repo(&s, &repo).unwrap().len(), 1);
507
508        s.conn()
509            .execute(
510                "INSERT INTO sync_state (repo_id, source_id, path) VALUES (?1, ?2, '')",
511                params![repo, fs.source_id],
512            )
513            .unwrap();
514        delete_source(&s, &fs.source_id).unwrap();
515        assert!(source_by_name(&s, "vault-fs").unwrap().is_none());
516        assert!(sources_for_repo(&s, &repo).unwrap().is_empty());
517        let n: i64 = s
518            .conn()
519            .query_row("SELECT count(*) FROM sync_state", [], |r| r.get(0))
520            .unwrap();
521        assert_eq!(n, 0);
522        assert_eq!(list_sources(&s).unwrap().len(), 1);
523
524        let headless = ensure_repo(&mut s, "head", None).unwrap();
525        assert!(sources_for_repo(&s, &headless).unwrap().is_empty());
526        assert_eq!(ensure_repo(&mut s, "empty-root", Some("")).unwrap(), "rp_2");
527        assert!(source_by_name(&s, "empty-root-fs").unwrap().is_none());
528    }
529}