use serde::{Deserialize, Serialize};
use crate::error::{Result, SdkError};
use crate::scheduler::{Footprint, Resource};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccessMode {
Read,
Write,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum ResourceSelector {
Literal {
resource: Resource,
access: AccessMode,
},
PathArgument { field: String, access: AccessMode },
ParentManifestOf { field: String, access: AccessMode },
ScopedArgument {
family: String,
field: String,
access: AccessMode,
},
ProviderRateLimit,
OpaqueWorkspace,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FootprintSpec {
pub selectors: Vec<ResourceSelector>,
}
impl FootprintSpec {
pub fn new(selectors: Vec<ResourceSelector>) -> Self {
Self { selectors }
}
pub fn opaque() -> Self {
Self::new(vec![ResourceSelector::OpaqueWorkspace])
}
pub fn validate(&self, schema: &serde_json::Value, mutates: bool) -> Result<()> {
if mutates && self.selectors.is_empty() {
return Err(SdkError::Domain(
"a mutating tool must declare a footprint or opt into OpaqueWorkspace".into(),
));
}
let properties = schema.get("properties").and_then(|p| p.as_object());
for selector in &self.selectors {
let field = match selector {
ResourceSelector::PathArgument { field, .. }
| ResourceSelector::ParentManifestOf { field, .. }
| ResourceSelector::ScopedArgument { field, .. } => field,
_ => continue,
};
let declared = properties.map(|p| p.contains_key(field)).unwrap_or(false);
if !declared {
return Err(SdkError::Domain(format!(
"footprint selector references argument {field:?} that the tool schema \
does not declare"
)));
}
}
Ok(())
}
pub fn resolve(&self, arguments: &serde_json::Value, provider: &str) -> Footprint {
let mut footprint = Footprint::new();
for selector in &self.selectors {
match selector {
ResourceSelector::Literal { resource, access } => {
footprint = add(footprint, resource.clone(), *access);
}
ResourceSelector::PathArgument { field, access } => {
match string_argument(arguments, field) {
FieldValue::Present(path) => {
footprint = add(footprint, Resource::File(path), *access);
}
FieldValue::Absent => {}
FieldValue::NotAString => return opaque_footprint(),
}
}
ResourceSelector::ParentManifestOf { field, access } => {
match string_argument(arguments, field) {
FieldValue::Present(path) => {
let manifest = parent_manifest(&path);
footprint = add(footprint, Resource::Manifest(manifest), *access);
}
FieldValue::Absent => {}
FieldValue::NotAString => return opaque_footprint(),
}
}
ResourceSelector::ScopedArgument {
family,
field,
access,
} => match scalar_argument(arguments, field) {
FieldValue::Present(key) => {
footprint = add(
footprint,
Resource::Scoped {
family: family.clone(),
key,
},
*access,
);
}
FieldValue::Absent => {}
FieldValue::NotAString => return opaque_footprint(),
},
ResourceSelector::ProviderRateLimit => {
footprint = add(
footprint,
Resource::ProviderRateLimit(provider.into()),
AccessMode::Read,
);
}
ResourceSelector::OpaqueWorkspace => return opaque_footprint(),
}
}
footprint
}
}
enum FieldValue {
Present(String),
Absent,
NotAString,
}
fn string_argument(arguments: &serde_json::Value, field: &str) -> FieldValue {
match arguments.get(field) {
None => FieldValue::Absent,
Some(value) => match value.as_str() {
Some(s) => FieldValue::Present(s.to_string()),
None => FieldValue::NotAString,
},
}
}
fn scalar_argument(arguments: &serde_json::Value, field: &str) -> FieldValue {
match arguments.get(field) {
None => FieldValue::Absent,
Some(serde_json::Value::String(value)) => FieldValue::Present(value.clone()),
Some(serde_json::Value::Number(value)) => FieldValue::Present(value.to_string()),
Some(serde_json::Value::Bool(value)) => FieldValue::Present(value.to_string()),
Some(_) => FieldValue::NotAString,
}
}
fn add(footprint: Footprint, resource: Resource, access: AccessMode) -> Footprint {
match access {
AccessMode::Read => footprint.read(resource),
AccessMode::Write => footprint.read(resource.clone()).write(resource),
}
}
fn opaque_footprint() -> Footprint {
Footprint::new()
.read(Resource::OpaqueWorkspace)
.write(Resource::OpaqueWorkspace)
}
fn parent_manifest(path: &str) -> String {
match path.rsplit_once('/') {
Some((dir, _)) => dir.to_string(),
None => ".".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
})
}
#[test]
fn writes_are_contained_in_reads_by_construction() {
let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
field: "path".into(),
access: AccessMode::Write,
}]);
let footprint = spec.resolve(&serde_json::json!({"path": "src/lib.rs"}), "p");
assert!(footprint.writes.iter().all(|w| footprint.reads.contains(w)));
assert!(footprint
.writes
.contains(&Resource::File("src/lib.rs".into())));
}
#[test]
fn validation_rejects_a_selector_for_an_undeclared_argument() {
let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
field: "nonexistent".into(),
access: AccessMode::Read,
}]);
assert!(spec.validate(&schema(), false).is_err());
}
#[test]
fn a_mutating_tool_may_not_declare_an_empty_footprint() {
assert!(FootprintSpec::default().validate(&schema(), true).is_err());
assert!(FootprintSpec::opaque().validate(&schema(), true).is_ok());
}
#[test]
fn a_non_string_path_argument_fails_closed_to_opaque() {
let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
field: "path".into(),
access: AccessMode::Write,
}]);
let footprint = spec.resolve(&serde_json::json!({"path": ["not", "a", "string"]}), "p");
assert!(footprint.writes.contains(&Resource::OpaqueWorkspace));
}
#[test]
fn rate_limit_is_a_read_never_a_serializing_write() {
let spec = FootprintSpec::new(vec![ResourceSelector::ProviderRateLimit]);
let footprint = spec.resolve(&serde_json::json!({}), "anthropic");
assert!(footprint.writes.is_empty());
assert!(footprint
.reads
.contains(&Resource::ProviderRateLimit("anthropic".into())));
}
#[test]
fn two_rate_limited_turns_commute() {
let spec = FootprintSpec::new(vec![ResourceSelector::ProviderRateLimit]);
let a = spec.resolve(&serde_json::json!({}), "anthropic");
let b = spec.resolve(&serde_json::json!({}), "anthropic");
assert!(
a.commutes_with(&b),
"a rate-limit delay is not a serialization"
);
}
}