use std::collections::BTreeMap;
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use ferrin_spec::JsonValue;
use ferrin_spec::ToolName;
use serde_json::json;
use sha2::Digest;
use sha2::Sha256;
use crate::set::ToolSet;
use crate::tool::Description;
pub type ToolFingerprints = BTreeMap<ToolName, String>;
#[must_use]
pub fn canonical_json(value: &JsonValue) -> String {
let mut out = String::new();
write_canonical(value, &mut out);
out
}
fn write_canonical(value: &JsonValue, out: &mut String) {
match value {
JsonValue::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (index, key) in keys.iter().enumerate() {
if index > 0 {
out.push(',');
}
out.push_str(&JsonValue::String((*key).clone()).to_string());
out.push(':');
if let Some(child) = map.get(*key) {
write_canonical(child, out);
}
}
out.push('}');
}
JsonValue::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_canonical(item, out);
}
out.push(']');
}
other => out.push_str(&other.to_string()),
}
}
#[must_use]
pub fn hash_canonical(value: &JsonValue) -> String {
let digest = Sha256::digest(canonical_json(value).as_bytes());
BASE64_URL_SAFE_NO_PAD.encode(digest)
}
#[must_use]
pub fn fingerprint_tools(tools: &ToolSet) -> ToolFingerprints {
tools
.iter()
.map(|(name, tool)| {
let description = match tool.description() {
Some(Description::Static(text)) => json!({ "type": "string", "value": text }),
Some(Description::Dynamic(_)) => json!({ "type": "function" }),
#[allow(unreachable_patterns, reason = "Description is non-exhaustive")]
Some(_) => json!({ "type": "function" }),
None => json!({ "type": "none" }),
};
let mut record = serde_json::Map::new();
record.insert("description".to_owned(), description);
record.insert(
"inputSchema".to_owned(),
tool.input_schema().json_schema().clone(),
);
if let Some(title) = tool.title() {
record.insert("title".to_owned(), json!(title));
}
(name.clone(), hash_canonical(&JsonValue::Object(record)))
})
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolDrift {
pub added: Vec<ToolName>,
pub removed: Vec<ToolName>,
pub changed: Vec<ToolName>,
}
impl ToolDrift {
#[must_use]
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
}
}
#[must_use]
pub fn detect_tool_drift(current: &ToolFingerprints, baseline: &ToolFingerprints) -> ToolDrift {
let mut drift = ToolDrift::default();
for (name, digest) in current {
match baseline.get(name) {
None => drift.added.push(name.clone()),
Some(previous) if previous != digest => drift.changed.push(name.clone()),
Some(_) => {}
}
}
for name in baseline.keys() {
if !current.contains_key(name) {
drift.removed.push(name.clone());
}
}
drift
}