Skip to main content

zeroclaw_log/
config.rs

1//! Policy types parsed from the runtime's observability config.
2//!
3//! `zeroclaw-log` defines its own minimal [`LogConfig`] shape so it does
4//! not depend on `zeroclaw-config`. Callers convert the full
5//! `zeroclaw_config::schema::ObservabilityConfig` into a [`LogConfig`]
6//! before calling [`crate::init_from_config`].
7
8use std::path::{Path, PathBuf};
9
10/// Minimal observability config shape used by the writer + tool-io
11/// capturer. Mirrors the relevant `[observability]` fields of
12/// `zeroclaw_config::schema::ObservabilityConfig`.
13#[derive(Debug, Clone)]
14pub struct LogConfig {
15    pub log_persistence: String,
16    pub log_persistence_path: String,
17    pub log_persistence_max_entries: usize,
18    pub log_tool_io: String,
19    pub log_tool_io_truncate_bytes: usize,
20    pub log_tool_io_denylist: Vec<String>,
21}
22
23impl Default for LogConfig {
24    fn default() -> Self {
25        Self {
26            log_persistence: "rolling".into(),
27            log_persistence_path: String::new(),
28            log_persistence_max_entries: 10_000,
29            log_tool_io: "redacted".into(),
30            log_tool_io_truncate_bytes: 2048,
31            log_tool_io_denylist: Vec::new(),
32        }
33    }
34}
35
36const DEFAULT_LOG_REL_PATH: &str = "state/runtime-trace.jsonl";
37
38/// JSONL persistence policy.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum StoragePolicy {
41    /// Do not persist; in-process broadcast only.
42    None,
43    /// Persist with rolling trim once `max_entries` is exceeded.
44    Rolling,
45    /// Persist all events forever (operator manages rotation).
46    Full,
47}
48
49impl StoragePolicy {
50    pub fn from_raw(raw: &str) -> Self {
51        match raw.trim().to_ascii_lowercase().as_str() {
52            "rolling" => Self::Rolling,
53            "full" => Self::Full,
54            _ => Self::None,
55        }
56    }
57
58    pub fn is_enabled(self) -> bool {
59        !matches!(self, Self::None)
60    }
61}
62
63/// Tool input/output capture policy.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ToolIoPolicy {
66    /// Tool name + outcome + duration only. No I/O bodies.
67    Off,
68    /// Leak-scan + truncate to `truncate_bytes`. Default.
69    Redacted,
70    /// Full I/O, still leak-scanned. No truncation.
71    Full,
72}
73
74impl ToolIoPolicy {
75    pub fn from_raw(raw: &str) -> Self {
76        match raw.trim().to_ascii_lowercase().as_str() {
77            "off" => Self::Off,
78            "full" => Self::Full,
79            _ => Self::Redacted,
80        }
81    }
82
83    pub fn captures_io(self) -> bool {
84        !matches!(self, Self::Off)
85    }
86}
87
88/// Resolved policy bundle the writer + tool-io capturers read at runtime.
89#[derive(Debug, Clone)]
90pub struct ResolvedPolicy {
91    pub storage: StoragePolicy,
92    pub path: PathBuf,
93    pub max_entries: usize,
94    pub tool_io: ToolIoPolicy,
95    pub tool_io_truncate_bytes: usize,
96    pub tool_io_denylist: Vec<String>,
97}
98
99impl ResolvedPolicy {
100    pub fn from_config(config: &LogConfig, workspace_dir: &Path) -> Self {
101        Self {
102            storage: StoragePolicy::from_raw(&config.log_persistence),
103            path: resolve_path(&config.log_persistence_path, workspace_dir),
104            max_entries: config.log_persistence_max_entries.max(1),
105            tool_io: ToolIoPolicy::from_raw(&config.log_tool_io),
106            tool_io_truncate_bytes: config.log_tool_io_truncate_bytes,
107            tool_io_denylist: config.log_tool_io_denylist.clone(),
108        }
109    }
110
111    pub fn is_tool_denylisted(&self, tool: &str) -> bool {
112        self.tool_io_denylist.iter().any(|t| t == tool)
113    }
114}
115
116fn resolve_path(raw: &str, workspace_dir: &Path) -> PathBuf {
117    let raw = raw.trim();
118    if raw.is_empty() {
119        return workspace_dir.join(DEFAULT_LOG_REL_PATH);
120    }
121    let p = PathBuf::from(raw);
122    if p.is_absolute() {
123        p
124    } else {
125        workspace_dir.join(p)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn make_config() -> LogConfig {
134        LogConfig::default()
135    }
136
137    #[test]
138    fn storage_policy_parses_known() {
139        assert_eq!(StoragePolicy::from_raw("none"), StoragePolicy::None);
140        assert_eq!(StoragePolicy::from_raw("rolling"), StoragePolicy::Rolling);
141        assert_eq!(StoragePolicy::from_raw("full"), StoragePolicy::Full);
142        assert_eq!(StoragePolicy::from_raw("xyz"), StoragePolicy::None);
143    }
144
145    #[test]
146    fn tool_io_policy_defaults_to_redacted() {
147        assert_eq!(ToolIoPolicy::from_raw(""), ToolIoPolicy::Redacted);
148        assert_eq!(ToolIoPolicy::from_raw("redacted"), ToolIoPolicy::Redacted);
149        assert_eq!(ToolIoPolicy::from_raw("off"), ToolIoPolicy::Off);
150        assert_eq!(ToolIoPolicy::from_raw("full"), ToolIoPolicy::Full);
151    }
152
153    #[test]
154    fn resolved_policy_uses_workspace_default_when_path_empty() {
155        let mut c = make_config();
156        c.log_persistence_path = String::new();
157        let tmp = tempfile::tempdir().unwrap();
158        let p = ResolvedPolicy::from_config(&c, tmp.path());
159        assert_eq!(p.path, tmp.path().join(DEFAULT_LOG_REL_PATH));
160    }
161
162    #[test]
163    fn resolved_policy_respects_denylist() {
164        let mut c = make_config();
165        c.log_tool_io_denylist = vec!["memory_recall_personal".to_string()];
166        let p = ResolvedPolicy::from_config(&c, std::path::Path::new("/"));
167        assert!(p.is_tool_denylisted("memory_recall_personal"));
168        assert!(!p.is_tool_denylisted("shell"));
169    }
170}