1use anyhow::{Context, Result};
21use sha2::{Digest, Sha256};
22use std::path::{Path, PathBuf};
23
24pub(crate) const ENTRIES: [&str; 5] = ["config.toml", "credentials", "agents", "skills", "state"];
26
27const LEGACY: [&str; 7] = [
29 "adapters",
30 "channels",
31 "run",
32 "server.sock",
33 "server.lock",
34 "clawbot",
35 "clawbot.toml",
36];
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Layout {
41 home: PathBuf,
42 default: bool,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Stray {
49 pub path: PathBuf,
50 pub legacy: bool,
52}
53
54impl Layout {
55 pub fn new(home: impl Into<PathBuf>) -> Self {
57 Self {
58 home: home.into(),
59 default: false,
60 }
61 }
62
63 pub fn from_env() -> Result<Self> {
68 let selected = std::env::var_os("SCV_HOME").map(PathBuf::from);
69 let default = selected.is_none();
70 let home = selected
71 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
72 .context("cannot determine SCV_HOME")?;
73 Ok(Self {
74 home: resolve(home)?,
75 default,
76 })
77 }
78
79 pub fn home(&self) -> &Path {
80 &self.home
81 }
82
83 pub fn is_default(&self) -> bool {
85 self.default
86 }
87
88 pub fn service_name(&self) -> String {
92 if self.default {
93 return "scv.service".into();
94 }
95 let digest = Sha256::digest(self.home.to_string_lossy().as_bytes());
96 let suffix = digest[..8]
97 .iter()
98 .map(|byte| format!("{byte:02x}"))
99 .collect::<String>();
100 format!("scv-{suffix}.service")
101 }
102
103 pub fn config(&self) -> PathBuf {
105 self.home.join("config.toml")
106 }
107
108 pub fn credentials(&self) -> PathBuf {
110 self.home.join("credentials")
111 }
112
113 pub fn channel_credentials(&self, channel: &str) -> PathBuf {
115 self.credentials().join(channel)
116 }
117
118 pub fn agents(&self) -> PathBuf {
120 self.home.join("agents")
121 }
122
123 pub fn agent_home(&self, agent: &str) -> PathBuf {
124 self.agents().join(agent)
125 }
126
127 pub fn skills(&self) -> PathBuf {
128 self.home.join("skills")
129 }
130
131 pub fn state(&self) -> PathBuf {
133 self.home.join("state")
134 }
135
136 pub fn socket(&self) -> PathBuf {
137 self.state().join("server.sock")
138 }
139
140 pub fn delegations(&self) -> PathBuf {
142 self.state().join("delegations")
143 }
144
145 pub fn conversations(&self) -> PathBuf {
147 self.state().join("conversations")
148 }
149
150 pub fn imports(&self) -> PathBuf {
152 self.state().join("imports")
153 }
154
155 pub fn channel_state(&self, channel: &str) -> PathBuf {
157 self.state().join("channels").join(channel)
158 }
159
160 pub fn media(&self) -> PathBuf {
163 self.state().join("media")
164 }
165
166 pub fn outbox(&self) -> PathBuf {
168 self.media().join("outbox")
169 }
170
171 pub fn update_plan(&self) -> PathBuf {
173 self.state().join("update.json")
174 }
175
176 pub fn last_owner(&self) -> PathBuf {
178 self.state().join("last-owner.json")
179 }
180
181 pub fn daemon_marker(&self) -> PathBuf {
184 self.state().join("daemon.json")
185 }
186
187 pub fn config_lock(&self) -> PathBuf {
189 self.state().join("config.lock")
190 }
191
192 pub fn strays(&self) -> Result<Vec<Stray>> {
194 let entries = match std::fs::read_dir(&self.home) {
195 Ok(entries) => entries,
196 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
197 Err(error) => {
198 return Err(error).with_context(|| format!("read {}", self.home.display()));
199 }
200 };
201 let mut strays = Vec::new();
202 for entry in entries {
203 let name = entry?.file_name();
204 let Some(text) = name.to_str() else {
205 strays.push(Stray {
206 path: self.home.join(&name),
207 legacy: false,
208 });
209 continue;
210 };
211 if ENTRIES.contains(&text) {
212 continue;
213 }
214 strays.push(Stray {
215 path: self.home.join(text),
216 legacy: LEGACY.contains(&text),
217 });
218 }
219 strays.sort_by(|a, b| a.path.cmp(&b.path));
220 Ok(strays)
221 }
222}
223
224fn resolve(path: PathBuf) -> Result<PathBuf> {
227 if path.exists() {
228 Ok(std::fs::canonicalize(&path).unwrap_or(path))
229 } else if path.is_absolute() {
230 Ok(path)
231 } else {
232 Ok(std::env::current_dir()
233 .context("cannot determine SCV instance home")?
234 .join(path))
235 }
236}
237
238#[cfg(test)]
239mod tests;