use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const HOME_ENV: &str = "SEMLITH_HOME";
pub fn home() -> PathBuf {
if let Some(dir) = std::env::var_os(HOME_ENV).filter(|v| !v.is_empty()) {
return PathBuf::from(dir);
}
let base = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
base.join(".semlith")
}
pub fn stores_root() -> PathBuf {
home().join("stores")
}
pub fn registry_path() -> PathBuf {
home().join("registry.json")
}
pub const LOCAL_DIR: &str = ".semlith";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub roots: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub created: u64,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Registry {
#[serde(default)]
pub stores: BTreeMap<String, Entry>,
}
impl Registry {
pub fn load() -> Result<Self> {
let path = registry_path();
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
serde_json::from_str(&text).with_context(|| {
format!(
"{} is not readable as a registry; move it aside to start over",
path.display()
)
})
}
pub fn save(&self) -> Result<()> {
let path = registry_path();
let dir = path.parent().unwrap_or(Path::new("."));
std::fs::create_dir_all(dir)
.with_context(|| format!("creating the store home {}", dir.display()))?;
let temp = path.with_extension("json.new");
let body = serde_json::to_string_pretty(self)? + "\n";
std::fs::write(&temp, body).with_context(|| format!("writing {}", temp.display()))?;
std::fs::rename(&temp, &path).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn dir_of(name: &str) -> PathBuf {
stores_root().join(name)
}
pub fn dirs(&self) -> Vec<PathBuf> {
self.stores.keys().map(|n| Self::dir_of(n)).collect()
}
pub fn covering(&self, path: &Path) -> Option<(&str, &Entry)> {
let mut best: Option<(&str, &Entry, usize)> = None;
for (name, entry) in &self.stores {
for root in &entry.roots {
if path == root || path.starts_with(root) {
let depth = root.components().count();
if best.is_none_or(|(_, _, d)| depth > d) {
best = Some((name, entry, depth));
}
}
}
}
best.map(|(n, e, _)| (n, e))
}
pub fn name_of(&self, dir: &Path) -> Option<&str> {
let dir = crate::canonical(dir);
self.stores
.keys()
.map(String::as_str)
.find(|n| crate::canonical(&Self::dir_of(n)) == dir)
}
pub fn register(&mut self, name: &str, root: &Path, model: Option<&str>) -> Result<()> {
let root = crate::canonical(root);
let entry = self
.stores
.entry(name.to_string())
.or_insert_with(|| Entry {
roots: Vec::new(),
model: model.map(str::to_string),
created: now(),
});
if entry.model.is_none() {
entry.model = model.map(str::to_string);
}
if !entry.roots.iter().any(|r| root.starts_with(r)) {
entry.roots.retain(|r| !r.starts_with(&root));
entry.roots.push(root);
entry.roots.sort();
}
self.save()
}
pub fn free_name(&self, stem: &str) -> String {
let stem = sanitize(stem);
let taken = |n: &str| self.stores.contains_key(n) || Self::dir_of(n).exists();
if !taken(&stem) {
return stem;
}
for n in 2.. {
let candidate = format!("{stem}-{n}");
if !taken(&candidate) {
return candidate;
}
}
unreachable!("the integers run out before the names do")
}
}
#[derive(Debug, Clone)]
pub enum Choice {
Named(Vec<PathBuf>),
Local(PathBuf),
Registered { name: String, dir: PathBuf },
New {
name: String,
dir: PathBuf,
root: PathBuf,
},
}
impl Choice {
pub fn one(&self) -> Result<PathBuf> {
Ok(match self {
Choice::Named(dirs) => match dirs.as_slice() {
[one] => one.clone(),
many => bail!(
"this command writes, so it takes one store, not {}: {}",
many.len(),
many.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
},
Choice::Local(dir) => dir.clone(),
Choice::Registered { dir, .. } | Choice::New { dir, .. } => dir.clone(),
})
}
pub fn all(&self) -> Vec<PathBuf> {
match self {
Choice::Named(dirs) => dirs.clone(),
Choice::Local(dir) => vec![dir.clone()],
Choice::Registered { dir, .. } | Choice::New { dir, .. } => vec![dir.clone()],
}
}
pub fn hint(&self) -> Option<String> {
match self {
Choice::Local(dir) => Some(format!(
"semlith: using the store at {} — `semlith adopt {}` moves it into {} \
so `semlith mcp` and `semlith start` find it with no flags",
dir.display(),
dir.display(),
stores_root().display(),
)),
_ => None,
}
}
}
pub fn resolve(flags: &[PathBuf], anchor: &Path, name: Option<&str>) -> Result<Choice> {
if !flags.is_empty() {
return Ok(Choice::Named(flags.to_vec()));
}
if let Some(raw) = std::env::var_os("SEMLITH_STORE") {
let dirs: Vec<PathBuf> = std::env::split_paths(&raw)
.filter(|p| !p.as_os_str().is_empty())
.collect();
if !dirs.is_empty() {
return Ok(Choice::Named(dirs));
}
}
let anchor_dir = directory_of(anchor);
let registry = Registry::load()?;
if let Some(name) = name {
let name = sanitize(name);
let dir = Registry::dir_of(&name);
return Ok(if registry.stores.contains_key(&name) {
Choice::Registered { name, dir }
} else {
Choice::New {
name,
dir,
root: crate::canonical(&anchor_dir),
}
});
}
let local = anchor_dir.join(LOCAL_DIR);
if local.join("store.db").exists() {
return Ok(Choice::Local(local));
}
let canonical_anchor = crate::canonical(&anchor_dir);
if let Some((name, _)) = registry.covering(&canonical_anchor) {
return Ok(Choice::Registered {
name: name.to_string(),
dir: Registry::dir_of(name),
});
}
let stem = canonical_anchor
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "store".to_string());
let name = registry.free_name(&stem);
Ok(Choice::New {
dir: Registry::dir_of(&name),
name,
root: canonical_anchor,
})
}
pub fn read_dirs(flags: &[PathBuf], cwd: &Path) -> Result<Vec<PathBuf>> {
match resolve(flags, cwd, None)? {
Choice::New { .. } => all_dirs(flags, cwd),
other => Ok(other.all()),
}
}
pub fn record(choice: &Choice, roots: &[PathBuf], model: &str) -> Result<()> {
let name = match choice {
Choice::New { name, .. } | Choice::Registered { name, .. } => name.clone(),
Choice::Local(_) | Choice::Named(_) => return Ok(()),
};
let mut registry = Registry::load()?;
for root in roots {
let root = directory_of(&crate::canonical(root));
registry.register(&name, &root, Some(model))?;
}
Ok(())
}
pub fn all_dirs(flags: &[PathBuf], cwd: &Path) -> Result<Vec<PathBuf>> {
if !flags.is_empty() {
return Ok(flags.to_vec());
}
if let Some(raw) = std::env::var_os("SEMLITH_STORE") {
let dirs: Vec<PathBuf> = std::env::split_paths(&raw)
.filter(|p| !p.as_os_str().is_empty())
.collect();
if !dirs.is_empty() {
return Ok(dirs);
}
}
let mut out = Vec::new();
let local = cwd.join(LOCAL_DIR);
if local.join("store.db").exists() {
out.push(local);
}
for dir in Registry::load()?.dirs() {
if dir.join("store.db").exists() {
out.push(dir);
}
}
Ok(out)
}
pub fn adopt(source: &Path, root: Option<&Path>, name: Option<&str>) -> Result<(String, PathBuf)> {
if !source.join("store.db").exists() {
bail!(
"{} is not a semlith store — no store.db in it",
source.display()
);
}
let source = crate::canonical(source);
let mut registry = Registry::load()?;
if let Some(existing) = registry.name_of(&source) {
bail!(
"{} is already the registered store {existing}",
source.display()
);
}
let root = match root {
Some(r) => crate::canonical(r),
None => source
.parent()
.map(crate::canonical)
.unwrap_or_else(|| source.clone()),
};
let stem = match name {
Some(n) => n.to_string(),
None => label_for(&source),
};
let name = registry.free_name(&stem);
let target = Registry::dir_of(&name);
std::fs::create_dir_all(stores_root())
.with_context(|| format!("creating {}", stores_root().display()))?;
match std::fs::rename(&source, &target) {
Ok(()) => {}
Err(_) => {
copy_tree(&source, &target)
.with_context(|| format!("copying {} to {}", source.display(), target.display()))?;
if !target.join("store.db").exists() {
bail!("copying {} left no store.db behind", source.display());
}
std::fs::remove_dir_all(&source)
.with_context(|| format!("removing {}", source.display()))?;
}
}
let model = crate::Semlith::open_existing(&target)
.ok()
.map(|s| s.model().to_string());
registry.register(&name, &root, model.as_deref())?;
Ok((name, target))
}
pub fn repoint(name: &str, root: &Path) -> Result<()> {
let mut registry = Registry::load()?;
let Some(entry) = registry.stores.get_mut(name) else {
bail!(
"no registered store called {name}; these are: {}",
registry
.stores
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
};
entry.roots = vec![crate::canonical(root)];
registry.save()
}
fn label_for(dir: &Path) -> String {
let own = dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if (own.starts_with('.') || own.is_empty())
&& let Some(parent) = dir
.parent()
.and_then(Path::file_name)
.map(|n| n.to_string_lossy().into_owned())
.filter(|n| !n.is_empty())
{
return parent;
}
if own.is_empty() {
"store".to_string()
} else {
own
}
}
fn directory_of(path: &Path) -> PathBuf {
if path.is_file() {
return path.parent().unwrap_or(Path::new(".")).to_path_buf();
}
path.to_path_buf()
}
fn sanitize(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect();
let cleaned = cleaned.trim_matches(['-', '.']).to_string();
if cleaned.is_empty() {
"store".to_string()
} else {
cleaned
}
}
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(roots: &[&str]) -> Entry {
Entry {
roots: roots.iter().map(PathBuf::from).collect(),
model: None,
created: 0,
}
}
#[test]
fn the_most_specific_root_wins() {
let mut registry = Registry::default();
registry
.stores
.insert("mono".into(), entry(&["/work/mono"]));
registry
.stores
.insert("api".into(), entry(&["/work/mono/services/api"]));
let (name, _) = registry
.covering(Path::new("/work/mono/services/api/src"))
.expect("an ancestor root covers a subdirectory");
assert_eq!(name, "api");
let (name, _) = registry
.covering(Path::new("/work/mono/docs"))
.expect("the wide root still covers everything else");
assert_eq!(name, "mono");
assert!(registry.covering(Path::new("/elsewhere")).is_none());
}
#[test]
fn a_taken_name_gets_a_suffix_rather_than_being_shared() {
let mut registry = Registry::default();
assert_eq!(registry.free_name("api"), "api");
registry.stores.insert("api".into(), entry(&["/a/api"]));
assert_eq!(registry.free_name("api"), "api-2");
registry.stores.insert("api-2".into(), entry(&["/b/api"]));
assert_eq!(registry.free_name("api"), "api-3");
}
#[test]
fn a_name_is_one_safe_segment() {
assert_eq!(sanitize("../../etc"), "etc");
assert_eq!(sanitize("my repo"), "my-repo");
assert_eq!(sanitize("ok-name_1.2"), "ok-name_1.2");
assert_eq!(sanitize("///"), "store");
}
#[test]
fn roots_do_not_accumulate_overlaps() {
let mut e = entry(&[]);
let add = |e: &mut Entry, root: &str| {
let root = PathBuf::from(root);
if !e.roots.iter().any(|r| root.starts_with(r)) {
e.roots.retain(|r| !r.starts_with(&root));
e.roots.push(root);
e.roots.sort();
}
};
add(&mut e, "/work/api/src");
add(&mut e, "/work/api/src");
assert_eq!(e.roots, vec![PathBuf::from("/work/api/src")]);
add(&mut e, "/work/api/tests");
assert_eq!(e.roots.len(), 2);
add(&mut e, "/work/api");
assert_eq!(
e.roots,
vec![PathBuf::from("/work/api")],
"a root that contains the others replaces them"
);
}
#[test]
fn an_adopted_store_is_named_after_its_corpus() {
assert_eq!(label_for(Path::new("/work/api/.semlith")), "api");
assert_eq!(label_for(Path::new("/work/api-store")), "api-store");
}
}