Skip to main content

scv_client/
layout.rs

1//! Where an SCV instance keeps everything, in one place.
2//!
3//! An instance home (`SCV_HOME`, default `~/.scv`) holds exactly:
4//!
5//! - `config.toml`: every setting a person edits, including channel accounts;
6//! - `credentials/`: sign-ins SCV writes itself (channel logins);
7//! - `agents/<name>/`: the private homes of delegated agent CLIs, which keep
8//!   their own sign-ins and configuration there;
9//! - `skills/`: the user's SCV skills;
10//! - `state/`: runtime data SCV writes: the daemon socket and lock, delegated
11//!   run records, conversation markers, import records, and channel delivery
12//!   state and locks.
13//!
14//! Anything else in the home is not read by SCV; [`Layout::strays`] lists it.
15
16use anyhow::{Context, Result};
17use std::path::{Path, PathBuf};
18
19/// Top-level entries of an instance home, in display order.
20pub const ENTRIES: [&str; 5] = ["config.toml", "credentials", "agents", "skills", "state"];
21
22/// Paths earlier releases used, which SCV no longer reads.
23const LEGACY: [&str; 7] = [
24    "adapters",
25    "channels",
26    "run",
27    "server.sock",
28    "server.lock",
29    "clawbot",
30    "clawbot.toml",
31];
32
33/// The paths of one SCV instance.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Layout {
36    home: PathBuf,
37}
38
39/// Something in an instance home that SCV does not read.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Stray {
42    pub path: PathBuf,
43    /// A path an earlier SCV release used, rather than an unknown file.
44    pub legacy: bool,
45}
46
47impl Layout {
48    pub fn new(home: impl Into<PathBuf>) -> Self {
49        Self { home: home.into() }
50    }
51
52    /// The instance selected by `SCV_HOME`, or `~/.scv`.
53    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    /// The settings file a person edits.
66    pub fn config(&self) -> PathBuf {
67        self.home.join("config.toml")
68    }
69
70    /// Sign-ins SCV writes itself.
71    pub fn credentials(&self) -> PathBuf {
72        self.home.join("credentials")
73    }
74
75    /// One channel's account credentials, `<account>.json` each.
76    pub fn channel_credentials(&self, channel: &str) -> PathBuf {
77        self.credentials().join(channel)
78    }
79
80    /// The private homes of delegated agent CLIs.
81    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    /// Runtime data SCV writes and reads back; never edited by hand.
94    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    /// Records of running delegated agents.
103    pub fn delegations(&self) -> PathBuf {
104        self.state().join("delegations")
105    }
106
107    /// Markers of live delegated conversations, for `scv agents gc`.
108    pub fn conversations(&self) -> PathBuf {
109        self.state().join("conversations")
110    }
111
112    /// What each `scv agents import` copied, and from where.
113    pub fn imports(&self) -> PathBuf {
114        self.state().join("imports")
115    }
116
117    /// One channel's delivery state and account locks.
118    pub fn channel_state(&self, channel: &str) -> PathBuf {
119        self.state().join("channels").join(channel)
120    }
121
122    /// Serializes SCV's own edits of `config.toml`.
123    pub fn config_lock(&self) -> PathBuf {
124        self.state().join("config.lock")
125    }
126
127    /// Entries of the home that SCV does not read, sorted by name.
128    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}