1use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12use zeroclaw_macros::Configurable;
13
14crate::define_provider_ref!(AgentAlias, "agents");
15crate::define_provider_ref!(PeerGroupName, "peer_groups");
16crate::define_provider_ref!(PeerUsername, "channels.peers");
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
25#[serde(rename_all = "snake_case")]
26pub enum AccessMode {
27 Read,
29 Write,
31 ReadWrite,
34}
35
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
47#[serde(rename_all = "snake_case")]
48pub enum MemoryBackendKind {
49 None,
51 #[default]
55 Sqlite,
56 Postgres,
59 Qdrant,
61 Markdown,
64 Lucid,
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
85#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
86#[prefix = "agent_workspace"]
87#[serde(default)]
88pub struct AgentWorkspaceConfig {
89 #[serde(skip_serializing_if = "Option::is_none")]
92 pub path: Option<PathBuf>,
93 pub access: BTreeMap<AgentAlias, AccessMode>,
97 pub unrestricted_filesystem: bool,
101 pub read_memory_from: Vec<AgentAlias>,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
116#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
117#[prefix = "agent_memory"]
118#[serde(default)]
119pub struct AgentMemoryConfig {
120 pub backend: MemoryBackendKind,
123}
124
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
132#[serde(rename_all = "snake_case")]
133pub enum OutputModality {
134 #[default]
136 Mirror,
137 Voice,
140 Text,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
146#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
147#[prefix = "peer_group"]
148#[serde(default)]
149pub struct PeerGroupConfig {
150 pub channel: String,
154 pub agents: Vec<AgentAlias>,
156 pub external_peers: Vec<PeerUsername>,
158 pub ignore: Vec<PeerUsername>,
160 pub output_modality: OutputModality,
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn agent_alias_round_trips_through_serde() {
173 let alias = AgentAlias::new("researcher");
177 let json = serde_json::to_string(&alias).unwrap();
178 assert_eq!(json, "\"researcher\"");
179 let back: AgentAlias = serde_json::from_str(&json).unwrap();
180 assert_eq!(alias, back);
181 }
182
183 #[test]
184 fn access_mode_serializes_snake_case() {
185 let cases = [
186 (AccessMode::Read, "\"read\""),
187 (AccessMode::Write, "\"write\""),
188 (AccessMode::ReadWrite, "\"read_write\""),
189 ];
190 for (mode, expected) in cases {
191 let json = serde_json::to_string(&mode).unwrap();
192 assert_eq!(json, expected, "mode={mode:?}");
193 let back: AccessMode = serde_json::from_str(&json).unwrap();
194 assert_eq!(back, mode);
195 }
196 }
197
198 #[test]
199 fn external_peers_round_trip_as_inline_string_array() {
200 let toml_input = r#"
201external_peers = ["@user_1", "@user_2"]
202"#;
203 #[derive(Deserialize)]
204 struct Wrapper {
205 external_peers: Vec<PeerUsername>,
206 }
207 let parsed: Wrapper = toml::from_str(toml_input).unwrap();
208 assert_eq!(parsed.external_peers.len(), 2);
209 assert_eq!(parsed.external_peers[0].as_str(), "@user_1");
210 assert_eq!(parsed.external_peers[1].as_str(), "@user_2");
211 }
212
213 #[test]
214 fn alias_newtypes_are_distinct_at_type_level() {
215 let agent = AgentAlias::new("alpha");
218 let group: PeerGroupName = PeerGroupName::new(agent.as_str());
219 assert_eq!(agent.as_str(), group.as_str());
220 }
221
222 #[test]
223 fn memory_backend_kind_serializes_snake_case() {
224 let cases = [
225 (MemoryBackendKind::None, "\"none\""),
226 (MemoryBackendKind::Sqlite, "\"sqlite\""),
227 (MemoryBackendKind::Postgres, "\"postgres\""),
228 (MemoryBackendKind::Qdrant, "\"qdrant\""),
229 (MemoryBackendKind::Markdown, "\"markdown\""),
230 (MemoryBackendKind::Lucid, "\"lucid\""),
231 ];
232 for (kind, expected) in cases {
233 let json = serde_json::to_string(&kind).unwrap();
234 assert_eq!(json, expected, "backend={kind:?}");
235 let back: MemoryBackendKind = serde_json::from_str(&json).unwrap();
236 assert_eq!(back, kind);
237 }
238 }
239
240 #[test]
241 fn memory_backend_kind_default_is_sqlite() {
242 assert_eq!(MemoryBackendKind::default(), MemoryBackendKind::Sqlite);
243 }
244
245 #[test]
246 fn agent_workspace_config_round_trips_with_access_map() {
247 let toml_input = r#"
248unrestricted_filesystem = false
249read_memory_from = ["beta"]
250
251[access]
252beta = "read"
253gamma = "read_write"
254"#;
255 let parsed: AgentWorkspaceConfig = toml::from_str(toml_input).unwrap();
256 assert_eq!(parsed.path, None);
257 assert!(!parsed.unrestricted_filesystem);
258 assert_eq!(parsed.read_memory_from.len(), 1);
259 assert_eq!(parsed.read_memory_from[0], "beta");
260 assert_eq!(parsed.access.len(), 2);
261 let beta = AgentAlias::new("beta");
262 let gamma = AgentAlias::new("gamma");
263 assert_eq!(parsed.access.get(&beta), Some(&AccessMode::Read));
264 assert_eq!(parsed.access.get(&gamma), Some(&AccessMode::ReadWrite));
265 }
266
267 #[test]
268 fn agent_workspace_config_default_is_jailed() {
269 let cfg = AgentWorkspaceConfig::default();
270 assert_eq!(cfg.path, None);
271 assert!(cfg.access.is_empty());
272 assert!(!cfg.unrestricted_filesystem);
273 assert!(cfg.read_memory_from.is_empty());
274 }
275
276 #[test]
277 fn agent_memory_config_round_trips() {
278 let toml_input = r#"backend = "postgres""#;
279 let parsed: AgentMemoryConfig = toml::from_str(toml_input).unwrap();
280 assert_eq!(parsed.backend, MemoryBackendKind::Postgres);
281 }
282
283 #[test]
284 fn agent_memory_config_default_is_sqlite() {
285 assert_eq!(
286 AgentMemoryConfig::default().backend,
287 MemoryBackendKind::Sqlite
288 );
289 }
290
291 #[test]
292 fn peer_group_config_round_trips_with_external_peers_and_ignore() {
293 let toml_input = r#"
294channel = "telegram.prod"
295agents = ["alpha", "beta"]
296external_peers = ["@user_1", "@user_2"]
297ignore = ["@known_spammer"]
298"#;
299 let parsed: PeerGroupConfig = toml::from_str(toml_input).unwrap();
300 assert_eq!(parsed.channel, "telegram.prod");
301 assert_eq!(parsed.agents.len(), 2);
302 assert_eq!(parsed.agents[0], "alpha");
303 assert_eq!(parsed.agents[1], "beta");
304 assert_eq!(parsed.external_peers.len(), 2);
305 assert_eq!(parsed.external_peers[0].as_str(), "@user_1");
306 assert_eq!(parsed.ignore.len(), 1);
307 assert_eq!(parsed.ignore[0].as_str(), "@known_spammer");
308 }
309
310 #[test]
311 fn peer_group_config_default_is_empty() {
312 let cfg = PeerGroupConfig::default();
313 assert!(cfg.channel.is_empty());
314 assert!(cfg.agents.is_empty());
315 assert!(cfg.external_peers.is_empty());
316 assert!(cfg.ignore.is_empty());
317 assert_eq!(cfg.output_modality, OutputModality::Mirror);
319 }
320
321 #[test]
322 fn output_modality_serializes_snake_case() {
323 let cases = [
324 (OutputModality::Mirror, "\"mirror\""),
325 (OutputModality::Voice, "\"voice\""),
326 (OutputModality::Text, "\"text\""),
327 ];
328 for (modality, expected) in cases {
329 let json = serde_json::to_string(&modality).unwrap();
330 assert_eq!(json, expected, "modality={modality:?}");
331 let back: OutputModality = serde_json::from_str(&json).unwrap();
332 assert_eq!(back, modality);
333 }
334 }
335
336 #[test]
337 fn peer_group_output_modality_parses_voice_and_defaults_to_mirror() {
338 let with_voice: PeerGroupConfig = toml::from_str(
339 r#"
340channel = "telegram"
341external_peers = ["@alice"]
342output_modality = "voice"
343"#,
344 )
345 .unwrap();
346 assert_eq!(with_voice.output_modality, OutputModality::Voice);
347 assert_eq!(with_voice.external_peers[0].as_str(), "@alice");
348
349 let defaulted: PeerGroupConfig = toml::from_str(r#"channel = "telegram""#).unwrap();
351 assert_eq!(defaulted.output_modality, OutputModality::Mirror);
352 }
353}