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, channel delivery
12//!   state and locks, and chat media.
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    /// Files chat users sent, under `<channel>/<account>`, and copies of files
123    /// the model sends back, under `outbox`.
124    pub fn media(&self) -> PathBuf {
125        self.state().join("media")
126    }
127
128    /// Serializes SCV's own edits of `config.toml`.
129    pub fn config_lock(&self) -> PathBuf {
130        self.state().join("config.lock")
131    }
132
133    /// Entries of the home that SCV does not read, sorted by name.
134    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}