1use serde::{Deserialize, Serialize};
16
17use crate::error::{Result, SdkError};
18use crate::scheduler::{Footprint, Resource};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum AccessMode {
24 Read,
25 Write,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case", tag = "kind")]
32pub enum ResourceSelector {
33 Literal {
35 resource: Resource,
36 access: AccessMode,
37 },
38 PathArgument { field: String, access: AccessMode },
40 ParentManifestOf { field: String, access: AccessMode },
43 ScopedArgument {
45 family: String,
46 field: String,
47 access: AccessMode,
48 },
49 ProviderRateLimit,
52 OpaqueWorkspace,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct FootprintSpec {
60 pub selectors: Vec<ResourceSelector>,
61}
62
63impl FootprintSpec {
64 pub fn new(selectors: Vec<ResourceSelector>) -> Self {
66 Self { selectors }
67 }
68
69 pub fn opaque() -> Self {
71 Self::new(vec![ResourceSelector::OpaqueWorkspace])
72 }
73
74 pub fn validate(&self, schema: &serde_json::Value, mutates: bool) -> Result<()> {
80 if mutates && self.selectors.is_empty() {
81 return Err(SdkError::Domain(
82 "a mutating tool must declare a footprint or opt into OpaqueWorkspace".into(),
83 ));
84 }
85 let properties = schema.get("properties").and_then(|p| p.as_object());
86 for selector in &self.selectors {
87 let field = match selector {
88 ResourceSelector::PathArgument { field, .. }
89 | ResourceSelector::ParentManifestOf { field, .. }
90 | ResourceSelector::ScopedArgument { field, .. } => field,
91 _ => continue,
92 };
93 let declared = properties.map(|p| p.contains_key(field)).unwrap_or(false);
94 if !declared {
95 return Err(SdkError::Domain(format!(
96 "footprint selector references argument {field:?} that the tool schema \
97 does not declare"
98 )));
99 }
100 }
101 Ok(())
102 }
103
104 pub fn resolve(&self, arguments: &serde_json::Value, provider: &str) -> Footprint {
112 let mut footprint = Footprint::new();
113 for selector in &self.selectors {
114 match selector {
115 ResourceSelector::Literal { resource, access } => {
116 footprint = add(footprint, resource.clone(), *access);
117 }
118 ResourceSelector::PathArgument { field, access } => {
119 match string_argument(arguments, field) {
120 FieldValue::Present(path) => {
121 footprint = add(footprint, Resource::File(path), *access);
122 }
123 FieldValue::Absent => {}
124 FieldValue::NotAString => return opaque_footprint(),
125 }
126 }
127 ResourceSelector::ParentManifestOf { field, access } => {
128 match string_argument(arguments, field) {
129 FieldValue::Present(path) => {
130 let manifest = parent_manifest(&path);
131 footprint = add(footprint, Resource::Manifest(manifest), *access);
132 }
133 FieldValue::Absent => {}
134 FieldValue::NotAString => return opaque_footprint(),
135 }
136 }
137 ResourceSelector::ScopedArgument {
138 family,
139 field,
140 access,
141 } => match scalar_argument(arguments, field) {
142 FieldValue::Present(key) => {
143 footprint = add(
144 footprint,
145 Resource::Scoped {
146 family: family.clone(),
147 key,
148 },
149 *access,
150 );
151 }
152 FieldValue::Absent => {}
153 FieldValue::NotAString => return opaque_footprint(),
154 },
155 ResourceSelector::ProviderRateLimit => {
156 footprint = add(
157 footprint,
158 Resource::ProviderRateLimit(provider.into()),
159 AccessMode::Read,
160 );
161 }
162 ResourceSelector::OpaqueWorkspace => return opaque_footprint(),
163 }
164 }
165 footprint
166 }
167}
168
169enum FieldValue {
170 Present(String),
171 Absent,
172 NotAString,
173}
174
175fn string_argument(arguments: &serde_json::Value, field: &str) -> FieldValue {
176 match arguments.get(field) {
177 None => FieldValue::Absent,
178 Some(value) => match value.as_str() {
179 Some(s) => FieldValue::Present(s.to_string()),
180 None => FieldValue::NotAString,
181 },
182 }
183}
184
185fn scalar_argument(arguments: &serde_json::Value, field: &str) -> FieldValue {
186 match arguments.get(field) {
187 None => FieldValue::Absent,
188 Some(serde_json::Value::String(value)) => FieldValue::Present(value.clone()),
189 Some(serde_json::Value::Number(value)) => FieldValue::Present(value.to_string()),
190 Some(serde_json::Value::Bool(value)) => FieldValue::Present(value.to_string()),
191 Some(_) => FieldValue::NotAString,
192 }
193}
194
195fn add(footprint: Footprint, resource: Resource, access: AccessMode) -> Footprint {
196 match access {
197 AccessMode::Read => footprint.read(resource),
198 AccessMode::Write => footprint.read(resource.clone()).write(resource),
200 }
201}
202
203fn opaque_footprint() -> Footprint {
204 Footprint::new()
205 .read(Resource::OpaqueWorkspace)
206 .write(Resource::OpaqueWorkspace)
207}
208
209fn parent_manifest(path: &str) -> String {
212 match path.rsplit_once('/') {
213 Some((dir, _)) => dir.to_string(),
214 None => ".".to_string(),
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn schema() -> serde_json::Value {
223 serde_json::json!({
224 "type": "object",
225 "properties": {"path": {"type": "string"}},
226 "required": ["path"],
227 })
228 }
229
230 #[test]
231 fn writes_are_contained_in_reads_by_construction() {
232 let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
233 field: "path".into(),
234 access: AccessMode::Write,
235 }]);
236 let footprint = spec.resolve(&serde_json::json!({"path": "src/lib.rs"}), "p");
237 assert!(footprint.writes.iter().all(|w| footprint.reads.contains(w)));
238 assert!(footprint
239 .writes
240 .contains(&Resource::File("src/lib.rs".into())));
241 }
242
243 #[test]
244 fn validation_rejects_a_selector_for_an_undeclared_argument() {
245 let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
246 field: "nonexistent".into(),
247 access: AccessMode::Read,
248 }]);
249 assert!(spec.validate(&schema(), false).is_err());
250 }
251
252 #[test]
253 fn a_mutating_tool_may_not_declare_an_empty_footprint() {
254 assert!(FootprintSpec::default().validate(&schema(), true).is_err());
255 assert!(FootprintSpec::opaque().validate(&schema(), true).is_ok());
256 }
257
258 #[test]
259 fn a_non_string_path_argument_fails_closed_to_opaque() {
260 let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
261 field: "path".into(),
262 access: AccessMode::Write,
263 }]);
264 let footprint = spec.resolve(&serde_json::json!({"path": ["not", "a", "string"]}), "p");
265 assert!(footprint.writes.contains(&Resource::OpaqueWorkspace));
266 }
267
268 #[test]
269 fn rate_limit_is_a_read_never_a_serializing_write() {
270 let spec = FootprintSpec::new(vec![ResourceSelector::ProviderRateLimit]);
271 let footprint = spec.resolve(&serde_json::json!({}), "anthropic");
272 assert!(footprint.writes.is_empty());
273 assert!(footprint
274 .reads
275 .contains(&Resource::ProviderRateLimit("anthropic".into())));
276 }
277
278 #[test]
279 fn two_rate_limited_turns_commute() {
280 let spec = FootprintSpec::new(vec![ResourceSelector::ProviderRateLimit]);
281 let a = spec.resolve(&serde_json::json!({}), "anthropic");
282 let b = spec.resolve(&serde_json::json!({}), "anthropic");
283 assert!(
284 a.commutes_with(&b),
285 "a rate-limit delay is not a serialization"
286 );
287 }
288}