1use std::path::{Path, PathBuf};
6
7use omgbase_store::{IdMinter, RandomMinter, Store};
8use rusqlite::params;
9
10use crate::error::{Error, Result};
11
12pub const OMGBASE_DIR: &str = ".omgbase";
14pub const DB_FILE: &str = "omgbase.db";
16pub const WORKSPACE_ENV: &str = "OMGBASE_WORKSPACE";
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct RepoRow {
22 pub repo_id: String,
23 pub slug: String,
24 pub root_path: Option<String>,
27}
28
29impl RepoRow {
30 #[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#[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
53pub 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#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct RepoSelection {
95 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
120fn 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
147fn 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
161pub 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 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
205pub 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#[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 pub fn open(root: impl AsRef<Path>) -> Result<Self> {
243 Self::open_with_minter(root, Box::new(RandomMinter))
244 }
245
246 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 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 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 #[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 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 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 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 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}