1use std::collections::BTreeMap;
7
8use omgbase_store::Store;
9use rusqlite::{OptionalExtension, params};
10use serde_json::{Map, Value};
11
12use crate::error::Result;
13
14pub const FS_ADAPTER: &str = "fs";
16pub const FS_ADAPTER_COMMAND: &str = "omgbase-fs-adapter";
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct AdapterRow {
22 pub name: String,
23 pub command: String,
24 pub args: Vec<String>,
25}
26
27#[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 pub env: BTreeMap<String, String>,
36}
37
38pub 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
48pub 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#[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
82pub 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
107pub 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
161pub 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
170pub 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
183pub 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
196pub 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
205pub 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
214pub 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
225pub 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
240pub 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
276fn 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 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; 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#[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#[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}