use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub(crate) const ENTRIES: [&str; 5] = ["config.toml", "credentials", "agents", "skills", "state"];
const LEGACY: [&str; 7] = [
"adapters",
"channels",
"run",
"server.sock",
"server.lock",
"clawbot",
"clawbot.toml",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
home: PathBuf,
default: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stray {
pub path: PathBuf,
pub legacy: bool,
}
impl Layout {
pub fn new(home: impl Into<PathBuf>) -> Self {
Self {
home: home.into(),
default: false,
}
}
pub fn from_env() -> Result<Self> {
let selected = std::env::var_os("SCV_HOME").map(PathBuf::from);
let default = selected.is_none();
let home = selected
.or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
.context("cannot determine SCV_HOME")?;
Ok(Self {
home: resolve(home)?,
default,
})
}
pub fn home(&self) -> &Path {
&self.home
}
pub fn is_default(&self) -> bool {
self.default
}
pub fn service_name(&self) -> String {
if self.default {
return "scv.service".into();
}
let digest = Sha256::digest(self.home.to_string_lossy().as_bytes());
let suffix = digest[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("scv-{suffix}.service")
}
pub fn config(&self) -> PathBuf {
self.home.join("config.toml")
}
pub fn credentials(&self) -> PathBuf {
self.home.join("credentials")
}
pub fn channel_credentials(&self, channel: &str) -> PathBuf {
self.credentials().join(channel)
}
pub fn agents(&self) -> PathBuf {
self.home.join("agents")
}
pub fn agent_home(&self, agent: &str) -> PathBuf {
self.agents().join(agent)
}
pub fn skills(&self) -> PathBuf {
self.home.join("skills")
}
pub fn state(&self) -> PathBuf {
self.home.join("state")
}
pub fn socket(&self) -> PathBuf {
self.state().join("server.sock")
}
pub fn delegations(&self) -> PathBuf {
self.state().join("delegations")
}
pub fn conversations(&self) -> PathBuf {
self.state().join("conversations")
}
pub fn imports(&self) -> PathBuf {
self.state().join("imports")
}
pub fn channel_state(&self, channel: &str) -> PathBuf {
self.state().join("channels").join(channel)
}
pub fn media(&self) -> PathBuf {
self.state().join("media")
}
pub fn outbox(&self) -> PathBuf {
self.media().join("outbox")
}
pub fn update_plan(&self) -> PathBuf {
self.state().join("update.json")
}
pub fn last_owner(&self) -> PathBuf {
self.state().join("last-owner.json")
}
pub fn daemon_marker(&self) -> PathBuf {
self.state().join("daemon.json")
}
pub fn config_lock(&self) -> PathBuf {
self.state().join("config.lock")
}
pub fn strays(&self) -> Result<Vec<Stray>> {
let entries = match std::fs::read_dir(&self.home) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(error).with_context(|| format!("read {}", self.home.display()));
}
};
let mut strays = Vec::new();
for entry in entries {
let name = entry?.file_name();
let Some(text) = name.to_str() else {
strays.push(Stray {
path: self.home.join(&name),
legacy: false,
});
continue;
};
if ENTRIES.contains(&text) {
continue;
}
strays.push(Stray {
path: self.home.join(text),
legacy: LEGACY.contains(&text),
});
}
strays.sort_by(|a, b| a.path.cmp(&b.path));
Ok(strays)
}
}
fn resolve(path: PathBuf) -> Result<PathBuf> {
if path.exists() {
Ok(std::fs::canonicalize(&path).unwrap_or(path))
} else if path.is_absolute() {
Ok(path)
} else {
Ok(std::env::current_dir()
.context("cannot determine SCV instance home")?
.join(path))
}
}
#[cfg(test)]
mod tests;