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 const HOME_VAR: &str = "HOME";
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn with_env_vars<T>(vars: &[(&str, &std::ffi::OsStr)], body: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let was: Vec<_> = vars
.iter()
.map(|(name, _)| std::env::var_os(name))
.collect();
for (name, value) in vars {
unsafe { std::env::set_var(name, value) };
}
let out = body();
for ((name, _), previous) in vars.iter().zip(was) {
match previous {
Some(previous) => unsafe { std::env::set_var(name, previous) },
None => unsafe { std::env::remove_var(name) },
}
}
out
}
#[cfg(test)]
pub(crate) fn with_env_var<T>(name: &str, value: &std::ffi::OsStr, body: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let was = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
let out = body();
match was {
Some(previous) => unsafe { std::env::set_var(name, previous) },
None => unsafe { std::env::remove_var(name) },
}
out
}
pub fn user_home() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(dir));
}
#[cfg(windows)]
{
if let Some(dir) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(dir));
}
let drive = std::env::var_os("HOMEDRIVE").filter(|v| !v.is_empty());
let path = std::env::var_os("HOMEPATH").filter(|v| !v.is_empty());
if let (Some(mut drive), Some(path)) = (drive, path) {
drive.push(path);
return Ok(PathBuf::from(drive));
}
bail!(
"none of HOME, USERPROFILE or HOMEDRIVE+HOMEPATH is set, so semlith does \
not know where your home directory is. Set {HOME_ENV} to the directory \
you want its stores in."
);
}
#[cfg(not(windows))]
bail!(
"HOME is not set, so semlith does not know where your home directory is. \
Set {HOME_ENV} to the directory you want its stores in."
)
}
pub fn home_or_error() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(HOME_ENV).filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(dir));
}
Ok(user_home()?.join(".semlith"))
}
pub fn stores_root() -> Result<PathBuf> {
Ok(home_or_error()?.join("stores"))
}
pub fn bin_dir() -> Result<PathBuf> {
Ok(home_or_error()?.join("bin"))
}
pub fn registry_path() -> Result<PathBuf> {
Ok(home_or_error()?.join("registry.json"))
}
pub fn settings_path() -> Result<PathBuf> {
Ok(home_or_error()?.join("settings.json"))
}
pub fn lane_rates_path() -> Result<PathBuf> {
Ok(home_or_error()?.join("lane-rates.json"))
}
pub fn prices_path() -> Result<PathBuf> {
Ok(home_or_error()?.join("prices.json"))
}
pub fn schedules_path() -> Result<PathBuf> {
Ok(home_or_error()?.join("schedules.json"))
}
pub fn projects_under(dir: &Path) -> Result<(Vec<PathBuf>, bool)> {
let listing = std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))?;
let mut repositories = Vec::new();
let mut plain = Vec::new();
for entry in listing.flatten() {
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let path = crate::canonical(&entry.path());
if path.join(".git").exists() {
repositories.push(path);
} else {
plain.push(path);
}
}
repositories.sort();
plain.sort();
let found = !repositories.is_empty();
Ok((if found { repositories } else { plain }, found))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub runs_at_once: Option<usize>,
pub embed_threads: Option<usize>,
pub index_memory_mb: Option<usize>,
#[serde(default)]
pub cpu_cap_percent: Option<u8>,
#[serde(default)]
pub session_replay: Option<bool>,
#[serde(default)]
pub ledger_paused: Option<bool>,
#[serde(default)]
pub airgap: Option<bool>,
#[serde(default)]
pub ledger_usage: Option<bool>,
#[serde(default)]
pub accelerators: crate::accel::Switches,
#[serde(default)]
pub gpu_adapter: Option<String>,
#[serde(default)]
pub vector_cache_mb: Option<u64>,
pub history_retention_days: Option<u64>,
pub compact_threshold_percent: Option<u64>,
}
impl Settings {
pub fn replay_on(&self) -> bool {
self.session_replay.unwrap_or(true)
}
pub fn load() -> Self {
let Ok(path) = settings_path() else {
return Self::default();
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
let path = settings_path()?;
let dir = path.parent().unwrap_or(Path::new("."));
secure_dir(dir).with_context(|| format!("creating the store home {}", dir.display()))?;
let temp = path.with_extension(unique_temp_suffix());
let body = serde_json::to_string_pretty(self)? + "\n";
write_private(&temp, body.as_bytes())
.with_context(|| format!("writing {}", temp.display()))?;
if let Err(e) = std::fs::rename(&temp, &path) {
let _ = std::fs::remove_file(&temp);
return Err(e).with_context(|| format!("writing {}", path.display()));
}
Ok(())
}
}
pub(crate) fn unique_temp_suffix() -> String {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
format!(
"json.{}.{}.new",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
)
}
pub fn agent_key_path() -> Result<PathBuf> {
Ok(home_or_error()?.join("agent.key"))
}
pub const AGENT_KEY_PREFIX: &str = "sml_";
pub fn agent_key() -> Result<String> {
let path = agent_key_path()?;
if path.exists() {
let key = std::fs::read_to_string(&path)
.with_context(|| format!("reading the agent key at {}", path.display()))?
.trim()
.to_string();
check_key_mode(&path)?;
if !is_agent_key(&key) {
bail!(
"{} does not hold an agent key. Delete it and start again, and a new key is written; every client then needs the new stanza.",
path.display()
);
}
return Ok(key);
}
let key = new_agent_key();
write_agent_key(&key)?;
Ok(key)
}
pub fn rotate_agent_key() -> Result<String> {
let key = new_agent_key();
write_agent_key(&key)?;
Ok(key)
}
pub fn agent_scope_path() -> Result<PathBuf> {
Ok(home_or_error()?.join("agent.scope"))
}
pub fn agent_may_write() -> bool {
agent_scope_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.is_some_and(|s| s.trim() == "write")
}
pub fn set_agent_scope(write: bool) -> Result<()> {
let path = agent_scope_path()?;
if let Some(parent) = path.parent() {
secure_dir(parent)?;
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&path)
.with_context(|| format!("writing the agent scope to {}", path.display()))?;
use std::io::Write;
writeln!(file, "{}", if write { "write" } else { "read" })?;
Ok(())
}
pub fn is_agent_key(value: &str) -> bool {
let Some(body) = value.strip_prefix(AGENT_KEY_PREFIX) else {
return false;
};
body.len() == 64 && body.bytes().all(|b| b.is_ascii_hexdigit())
}
fn new_agent_key() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("the OS random source");
let mut out = String::with_capacity(AGENT_KEY_PREFIX.len() + 64);
out.push_str(AGENT_KEY_PREFIX);
for byte in bytes {
use std::fmt::Write;
let _ = write!(out, "{byte:02x}");
}
out
}
fn write_agent_key(key: &str) -> Result<()> {
let path = agent_key_path()?;
if let Some(parent) = path.parent() {
secure_dir(parent)?;
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&path)
.with_context(|| format!("writing the agent key to {}", path.display()))?;
use std::io::Write;
writeln!(file, "{key}")?;
Ok(())
}
#[cfg(unix)]
fn check_key_mode(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
bail!(
"{} is mode {:o}; an agent key that other users on this machine can read is not one. Run `chmod 600 {}` and start again.",
path.display(),
mode,
path.display()
);
}
Ok(())
}
#[cfg(not(unix))]
fn check_key_mode(_path: &Path) -> Result<()> {
Ok(())
}
pub const LOCAL_DIR: &str = ".semlith";
pub const DIR_MODE: u32 = 0o700;
pub const FILE_MODE: u32 = 0o600;
pub fn secure_dir(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
tighten_dir(path);
Ok(())
}
pub fn tighten_dir(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(DIR_MODE));
}
}
}
#[cfg(not(unix))]
let _ = path;
}
pub fn tighten_file(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(FILE_MODE));
}
}
}
#[cfg(not(unix))]
let _ = path;
}
pub fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(FILE_MODE);
}
let mut file = options.open(path)?;
use std::io::Write;
file.write_all(bytes)?;
tighten_file(path);
Ok(())
}
pub fn loose_mode(path: &Path) -> Option<u32> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path).ok()?.permissions().mode() & 0o777;
(mode & 0o077 != 0).then_some(mode)
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
#[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,
#[serde(flatten)]
pub settings: StoreSettings,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct StoreSettings {
pub kind: String,
pub lean: String,
pub watch: bool,
pub record: bool,
pub gitignore: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cloud_sync: Option<CloudSync>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CloudSync {
pub org: String,
pub since: i64,
}
impl Default for StoreSettings {
fn default() -> Self {
Self {
kind: "both".to_string(),
lean: "either".to_string(),
watch: true,
record: true,
gitignore: true,
cloud_sync: None,
}
}
}
pub fn check_store_name(name: &str) -> Result<()> {
let mut chars = name.chars();
let ok = name.len() <= 40
&& chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !ok {
bail!("Use lowercase letters, digits and dashes — for example research-notes.");
}
Ok(())
}
fn taken_message(name: &str) -> String {
format!("“{name}” is already a store on this machine. Pick another name.")
}
pub fn create_store(name: &str, kind: &str) -> Result<PathBuf> {
refuse_remote(name)?;
check_store_name(name)?;
let mut registry = Registry::load()?;
let dir = Registry::dir_of(name)?;
if registry.stores.contains_key(name) || dir.exists() {
bail!("{}", taken_message(name));
}
let model = crate::Semlith::open(&dir, None)?.model().to_string();
registry.stores.insert(
name.to_string(),
Entry {
roots: Vec::new(),
model: Some(model),
created: now(),
settings: StoreSettings {
kind: kind.to_string(),
..StoreSettings::default()
},
},
);
registry.save()?;
Ok(dir)
}
pub fn rename_store(old: &str, new: &str) -> Result<PathBuf> {
refuse_remote(old)?;
check_store_name(new)?;
let mut registry = Registry::load()?;
let Some(entry) = registry.stores.get(old).cloned() else {
bail!("no registered store called {old}");
};
let to = Registry::dir_of(new)?;
if registry.stores.contains_key(new) || to.exists() {
bail!("{}", taken_message(new));
}
let from = Registry::dir_of(old)?;
if from.exists() {
std::fs::rename(&from, &to).with_context(|| {
format!(
"moving {} to {}; nothing was renamed",
crate::plain(&from.display().to_string()),
crate::plain(&to.display().to_string())
)
})?;
}
registry.stores.remove(old);
registry.stores.insert(new.to_string(), entry);
if let Err(e) = registry.save() {
let _ = std::fs::rename(&to, &from);
return Err(e);
}
Ok(to)
}
pub fn update_store_settings(
name: &str,
change: impl FnOnce(&mut StoreSettings),
) -> Result<StoreSettings> {
refuse_remote(name)?;
let mut registry = Registry::load()?;
let Some(entry) = registry.stores.get_mut(name) else {
bail!("no registered store called {name}");
};
change(&mut entry.settings);
let out = entry.settings.clone();
registry.save()?;
Ok(out)
}
pub fn store_settings(dir: &Path) -> StoreSettings {
Registry::load()
.ok()
.and_then(|r| {
r.name_of(dir)
.and_then(|n| r.stores.get(n))
.map(|e| e.settings.clone())
})
.unwrap_or_default()
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Registry {
#[serde(default)]
pub stores: BTreeMap<String, Entry>,
#[serde(default)]
pub trusted: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub remote: BTreeMap<String, Remote>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Remote {
pub host: String,
pub org: String,
pub store: String,
pub mcp_url: String,
}
pub fn refuse_remote(name: &str) -> Result<()> {
let Ok(registry) = Registry::load() else {
return Ok(());
};
if let Some(remote) = registry.remote.get(name) {
bail!("{}", remote_refusal(name, remote));
}
Ok(())
}
pub fn remote_refusal(name: &str, remote: &Remote) -> String {
format!(
"{name} is a remote store: Semlith Cloud ({}) indexes it from its own sources and \
this machine only reads it, so it cannot be indexed, added to, forgotten from, \
compacted, changed or deleted here. Change it in the cloud app, or send files \
to it with `semlith cloud push {name} <dir>`.",
remote
.host
.split_once("://")
.map_or(remote.host.as_str(), |(_, h)| h)
)
}
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("."));
secure_dir(dir).with_context(|| format!("creating the store home {}", dir.display()))?;
let temp = path.with_extension(unique_temp_suffix());
let body = serde_json::to_string_pretty(self)? + "\n";
write_private(&temp, body.as_bytes())
.with_context(|| format!("writing {}", temp.display()))?;
if let Err(e) = std::fs::rename(&temp, &path) {
let _ = std::fs::remove_file(&temp);
return Err(e).with_context(|| format!("writing {}", path.display()));
}
Ok(())
}
pub fn dir_of(name: &str) -> Result<PathBuf> {
Ok(stores_root()?.join(sanitize(name)))
}
pub fn dirs(&self) -> Result<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| Self::dir_of(n).is_ok_and(|theirs| crate::canonical(&theirs) == 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(),
settings: StoreSettings::default(),
});
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 trusts(&self, dir: &Path) -> bool {
let dir = crate::canonical(dir);
if home_or_error().is_ok_and(|home| dir.starts_with(crate::canonical(&home))) {
return true;
}
self.trusted.iter().any(|t| crate::canonical(t) == dir)
}
pub fn trust(&mut self, dir: &Path) -> Result<PathBuf> {
let dir = crate::canonical(dir);
if !dir.join("store.db").exists() {
bail!(
"{} is not a semlith store — no store.db in it",
dir.display()
);
}
if !self.trusted.iter().any(|t| t == &dir) {
self.trusted.push(dir.clone());
self.trusted.sort();
self.save()?;
}
Ok(dir)
}
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).is_ok_and(|d| d.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(),
store_home_for_humans(),
)),
_ => 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));
}
}
home_or_error()?;
let anchor_dir = directory_of(anchor);
let registry = Registry::load()?;
if let Some(name) = name {
refuse_remote(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() {
if !registry.trusts(&local) {
bail!("{}", untrusted(&local));
}
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 registry = Registry::load()?;
let mut out = Vec::new();
let local = cwd.join(LOCAL_DIR);
if local.join("store.db").exists() {
if !registry.trusts(&local) {
bail!("{}", untrusted(&local));
}
out.push(local);
}
for dir in registry.dirs()? {
if dir.join("store.db").exists() {
out.push(dir);
}
}
Ok(out)
}
pub fn index_roots(store_dir: &Path) -> Vec<PathBuf> {
let mut roots = Registry::load()
.ok()
.and_then(|registry| {
registry
.name_of(store_dir)
.and_then(|name| registry.stores.get(name))
.map(|entry| entry.roots.clone())
})
.unwrap_or_default();
if store_dir.file_name().is_some_and(|n| n == LOCAL_DIR)
&& let Some(corpus) = store_dir.parent()
{
let corpus = crate::canonical(corpus);
if !roots.contains(&corpus) {
roots.push(corpus);
}
}
roots
}
fn untrusted(dir: &Path) -> String {
format!(
"{} is a semlith store this machine has not been told to open.\n\
A `.semlith` directory can arrive inside a repository, and a store is \
what semlith answers from, so it is opened only once you have said so:\n\
\n semlith trust {} keep it where it is\n\
\n semlith adopt {} move it into {}\n\
\nOr name it explicitly with --store, which is always an instruction.",
dir.display(),
dir.display(),
dir.display(),
store_home_for_humans(),
)
}
fn store_home_for_humans() -> String {
match stores_root() {
Ok(dir) => dir.display().to_string(),
Err(_) => "the store home".to_string(),
}
}
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)?;
secure_dir(&stores_root()?)?;
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<()> {
refuse_remote(name)?;
if !root.is_dir() {
bail!("{} is not a directory", root.display());
}
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()
}
pub fn delete_store(name: &str) -> Result<PathBuf> {
refuse_remote(name)?;
let mut registry = Registry::load()?;
if !registry.stores.contains_key(name) {
bail!(
"no registered store called {name}; these are: {}",
registry
.stores
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
let dir = Registry::dir_of(name)?;
if dir.exists() {
let doomed = dir.with_file_name(format!(
".{}.deleting-{}",
dir.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
std::process::id()
));
let mut moved = std::fs::rename(&dir, &doomed);
for _ in 0..20 {
if moved.is_ok() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
moved = std::fs::rename(&dir, &doomed);
}
moved.with_context(|| {
format!(
"{} is still in use, so nothing of it was deleted; stop whatever \
else has it open and delete it again",
crate::plain(&dir.display().to_string())
)
})?;
registry.stores.remove(name);
registry.save()?;
std::fs::remove_dir_all(&doomed).with_context(|| {
format!(
"{} was taken out of the store home but could not be removed; \
delete it by hand",
crate::plain(&doomed.display().to_string())
)
})?;
return Ok(dir);
}
registry.stores.remove(name);
registry.save()?;
Ok(dir)
}
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,
settings: StoreSettings::default(),
}
}
#[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");
}
}
#[cfg(test)]
mod agent_key_tests {
use super::*;
#[test]
fn a_key_is_created_once_and_then_read() {
let home = tempfile::tempdir().unwrap();
temp_env(home.path(), || {
let first = agent_key().unwrap();
assert!(is_agent_key(&first), "{first} is not shaped like a key");
let second = agent_key().unwrap();
assert_eq!(first, second, "the key was reminted on the second read");
});
}
#[test]
#[cfg(unix)]
fn a_new_key_file_is_not_readable_by_anyone_else() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
temp_env(home.path(), || {
agent_key().unwrap();
let mode = std::fs::metadata(agent_key_path().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "the key file is mode {mode:o}");
});
}
#[test]
#[cfg(unix)]
fn a_group_readable_key_is_refused() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
temp_env(home.path(), || {
agent_key().unwrap();
let path = agent_key_path().unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let refused = agent_key().unwrap_err().to_string();
assert!(refused.contains("is mode 644"), "{refused}");
});
}
#[test]
fn rotation_writes_a_different_key() {
let home = tempfile::tempdir().unwrap();
temp_env(home.path(), || {
let first = agent_key().unwrap();
let second = rotate_agent_key().unwrap();
assert_ne!(first, second);
assert_eq!(
second,
agent_key().unwrap(),
"the new key was not persisted"
);
});
}
#[test]
fn a_created_store_is_named_by_the_rule_and_never_twice() {
for good in ["research-notes", "a", "0", "x-1-y"] {
assert!(check_store_name(good).is_ok(), "{good} refused");
}
for bad in ["", "-a", "A", "has space", "under_score", &"a".repeat(41)] {
let e = check_store_name(bad).unwrap_err().to_string();
assert_eq!(
e,
"Use lowercase letters, digits and dashes — for example research-notes."
);
}
let home = tempfile::tempdir().unwrap();
temp_env(home.path(), || {
let dir = create_store("research-notes", "docs").unwrap();
assert!(dir.join("store.db").exists());
let registry = Registry::load().unwrap();
let entry = ®istry.stores["research-notes"];
assert!(entry.roots.is_empty());
assert!(entry.model.is_some(), "the model was not recorded");
assert_eq!(entry.settings.kind, "docs");
assert_eq!(entry.settings.lean, "either");
let again = create_store("research-notes", "both").unwrap_err();
assert_eq!(
again.to_string(),
"“research-notes” is already a store on this machine. Pick another name."
);
});
}
#[test]
fn a_rename_moves_the_directory_and_keeps_the_settings() {
let home = tempfile::tempdir().unwrap();
temp_env(home.path(), || {
let old = create_store("first", "code").unwrap();
update_store_settings("first", |s| s.lean = "code".into()).unwrap();
let new = rename_store("first", "second").unwrap();
assert!(!old.exists() && new.join("store.db").exists());
assert_eq!(new, Registry::dir_of("second").unwrap());
let registry = Registry::load().unwrap();
assert!(!registry.stores.contains_key("first"));
assert_eq!(registry.stores["second"].settings.lean, "code");
create_store("third", "both").unwrap();
assert!(rename_store("second", "third").is_err());
assert!(rename_store("second", "Bad").is_err());
});
}
#[test]
fn store_settings_default_to_what_a_store_did_before_them() {
let old: Registry =
serde_json::from_str(r#"{"stores":{"a":{"roots":["/x"],"created":1}}}"#).unwrap();
assert_eq!(old.stores["a"].settings, StoreSettings::default());
let written = serde_json::to_value(&old).unwrap();
assert_eq!(written["stores"]["a"]["watch"], serde_json::json!(true));
assert_eq!(written["stores"]["a"]["lean"], serde_json::json!("either"));
}
fn temp_env(home: &Path, body: impl FnOnce()) {
super::with_env_var(HOME_ENV, home.as_os_str(), body);
}
}