1use serde::{Deserialize, Serialize};
2use std::collections::BTreeSet;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
6#[serde(tag = "effect")]
7pub enum Effect {
8 Cost {
9 cents: u64,
10 },
11 Fallible,
12 FsRead {
20 path: PathBuf,
21 },
22 FsWrite {
32 path: PathBuf,
33 },
34 Llm {
35 model: String,
36 },
37 Network,
38 NonDeterministic,
39 Process,
41 Pure,
42 Unknown,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum EffectKind {
52 Cost,
53 Fallible,
54 FsRead,
55 FsWrite,
56 Llm,
57 Network,
58 NonDeterministic,
59 Process,
60 Pure,
61 Unknown,
62}
63
64impl Effect {
65 pub fn kind(&self) -> EffectKind {
68 match self {
69 Effect::Cost { .. } => EffectKind::Cost,
70 Effect::Fallible => EffectKind::Fallible,
71 Effect::FsRead { .. } => EffectKind::FsRead,
72 Effect::FsWrite { .. } => EffectKind::FsWrite,
73 Effect::Llm { .. } => EffectKind::Llm,
74 Effect::Network => EffectKind::Network,
75 Effect::NonDeterministic => EffectKind::NonDeterministic,
76 Effect::Process => EffectKind::Process,
77 Effect::Pure => EffectKind::Pure,
78 Effect::Unknown => EffectKind::Unknown,
79 }
80 }
81}
82
83impl std::fmt::Display for EffectKind {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 let s = match self {
86 EffectKind::Cost => "cost",
87 EffectKind::Fallible => "fallible",
88 EffectKind::FsRead => "fs-read",
89 EffectKind::FsWrite => "fs-write",
90 EffectKind::Llm => "llm",
91 EffectKind::Network => "network",
92 EffectKind::NonDeterministic => "non-deterministic",
93 EffectKind::Process => "process",
94 EffectKind::Pure => "pure",
95 EffectKind::Unknown => "unknown",
96 };
97 write!(f, "{s}")
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct EffectSet {
107 effects: BTreeSet<Effect>,
108}
109
110impl EffectSet {
111 pub fn unknown() -> Self {
112 Self {
113 effects: BTreeSet::from([Effect::Unknown]),
114 }
115 }
116
117 pub fn pure() -> Self {
118 Self {
119 effects: BTreeSet::from([Effect::Pure]),
120 }
121 }
122
123 pub fn new(effects: impl IntoIterator<Item = Effect>) -> Self {
124 Self {
125 effects: effects.into_iter().collect(),
126 }
127 }
128
129 pub fn contains(&self, effect: &Effect) -> bool {
130 self.effects.contains(effect)
131 }
132
133 pub fn is_unknown(&self) -> bool {
134 self.effects.contains(&Effect::Unknown)
135 }
136
137 pub fn iter(&self) -> impl Iterator<Item = &Effect> {
138 self.effects.iter()
139 }
140}
141
142impl Default for EffectSet {
143 fn default() -> Self {
144 Self::unknown()
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn default_is_unknown() {
154 let es = EffectSet::default();
155 assert!(es.is_unknown());
156 assert!(es.contains(&Effect::Unknown));
157 }
158
159 #[test]
160 fn pure_does_not_contain_unknown() {
161 let es = EffectSet::pure();
162 assert!(!es.is_unknown());
163 assert!(es.contains(&Effect::Pure));
164 }
165
166 #[test]
167 fn serde_round_trip() {
168 let es = EffectSet::new([
169 Effect::Network,
170 Effect::Fallible,
171 Effect::Llm {
172 model: "claude-sonnet-4".into(),
173 },
174 ]);
175 let json = serde_json::to_string(&es).unwrap();
176 let deserialized: EffectSet = serde_json::from_str(&json).unwrap();
177 assert_eq!(es, deserialized);
178 }
179
180 #[test]
181 fn fs_effects_round_trip_through_json() {
182 let es = EffectSet::new([
188 Effect::Pure,
189 Effect::FsRead {
190 path: PathBuf::from("/etc/ssl/certs"),
191 },
192 Effect::FsWrite {
193 path: PathBuf::from("/tmp/agent-output"),
194 },
195 ]);
196 let json = serde_json::to_string(&es).unwrap();
197 assert!(
202 json.contains(r#""effect":"FsRead""#),
203 "expected FsRead tag in wire: {json}"
204 );
205 assert!(
206 json.contains(r#""effect":"FsWrite""#),
207 "expected FsWrite tag in wire: {json}"
208 );
209 let deserialized: EffectSet = serde_json::from_str(&json).unwrap();
210 assert_eq!(es, deserialized);
211 }
212
213 #[test]
214 fn fs_effect_kinds_map_one_to_one() {
215 let read = Effect::FsRead {
216 path: PathBuf::from("/a"),
217 };
218 let write = Effect::FsWrite {
219 path: PathBuf::from("/b"),
220 };
221 assert_eq!(read.kind(), EffectKind::FsRead);
222 assert_eq!(write.kind(), EffectKind::FsWrite);
223 assert_eq!(EffectKind::FsRead.to_string(), "fs-read");
225 assert_eq!(EffectKind::FsWrite.to_string(), "fs-write");
226 }
227
228 #[test]
229 fn distinct_fs_read_paths_are_distinct_elements() {
230 let es = EffectSet::new([
234 Effect::FsRead {
235 path: PathBuf::from("/etc"),
236 },
237 Effect::FsRead {
238 path: PathBuf::from("/home"),
239 },
240 ]);
241 assert_eq!(es.iter().count(), 2);
242 }
243}