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 config_lock(&self) -> PathBuf {
124 self.state().join("config.lock")
125 }
126
127 pub fn strays(&self) -> Result<Vec<Stray>> {
129 let entries = match std::fs::read_dir(&self.home) {
130 Ok(entries) => entries,
131 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
132 Err(error) => {
133 return Err(error).with_context(|| format!("read {}", self.home.display()));
134 }
135 };
136 let mut strays = Vec::new();
137 for entry in entries {
138 let name = entry?.file_name();
139 let Some(text) = name.to_str() else {
140 strays.push(Stray {
141 path: self.home.join(&name),
142 legacy: false,
143 });
144 continue;
145 };
146 if ENTRIES.contains(&text) {
147 continue;
148 }
149 strays.push(Stray {
150 path: self.home.join(text),
151 legacy: LEGACY.contains(&text),
152 });
153 }
154 strays.sort_by(|a, b| a.path.cmp(&b.path));
155 Ok(strays)
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn every_path_lives_under_one_of_the_top_level_entries() {
165 let layout = Layout::new("/h");
166 for path in [
167 layout.config(),
168 layout.channel_credentials("wechat"),
169 layout.agent_home("codex"),
170 layout.skills(),
171 layout.socket(),
172 layout.delegations(),
173 layout.conversations(),
174 layout.imports(),
175 layout.channel_state("feishu"),
176 layout.config_lock(),
177 ] {
178 let top = path
179 .strip_prefix("/h")
180 .unwrap()
181 .components()
182 .next()
183 .unwrap();
184 let top = top.as_os_str().to_str().unwrap();
185 assert!(ENTRIES.contains(&top), "{}", path.display());
186 }
187 assert_eq!(layout.socket(), Path::new("/h/state/server.sock"));
188 }
189
190 #[test]
191 fn strays_name_old_layout_paths_and_unknown_files() {
192 let home = tempfile::tempdir().unwrap();
193 for directory in ["state", "agents", "adapters", "notes"] {
194 std::fs::create_dir(home.path().join(directory)).unwrap();
195 }
196 for file in ["config.toml", "server.sock", "config.toml.bak"] {
197 std::fs::write(home.path().join(file), "").unwrap();
198 }
199 let strays = Layout::new(home.path()).strays().unwrap();
200 let named: Vec<(String, bool)> = strays
201 .iter()
202 .map(|stray| {
203 (
204 stray
205 .path
206 .file_name()
207 .unwrap()
208 .to_string_lossy()
209 .into_owned(),
210 stray.legacy,
211 )
212 })
213 .collect();
214 assert_eq!(
215 named,
216 [
217 ("adapters".to_owned(), true),
218 ("config.toml.bak".to_owned(), false),
219 ("notes".to_owned(), false),
220 ("server.sock".to_owned(), true),
221 ]
222 );
223 assert!(
224 Layout::new(home.path().join("missing"))
225 .strays()
226 .unwrap()
227 .is_empty()
228 );
229 }
230}