1use anyhow::{Context, Result};
17use std::path::{Path, PathBuf};
18
19pub const ENTRIES: [&str; 5] = ["config.toml", "credentials", "agents", "skills", "state"];
21
22const LEGACY: [&str; 7] = [
24 "adapters",
25 "channels",
26 "run",
27 "server.sock",
28 "server.lock",
29 "clawbot",
30 "clawbot.toml",
31];
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Layout {
36 home: PathBuf,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Stray {
42 pub path: PathBuf,
43 pub legacy: bool,
45}
46
47impl Layout {
48 pub fn new(home: impl Into<PathBuf>) -> Self {
49 Self { home: home.into() }
50 }
51
52 pub fn from_env() -> Result<Self> {
54 std::env::var_os("SCV_HOME")
55 .map(PathBuf::from)
56 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
57 .map(Self::new)
58 .context("cannot determine SCV_HOME")
59 }
60
61 pub fn home(&self) -> &Path {
62 &self.home
63 }
64
65 pub fn config(&self) -> PathBuf {
67 self.home.join("config.toml")
68 }
69
70 pub fn credentials(&self) -> PathBuf {
72 self.home.join("credentials")
73 }
74
75 pub fn channel_credentials(&self, channel: &str) -> PathBuf {
77 self.credentials().join(channel)
78 }
79
80 pub fn agents(&self) -> PathBuf {
82 self.home.join("agents")
83 }
84
85 pub fn agent_home(&self, agent: &str) -> PathBuf {
86 self.agents().join(agent)
87 }
88
89 pub fn skills(&self) -> PathBuf {
90 self.home.join("skills")
91 }
92
93 pub fn state(&self) -> PathBuf {
95 self.home.join("state")
96 }
97
98 pub fn socket(&self) -> PathBuf {
99 self.state().join("server.sock")
100 }
101
102 pub fn delegations(&self) -> PathBuf {
104 self.state().join("delegations")
105 }
106
107 pub fn conversations(&self) -> PathBuf {
109 self.state().join("conversations")
110 }
111
112 pub fn imports(&self) -> PathBuf {
114 self.state().join("imports")
115 }
116
117 pub fn channel_state(&self, channel: &str) -> PathBuf {
119 self.state().join("channels").join(channel)
120 }
121
122 pub fn media(&self) -> PathBuf {
125 self.state().join("media")
126 }
127
128 pub fn config_lock(&self) -> PathBuf {
130 self.state().join("config.lock")
131 }
132
133 pub fn strays(&self) -> Result<Vec<Stray>> {
135 let entries = match std::fs::read_dir(&self.home) {
136 Ok(entries) => entries,
137 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
138 Err(error) => {
139 return Err(error).with_context(|| format!("read {}", self.home.display()));
140 }
141 };
142 let mut strays = Vec::new();
143 for entry in entries {
144 let name = entry?.file_name();
145 let Some(text) = name.to_str() else {
146 strays.push(Stray {
147 path: self.home.join(&name),
148 legacy: false,
149 });
150 continue;
151 };
152 if ENTRIES.contains(&text) {
153 continue;
154 }
155 strays.push(Stray {
156 path: self.home.join(text),
157 legacy: LEGACY.contains(&text),
158 });
159 }
160 strays.sort_by(|a, b| a.path.cmp(&b.path));
161 Ok(strays)
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn every_path_lives_under_one_of_the_top_level_entries() {
171 let layout = Layout::new("/h");
172 for path in [
173 layout.config(),
174 layout.channel_credentials("wechat"),
175 layout.agent_home("codex"),
176 layout.skills(),
177 layout.socket(),
178 layout.delegations(),
179 layout.conversations(),
180 layout.imports(),
181 layout.channel_state("feishu"),
182 layout.config_lock(),
183 ] {
184 let top = path
185 .strip_prefix("/h")
186 .unwrap()
187 .components()
188 .next()
189 .unwrap();
190 let top = top.as_os_str().to_str().unwrap();
191 assert!(ENTRIES.contains(&top), "{}", path.display());
192 }
193 assert_eq!(layout.socket(), Path::new("/h/state/server.sock"));
194 }
195
196 #[test]
197 fn strays_name_old_layout_paths_and_unknown_files() {
198 let home = tempfile::tempdir().unwrap();
199 for directory in ["state", "agents", "adapters", "notes"] {
200 std::fs::create_dir(home.path().join(directory)).unwrap();
201 }
202 for file in ["config.toml", "server.sock", "config.toml.bak"] {
203 std::fs::write(home.path().join(file), "").unwrap();
204 }
205 let strays = Layout::new(home.path()).strays().unwrap();
206 let named: Vec<(String, bool)> = strays
207 .iter()
208 .map(|stray| {
209 (
210 stray
211 .path
212 .file_name()
213 .unwrap()
214 .to_string_lossy()
215 .into_owned(),
216 stray.legacy,
217 )
218 })
219 .collect();
220 assert_eq!(
221 named,
222 [
223 ("adapters".to_owned(), true),
224 ("config.toml.bak".to_owned(), false),
225 ("notes".to_owned(), false),
226 ("server.sock".to_owned(), true),
227 ]
228 );
229 assert!(
230 Layout::new(home.path().join("missing"))
231 .strays()
232 .unwrap()
233 .is_empty()
234 );
235 }
236}