use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::client::HfClient;
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiCompatState {
pub api: ApiInfo,
#[serde(default)]
pub endpoints: HashMap<String, EndpointInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiInfo {
pub openapi_sha256: String,
pub last_checked: String,
pub spec_version: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EndpointInfo {
pub expected_fields: Vec<String>,
pub last_verified: String,
}
#[derive(Debug)]
pub enum ApiCompatLevel {
Ok,
Warn(Vec<String>),
Error(Vec<String>),
FirstRun,
}
impl fmt::Display for ApiCompatLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ok => write!(f, "API compatible (spec unchanged)"),
Self::Warn(msgs) => {
write!(f, "API warnings:")?;
for m in msgs {
write!(f, "\n - {}", m)?;
}
Ok(())
}
Self::Error(msgs) => {
write!(f, "API breaking changes:")?;
for m in msgs {
write!(f, "\n - {}", m)?;
}
Ok(())
}
Self::FirstRun => write!(f, "First run — API baseline recorded"),
}
}
}
fn compat_state_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from(".config"))
.join("securegit")
.join("hf_api_compat.toml")
}
pub fn load_state() -> Option<ApiCompatState> {
let path = compat_state_path();
let contents = std::fs::read_to_string(path).ok()?;
toml::from_str(&contents).ok()
}
pub fn save_state(state: &ApiCompatState) -> Result<()> {
let path = compat_state_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let serialized =
toml::to_string_pretty(state).context("Failed to serialize API compat state")?;
std::fs::write(&path, serialized)
.with_context(|| format!("Failed to write API compat state to {}", path.display()))?;
Ok(())
}
pub fn hash_spec(spec: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(spec.as_bytes());
hex::encode(hasher.finalize())
}
pub fn is_stale(state: &ApiCompatState, max_age_hours: u64) -> bool {
let last = match chrono::DateTime::parse_from_rfc3339(&state.api.last_checked) {
Ok(dt) => dt.with_timezone(&chrono::Utc),
Err(_) => return true, };
let age = chrono::Utc::now().signed_duration_since(last);
age > chrono::Duration::hours(max_age_hours as i64)
}
pub async fn check_api_compat(client: &HfClient) -> Result<ApiCompatLevel> {
let spec = client
.fetch_openapi_spec()
.await
.context("Failed to fetch OpenAPI spec for compat check")?;
let new_hash = hash_spec(&spec);
let now = chrono::Utc::now().to_rfc3339();
let spec_version = serde_json::from_str::<serde_json::Value>(&spec)
.ok()
.and_then(|v| {
v.get("openapi")
.or_else(|| v.get("info").and_then(|i| i.get("version")))
.and_then(|v| v.as_str().map(String::from))
})
.unwrap_or_else(|| "unknown".to_string());
let previous = load_state();
let level = match previous {
None => {
let state = ApiCompatState {
api: ApiInfo {
openapi_sha256: new_hash,
last_checked: now,
spec_version,
},
endpoints: default_endpoint_expectations(),
};
save_state(&state)?;
ApiCompatLevel::FirstRun
}
Some(prev) => {
let hash_changed = prev.api.openapi_sha256 != new_hash;
let version_changed = prev.api.spec_version != spec_version;
let level = if hash_changed {
ApiCompatLevel::Error(vec![format!(
"OpenAPI spec hash changed: {} -> {}",
prev.api.openapi_sha256, new_hash
)])
} else if version_changed {
ApiCompatLevel::Warn(vec![format!(
"Spec version changed: {} -> {}",
prev.api.spec_version, spec_version
)])
} else {
ApiCompatLevel::Ok
};
let state = ApiCompatState {
api: ApiInfo {
openapi_sha256: new_hash,
last_checked: now,
spec_version,
},
endpoints: prev.endpoints,
};
save_state(&state)?;
level
}
};
Ok(level)
}
pub fn verify_response_fields(
endpoint_name: &str,
response: &serde_json::Value,
state: &mut ApiCompatState,
) -> ApiCompatLevel {
let ep = match state.endpoints.get(endpoint_name) {
Some(ep) => ep,
None => return ApiCompatLevel::Ok, };
let obj = match response {
serde_json::Value::Array(arr) => arr.first().and_then(|v| v.as_object()),
serde_json::Value::Object(_) => response.as_object(),
_ => None,
};
let missing: Vec<String> = match obj {
Some(map) => ep
.expected_fields
.iter()
.filter(|f| !map.contains_key(f.as_str()))
.cloned()
.collect(),
None => ep.expected_fields.clone(), };
let now = chrono::Utc::now().to_rfc3339();
if let Some(ep_mut) = state.endpoints.get_mut(endpoint_name) {
ep_mut.last_verified = now;
}
if missing.is_empty() {
ApiCompatLevel::Ok
} else {
ApiCompatLevel::Error(
missing
.iter()
.map(|f| format!("missing field '{}' in endpoint '{}'", f, endpoint_name))
.collect(),
)
}
}
fn default_endpoint_expectations() -> HashMap<String, EndpointInfo> {
let now = chrono::Utc::now().to_rfc3339();
let mut map = HashMap::new();
map.insert(
"models_info".to_string(),
EndpointInfo {
expected_fields: vec![
"id".into(),
"modelId".into(),
"sha".into(),
"pipeline_tag".into(),
"tags".into(),
"downloads".into(),
"likes".into(),
],
last_verified: now.clone(),
},
);
map.insert(
"models_search".to_string(),
EndpointInfo {
expected_fields: vec!["modelId".into(), "downloads".into(), "likes".into()],
last_verified: now.clone(),
},
);
map.insert(
"tree".to_string(),
EndpointInfo {
expected_fields: vec!["type".into(), "oid".into(), "size".into(), "path".into()],
last_verified: now,
},
);
map
}