Skip to main content

omgbase_sync/
workspace.rs

1//! The workspace (`spec/sync/README.md` §1): the directory holding
2//! `.omgbase/`, discovered by walking up like git; its repos with their
3//! *derived* root paths; repo selection for a command run in some `cwd`.
4
5use std::path::{Path, PathBuf};
6
7use omgbase_store::{IdMinter, RandomMinter, Store};
8use rusqlite::params;
9
10use crate::error::{Error, Result};
11
12/// The directory a workspace is recognised by.
13pub const OMGBASE_DIR: &str = ".omgbase";
14/// The database file inside it (`spec/store` §1).
15pub const DB_FILE: &str = "omgbase.db";
16/// The environment variable that names a workspace when no flag does.
17pub const WORKSPACE_ENV: &str = "OMGBASE_WORKSPACE";
18
19/// A repo as the workspace lists it.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct RepoRow {
22    pub repo_id: String,
23    pub slug: String,
24    /// The `config.root` of the first `fs` source attached to the repo;
25    /// `None` for a sourceless repo (§1: its sync and watch are no-ops).
26    pub root_path: Option<String>,
27}
28
29impl RepoRow {
30    /// A row for the pure [`select_repo`] (the id is irrelevant there).
31    #[must_use]
32    pub fn candidate(slug: &str, root_path: Option<&str>) -> Self {
33        Self {
34            repo_id: String::new(),
35            slug: slug.to_owned(),
36            root_path: root_path.map(str::to_owned),
37        }
38    }
39}
40
41/// The `root` of an `fs` source's stored `config` JSON, or `None` when the
42/// config is unparsable, the root is not a string, or it is empty.
43#[must_use]
44pub fn fs_root_from_config(config: Option<&str>) -> Option<String> {
45    let text = config?;
46    let v: serde_json::Value = serde_json::from_str(text).ok()?;
47    match v.get("root") {
48        Some(serde_json::Value::String(s)) if !s.is_empty() => Some(s.clone()),
49        _ => None,
50    }
51}
52
53/// The repos of a store with their derived root paths, ordered by `slug`
54/// (§1: attachments joined to sources with `adapter = 'fs'`, the first
55/// non-empty root per repo).
56pub fn list_repos(store: &Store) -> Result<Vec<RepoRow>> {
57    let mut stmt = store.conn().prepare(
58        "SELECT r.repo_id, r.slug, s.config
59         FROM repos r
60         LEFT JOIN attachments a ON a.repo_id = r.repo_id
61         LEFT JOIN sources s ON s.source_id = a.source_id AND s.adapter = 'fs'
62         ORDER BY r.slug",
63    )?;
64    let rows = stmt.query_map([], |r| {
65        Ok((
66            r.get::<_, String>(0)?,
67            r.get::<_, String>(1)?,
68            r.get::<_, Option<String>>(2)?,
69        ))
70    })?;
71    let mut out: Vec<RepoRow> = Vec::new();
72    for row in rows {
73        let (repo_id, slug, config) = row?;
74        let root = fs_root_from_config(config.as_deref());
75        match out.iter_mut().find(|r| r.repo_id == repo_id) {
76            Some(existing) => {
77                if existing.root_path.is_none() {
78                    existing.root_path = root;
79                }
80            }
81            None => out.push(RepoRow {
82                repo_id,
83                slug,
84                root_path: root,
85            }),
86        }
87    }
88    Ok(out)
89}
90
91/// Why [`select_repo`] found nothing: `repo_not_found` with the slugs that
92/// exist.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct RepoSelection {
95    /// Always `"repo_not_found"` (the reference's one code).
96    pub error: &'static str,
97    pub message: String,
98    pub candidates: Vec<String>,
99}
100
101impl RepoSelection {
102    fn not_found(message: String, repos: &[RepoRow]) -> Self {
103        Self {
104            error: "repo_not_found",
105            message,
106            candidates: repos.iter().map(|r| r.slug.clone()).collect(),
107        }
108    }
109}
110
111impl From<RepoSelection> for Error {
112    fn from(s: RepoSelection) -> Self {
113        Error::RepoNotFound {
114            message: s.message,
115            candidates: s.candidates,
116        }
117    }
118}
119
120/// Node's `path.resolve` for one segment: absolute against the process cwd
121/// when relative, then `.`/`..`/duplicate-slash normalization; no trailing
122/// slash except for the root.
123fn resolve_path(p: &str) -> String {
124    let joined = if p.starts_with('/') {
125        p.to_owned()
126    } else {
127        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
128        format!("{}/{p}", cwd.to_string_lossy())
129    };
130    let mut parts: Vec<&str> = Vec::new();
131    for seg in joined.split('/') {
132        match seg {
133            "" | "." => {}
134            ".." => {
135                parts.pop();
136            }
137            s => parts.push(s),
138        }
139    }
140    if parts.is_empty() {
141        "/".to_owned()
142    } else {
143        format!("/{}", parts.join("/"))
144    }
145}
146
147/// Path-prefix containment after resolving both (§1): `root == here` or
148/// `here` is under `root`.
149fn contains(root: &str, here: &str) -> bool {
150    let root = resolve_path(root);
151    let here = resolve_path(here);
152    if root == "/" {
153        return true;
154    }
155    here == root
156        || here
157            .strip_prefix(&root)
158            .is_some_and(|rest| rest.starts_with('/'))
159}
160
161/// §1 repo selection, pure over the rows: an explicit slug must exist; else
162/// with exactly one repo, that repo; else the repos whose root path contains
163/// `cwd` (sourceless repos never match) — exactly one → it; none →
164/// `repo_not_found`; several → the longest resolved root path.
165pub fn select_repo<'a>(
166    repos: &'a [RepoRow],
167    cwd: &str,
168    slug: Option<&str>,
169) -> std::result::Result<&'a RepoRow, RepoSelection> {
170    if let Some(slug) = slug.filter(|s| !s.is_empty()) {
171        return repos
172            .iter()
173            .find(|r| r.slug == slug)
174            .ok_or_else(|| RepoSelection::not_found(format!("no repo with slug '{slug}'"), repos));
175    }
176    if repos.len() == 1 {
177        return Ok(&repos[0]);
178    }
179    let here = resolve_path(cwd);
180    let mut containing: Vec<&RepoRow> = repos
181        .iter()
182        .filter(|r| {
183            r.root_path
184                .as_deref()
185                .is_some_and(|root| contains(root, &here))
186        })
187        .collect();
188    match containing.len() {
189        1 => Ok(containing[0]),
190        0 => Err(RepoSelection::not_found(
191            format!("no repo contains {here}; select one with --repo"),
192            repos,
193        )),
194        _ => {
195            // Deepest root wins: the longest resolved root path (stable sort,
196            // as the reference's `Array.prototype.sort`).
197            containing.sort_by_key(|r| {
198                std::cmp::Reverse(resolve_path(r.root_path.as_deref().unwrap_or("")).len())
199            });
200            Ok(containing[0])
201        }
202    }
203}
204
205/// An open workspace: its root, `.omgbase/` directory, database path and
206/// store.
207pub struct Workspace {
208    root: PathBuf,
209    omgbase_dir: PathBuf,
210    db_path: PathBuf,
211    store: Store,
212}
213
214impl std::fmt::Debug for Workspace {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.debug_struct("Workspace")
217            .field("root", &self.root)
218            .finish_non_exhaustive()
219    }
220}
221
222/// Walk up from `start` to the filesystem root; the first directory holding
223/// a `.omgbase` *directory* is the workspace root (§1).
224#[must_use]
225pub fn find_root(start: &Path) -> Option<PathBuf> {
226    let mut dir = if start.is_absolute() {
227        start.to_path_buf()
228    } else {
229        std::env::current_dir().ok()?.join(start)
230    };
231    loop {
232        if dir.join(OMGBASE_DIR).is_dir() {
233            return Some(dir);
234        }
235        dir = dir.parent()?.to_path_buf();
236    }
237}
238
239impl Workspace {
240    /// Open the workspace rooted exactly at `root`, creating `.omgbase/` and
241    /// the database as needed.
242    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
243        Self::open_with_minter(root, Box::new(RandomMinter))
244    }
245
246    /// [`Workspace::open`] with a replaceable id minter (`spec/store` §2.2).
247    pub fn open_with_minter(root: impl AsRef<Path>, minter: Box<dyn IdMinter>) -> Result<Self> {
248        let root = root.as_ref();
249        let root = if root.is_absolute() {
250            root.to_path_buf()
251        } else {
252            std::env::current_dir()
253                .map_err(|e| Error::io("cannot read the current directory", root, e))?
254                .join(root)
255        };
256        let omgbase_dir = root.join(OMGBASE_DIR);
257        let db_path = omgbase_dir.join(DB_FILE);
258        let store = Store::open_with_minter(&db_path, minter)?;
259        Ok(Self {
260            root,
261            omgbase_dir,
262            db_path,
263            store,
264        })
265    }
266
267    /// The workspace containing `start` (walk up), or `None`.
268    pub fn find(start: &Path) -> Result<Option<Self>> {
269        match find_root(start) {
270            Some(root) => Ok(Some(Self::open(root)?)),
271            None => Ok(None),
272        }
273    }
274
275    /// §1 precedence: an explicit `--workspace` value, else
276    /// `$OMGBASE_WORKSPACE`, else discovery from `start`.
277    pub fn locate(explicit: Option<&Path>, start: &Path) -> Result<Option<Self>> {
278        if let Some(p) = explicit {
279            return Ok(Some(Self::open(p)?));
280        }
281        if let Some(env) = std::env::var_os(WORKSPACE_ENV).filter(|v| !v.is_empty()) {
282            return Ok(Some(Self::open(PathBuf::from(env))?));
283        }
284        Self::find(start)
285    }
286
287    #[must_use]
288    pub fn root(&self) -> &Path {
289        &self.root
290    }
291
292    /// `<root>/.omgbase` — where the locks live (§7).
293    #[must_use]
294    pub fn omgbase_dir(&self) -> &Path {
295        &self.omgbase_dir
296    }
297
298    #[must_use]
299    pub fn db_path(&self) -> &Path {
300        &self.db_path
301    }
302
303    #[must_use]
304    pub fn store(&self) -> &Store {
305        &self.store
306    }
307
308    pub fn store_mut(&mut self) -> &mut Store {
309        &mut self.store
310    }
311
312    /// The repos with their derived roots, by slug.
313    pub fn repos(&self) -> Result<Vec<RepoRow>> {
314        list_repos(&self.store)
315    }
316
317    pub fn repo_by_slug(&self, slug: &str) -> Result<Option<RepoRow>> {
318        Ok(self.repos()?.into_iter().find(|r| r.slug == slug))
319    }
320
321    /// [`select_repo`] over this workspace's repos.
322    pub fn select_repo(&self, cwd: &Path, slug: Option<&str>) -> Result<RepoRow> {
323        let repos = self.repos()?;
324        let cwd = cwd.to_string_lossy();
325        select_repo(&repos, &cwd, slug)
326            .cloned()
327            .map_err(Error::from)
328    }
329
330    /// Whether `slug` exists (a cheap probe the registry uses).
331    pub fn has_repo(&self, slug: &str) -> Result<bool> {
332        Ok(self
333            .store
334            .conn()
335            .query_row("SELECT 1 FROM repos WHERE slug = ?1", params![slug], |_| {
336                Ok(())
337            })
338            .is_ok())
339    }
340
341    /// Close the store.
342    pub fn close(self) -> Result<()> {
343        Ok(self.store.close()?)
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn rows(specs: &[(&str, Option<&str>)]) -> Vec<RepoRow> {
352        specs
353            .iter()
354            .map(|(slug, root)| RepoRow::candidate(slug, *root))
355            .collect()
356    }
357
358    #[test]
359    fn fs_root_parsing() {
360        assert_eq!(
361            fs_root_from_config(Some(r#"{"root":"/data/v"}"#)),
362            Some("/data/v".to_owned())
363        );
364        assert_eq!(fs_root_from_config(Some(r#"{"root":""}"#)), None);
365        assert_eq!(fs_root_from_config(Some(r#"{"root":3}"#)), None);
366        assert_eq!(fs_root_from_config(Some("{")), None);
367        assert_eq!(fs_root_from_config(None), None);
368    }
369
370    #[test]
371    fn resolve_normalizes_like_node() {
372        assert_eq!(resolve_path("/a/b/../c/./d/"), "/a/c/d");
373        assert_eq!(resolve_path("//a///b"), "/a/b");
374        assert_eq!(resolve_path("/"), "/");
375        assert_eq!(resolve_path("/.."), "/");
376        assert!(resolve_path("rel").starts_with('/'));
377    }
378
379    #[test]
380    fn containment_is_by_path_component() {
381        assert!(contains("/a/b", "/a/b"));
382        assert!(contains("/a/b", "/a/b/c"));
383        assert!(contains("/a/b/", "/a/b/c"));
384        assert!(!contains("/a/b", "/a/bc"));
385        assert!(!contains("/a/b", "/a"));
386        assert!(contains("/", "/anything"));
387    }
388
389    #[test]
390    fn explicit_slug_wins_or_fails_with_candidates() {
391        let r = rows(&[("a", Some("/x")), ("b", None)]);
392        assert_eq!(select_repo(&r, "/nowhere", Some("b")).unwrap().slug, "b");
393        let err = select_repo(&r, "/x", Some("zzz")).unwrap_err();
394        assert_eq!(err.error, "repo_not_found");
395        assert_eq!(err.candidates, vec!["a", "b"]);
396        assert!(err.message.contains("zzz"));
397    }
398
399    #[test]
400    fn single_repo_matches_any_cwd_even_sourceless() {
401        let r = rows(&[("only", None)]);
402        assert_eq!(select_repo(&r, "/anywhere", None).unwrap().slug, "only");
403    }
404
405    #[test]
406    fn cwd_containment_and_deepest_root() {
407        let r = rows(&[
408            ("outer", Some("/data")),
409            ("inner", Some("/data/inner")),
410            ("headless", None),
411            ("other", Some("/elsewhere")),
412        ]);
413        assert_eq!(
414            select_repo(&r, "/data/inner/deep", None).unwrap().slug,
415            "inner"
416        );
417        assert_eq!(select_repo(&r, "/data/x", None).unwrap().slug, "outer");
418        assert_eq!(select_repo(&r, "/elsewhere", None).unwrap().slug, "other");
419        let err = select_repo(&r, "/tmp", None).unwrap_err();
420        assert_eq!(err.candidates, vec!["outer", "inner", "headless", "other"]);
421        assert!(err.message.contains("/tmp"));
422        assert_eq!(
423            select_repo(&r, "/data/../data/inner", None).unwrap().slug,
424            "inner"
425        );
426    }
427
428    #[test]
429    fn find_root_walks_up_to_a_dot_omgbase_directory() {
430        let base = std::env::temp_dir().join(format!("omgbase-sync-ws-{}", std::process::id()));
431        let _ = std::fs::remove_dir_all(&base);
432        std::fs::create_dir_all(base.join("ws/.omgbase")).unwrap();
433        std::fs::create_dir_all(base.join("ws/a/b")).unwrap();
434        std::fs::write(base.join("ws/a/.omgbase"), "not a dir").unwrap();
435        assert_eq!(find_root(&base.join("ws/a/b")).unwrap(), base.join("ws"));
436        assert_eq!(find_root(&base.join("ws")).unwrap(), base.join("ws"));
437        assert_eq!(find_root(&base), None);
438        let ws = Workspace::open(base.join("fresh")).unwrap();
439        assert!(ws.db_path().is_file());
440        assert_eq!(ws.omgbase_dir(), base.join("fresh/.omgbase"));
441        assert!(ws.repos().unwrap().is_empty());
442        assert!(!ws.has_repo("x").unwrap());
443        ws.close().unwrap();
444        let found = Workspace::find(&base.join("fresh")).unwrap().unwrap();
445        assert_eq!(found.root(), base.join("fresh"));
446        let _ = std::fs::remove_dir_all(&base);
447    }
448}