use openapi_contract::api;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use super::client::CloudClient;
use crate::contract::{BillingCurrent, Success, SyncProviders, SyncSettings, Team, UserProfile};
use crate::domain::models::SkillRecord;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResult {
pub created: Vec<SyncedRule>,
pub updated: Vec<SyncedRule>,
pub deleted: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamSyncResult {
pub visible_count: i32,
pub synced: SyncResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncedRule {
pub id: String,
pub name: String,
pub r#type: String,
pub description: String,
pub version: String,
pub engines: Vec<String>,
pub tags: Vec<String>,
pub trigger: Option<String>,
pub check_prompt: Option<String>,
pub content: String,
pub updated_at: String,
pub created_at: String,
#[serde(default)]
pub file_patterns: Vec<String>,
#[serde(default)]
pub origin: Option<String>,
#[serde(default, rename = "sourceRepo")]
pub source_repo: Option<String>,
}
impl SyncResult {
pub const fn created_count(&self) -> usize {
self.created.len()
}
pub const fn updated_count(&self) -> usize {
self.updated.len()
}
pub const fn deleted_count(&self) -> usize {
self.deleted.len()
}
}
pub async fn sync_skills(
client: &CloudClient,
skills: &[SkillRecord],
) -> Result<Option<SyncResult>, crate::CoreError> {
sync_skills_filtered(client, skills, &[]).await
}
pub async fn sync_skills_filtered(
client: &CloudClient,
skills: &[SkillRecord],
exclude_ids: &[String],
) -> Result<Option<SyncResult>, crate::CoreError> {
let exclude: std::collections::HashSet<&str> = exclude_ids.iter().map(String::as_str).collect();
let ids_for_scope: Vec<String> = skills
.iter()
.filter(|s| !exclude.contains(s.id.as_str()))
.map(|s| s.id.clone())
.collect();
let scope_by_id = load_sync_hash_scope(&ids_for_scope).await;
let local_hashes: std::collections::HashMap<String, String> = skills
.iter()
.filter(|s| !exclude.contains(s.id.as_str()))
.map(|skill| {
(
skill.id.clone(),
skill_content_hash(skill, scope_by_id.get(&skill.id)),
)
})
.collect();
let payload = serde_json::json!({ "localHashes": local_hashes });
let result: serde_json::Value = client
.fetch_api_json(api!(POST "/rules/sync", body = &payload), "rules_sync")
.await
.map_err(|e| {
crate::CoreError::Internal(format!(
"rules sync failed: {e}; run `difflore doctor --report` for cloud diagnostics"
))
})?;
let created = result
.get("created")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let updated = result
.get("updated")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let deleted: Vec<String> = result
.get("deleted")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let created = created
.iter()
.map(map_synced_rule_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
crate::CoreError::Internal(format!("rules sync: malformed `created` rule: {e}"))
})?;
let updated = updated
.iter()
.map(map_synced_rule_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
crate::CoreError::Internal(format!("rules sync: malformed `updated` rule: {e}"))
})?;
if deleted.iter().any(|id| id.trim().is_empty()) {
return Err(crate::CoreError::Internal(
"rules sync: `deleted` contained an empty rule id".to_owned(),
));
}
Ok(Some(SyncResult {
created,
updated,
deleted,
}))
}
fn map_synced_rule_value(val: &serde_json::Value) -> crate::Result<SyncedRule> {
let required = |key: &str| -> crate::Result<String> {
match val.get(key).and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => Ok(s.to_owned()),
_ => Err(crate::CoreError::Internal(format!(
"missing or empty required field `{key}`"
))),
}
};
Ok(SyncedRule {
id: required("id")?,
name: val
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned(),
r#type: val
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("review_standard")
.to_owned(),
description: val
.get("description")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned(),
version: val
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("1.0.0")
.to_owned(),
engines: val
.get("engines")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
tags: val
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
trigger: val
.get("trigger")
.and_then(|v| v.as_str())
.map(String::from),
check_prompt: val
.get("checkPrompt")
.and_then(|v| v.as_str())
.map(String::from),
content: required("content")?,
updated_at: val
.get("updatedAt")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned(),
created_at: val
.get("createdAt")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned(),
file_patterns: val
.get("filePatterns")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
origin: val.get("origin").and_then(|v| v.as_str()).map(String::from),
source_repo: val
.get("sourceRepo")
.and_then(|v| v.as_str())
.map(String::from),
})
}
fn map_team_synced_rule_value(val: &serde_json::Value) -> crate::Result<SyncedRule> {
let mut normalized = val.clone();
let content_is_missing = normalized
.get("content")
.and_then(|v| v.as_str())
.is_none_or(|s| s.trim().is_empty());
if content_is_missing {
let description = normalized
.get("description")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
normalized["content"] = serde_json::Value::String(description);
}
let mut rule = map_synced_rule_value(&normalized)?;
if rule.origin.is_none() {
rule.origin = Some("team".to_owned());
}
Ok(rule)
}
pub async fn sync_team_skills(client: &CloudClient) -> Result<TeamSyncResult, crate::CoreError> {
let skills_json: Vec<serde_json::Value> = api!(GET "/rules/team").fetch(client).await?;
let visible_count = i32::try_from(skills_json.len()).unwrap_or(i32::MAX);
let created = skills_json
.iter()
.map(map_team_synced_rule_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| crate::CoreError::Internal(format!("team rules sync: malformed rule: {e}")))?;
Ok(TeamSyncResult {
visible_count,
synced: SyncResult {
created,
updated: vec![],
deleted: vec![],
},
})
}
pub async fn sync_settings(
client: &CloudClient,
settings: &serde_json::Value,
) -> Result<(), crate::CoreError> {
let payload = serde_json::json!({ "settings": settings });
let _: Success = api!(PUT "/sync/settings", body = &payload)
.fetch(client)
.await?;
Ok(())
}
pub fn mask_api_key(key: &str) -> String {
let trimmed = key.trim();
if trimmed.len() <= 4 {
"•".repeat(trimmed.len())
} else {
let visible = &trimmed[trimmed.len().saturating_sub(4)..];
format!("••••{visible}")
}
}
pub fn build_provider_sync_entries(
providers: &[crate::domain::models::ProviderRecord],
) -> Vec<serde_json::Value> {
providers
.iter()
.map(|p| {
let mut obj = serde_json::Map::new();
obj.insert("name".into(), serde_json::Value::String(p.name.clone()));
obj.insert(
"baseUrl".into(),
serde_json::Value::String(p.base_url.clone()),
);
if let Some(key) = p.api_key.as_deref() {
obj.insert(
"maskedKey".into(),
serde_json::Value::String(mask_api_key(key)),
);
}
if !p.model_mapping.is_empty() {
obj.insert(
"modelMapping".into(),
serde_json::to_value(&p.model_mapping).unwrap_or(serde_json::Value::Null),
);
}
obj.insert(
"updatedAt".into(),
serde_json::Value::String(p.updated_at.clone()),
);
serde_json::Value::Object(obj)
})
.collect()
}
pub async fn sync_providers(
client: &CloudClient,
providers: &[serde_json::Value],
) -> Result<(), crate::CoreError> {
let payload = serde_json::json!({ "providers": providers });
let _: Success = api!(PUT "/sync/providers", body = &payload)
.fetch(client)
.await?;
Ok(())
}
pub async fn pull_settings(
client: &CloudClient,
) -> Result<Option<(serde_json::Value, Option<String>)>, crate::CoreError> {
let result: SyncSettings = api!(GET "/sync/settings").fetch(client).await?;
let val = serde_json::to_value(&result).unwrap_or_default();
let settings = val
.get("settings")
.cloned()
.unwrap_or(serde_json::json!({}));
let updated_at = val
.get("updatedAt")
.and_then(|v| v.as_str())
.map(String::from);
if settings.is_null() || settings.as_object().is_none_or(serde_json::Map::is_empty) {
Ok(None)
} else {
Ok(Some((settings, updated_at)))
}
}
pub async fn pull_providers(
client: &CloudClient,
) -> Result<Option<(serde_json::Value, Option<String>)>, crate::CoreError> {
let result: SyncProviders = api!(GET "/sync/providers").fetch(client).await?;
let val = serde_json::to_value(&result).unwrap_or_default();
let providers = val.get("providers").cloned();
let updated_at = val
.get("updatedAt")
.and_then(|v| v.as_str())
.map(String::from);
Ok(normalize_provider_payload(providers).map(|providers| (providers, updated_at)))
}
fn normalize_provider_payload(providers: Option<serde_json::Value>) -> Option<serde_json::Value> {
match providers {
Some(arr @ serde_json::Value::Array(_)) => Some(arr),
None | Some(_) => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudStatus {
pub logged_in: bool,
pub email: Option<String>,
pub plan: Option<String>,
pub team_id: Option<String>,
pub team_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloudTier {
Free,
Team,
TeamPlus,
}
impl CloudTier {
pub const fn is_team(self) -> bool {
matches!(self, Self::Team | Self::TeamPlus)
}
pub const fn default_label(self) -> &'static str {
match self {
Self::Free => "Cloud Free",
Self::Team => "Cloud Team",
Self::TeamPlus => "Cloud Team Plus",
}
}
}
pub fn cloud_tier_from_status(status: &CloudStatus) -> CloudTier {
if !status.logged_in {
return CloudTier::Free;
}
let plan = status
.plan
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase()
.replace('-', "_");
match plan.as_str() {
"team_plus" | "enterprise" => CloudTier::TeamPlus,
"team" | "pro" | "business" => CloudTier::Team,
"free" | "self_host" | "oss" => CloudTier::Free,
_ if has_team_identity(status) => CloudTier::Team,
_ => CloudTier::Free,
}
}
pub fn cloud_plan_label_from_status(status: &CloudStatus) -> String {
if let Some(team) = status.team_name.as_deref().map(str::trim)
&& !team.is_empty()
{
return team.to_owned();
}
cloud_tier_from_status(status).default_label().to_owned()
}
fn has_team_identity(status: &CloudStatus) -> bool {
status
.team_id
.as_deref()
.is_some_and(|team| !team.trim().is_empty())
|| status
.team_name
.as_deref()
.is_some_and(|team| !team.trim().is_empty())
}
pub async fn fetch_cloud_status(client: &CloudClient) -> CloudStatus {
if !client.is_logged_in() {
return CloudStatus {
logged_in: false,
email: None,
plan: None,
team_id: None,
team_name: None,
};
}
let mut status_client = client.clone();
let mut profile_result: Result<UserProfile, _> = api!(GET "/auth/profile").fetch(client).await;
if profile_result.is_err() && CloudClient::refresh_saved_token().await.is_some() {
status_client = CloudClient::create().await;
profile_result = api!(GET "/auth/profile").fetch(&status_client).await;
}
let Ok(profile) = profile_result else {
return CloudStatus {
logged_in: false,
email: None,
plan: None,
team_id: None,
team_name: None,
};
};
let email = serde_json::to_value(&profile)
.ok()
.and_then(|v| v.get("email").and_then(|e| e.as_str()).map(String::from));
let billing_result: Result<BillingCurrent, _> =
api!(GET "/billing/current").fetch(&status_client).await;
let plan = billing_result
.ok()
.and_then(|b| serde_json::to_value(&b).ok())
.and_then(|v| v.get("planId").and_then(|p| p.as_str()).map(String::from));
let team_result: Result<Option<Team>, _> = api!(GET "/teams/my").fetch(&status_client).await;
let team_value = team_result
.ok()
.flatten()
.and_then(|t| serde_json::to_value(&t).ok());
let team_id = team_value.as_ref().and_then(|v| {
v.get("id")
.or_else(|| v.get("teamId"))
.and_then(|id| id.as_str())
.map(String::from)
});
let team_name = team_value
.as_ref()
.and_then(|v| v.get("name").and_then(|n| n.as_str()).map(String::from));
CloudStatus {
logged_in: true,
email,
plan,
team_id,
team_name,
}
}
#[derive(Debug, Clone, Default)]
struct SyncHashScope {
file_patterns: Vec<String>,
source_repo: Option<String>,
}
async fn load_sync_hash_scope(ids: &[String]) -> std::collections::HashMap<String, SyncHashScope> {
let mut out = std::collections::HashMap::new();
if ids.is_empty() {
return out;
}
let Ok(db) = crate::infra::db::init_db().await else {
return out;
};
let Ok(ids_json) = serde_json::to_string(ids) else {
return out;
};
let Ok(rows) = sqlx::query_as::<_, (String, Option<String>, Option<String>)>(
"SELECT id, file_patterns, source_repo
FROM skills WHERE id IN (SELECT value FROM json_each(?1))",
)
.bind(ids_json)
.fetch_all(&db)
.await
else {
return out;
};
for (id, file_patterns_raw, source_repo) in rows {
out.insert(
id,
SyncHashScope {
file_patterns: parse_file_patterns_for_hash(file_patterns_raw.as_deref()),
source_repo,
},
);
}
out
}
fn skill_content_hash(skill: &SkillRecord, scope: Option<&SyncHashScope>) -> String {
let content = skill_content_for_hash(skill);
let payload = rule_sync_hash_payload_json(skill, scope, &content);
let digest = sha2::Sha256::digest(payload.as_bytes());
use std::fmt::Write as _;
digest
.iter()
.fold(String::with_capacity(digest.len() * 2), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct RuleSyncHashPayload<'a> {
content: &'a str,
file_patterns: Vec<String>,
source_repo: Option<String>,
tags: Vec<String>,
version: &'a str,
}
fn rule_sync_hash_payload_json(
skill: &SkillRecord,
scope: Option<&SyncHashScope>,
content: &str,
) -> String {
let version = clean_string(Some(&skill.version)).unwrap_or_default();
serde_json::to_string(&RuleSyncHashPayload {
content,
file_patterns: clean_string_array(
scope
.map(|scope| scope.file_patterns.as_slice())
.unwrap_or_default()
.iter(),
),
source_repo: clean_string(scope.and_then(|scope| scope.source_repo.as_deref())),
tags: clean_string_array(skill.tags.iter()),
version: &version,
})
.unwrap_or_else(|_| {
format!(
r#"{{"content":{},"filePatterns":[],"sourceRepo":null,"tags":[],"version":""}}"#,
serde_json::to_string(content).unwrap_or_else(|_| "\"\"".to_owned())
)
})
}
fn parse_file_patterns_for_hash(raw: Option<&str>) -> Vec<String> {
raw.and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
.unwrap_or_default()
}
fn clean_string(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn clean_string_array<'a>(values: impl Iterator<Item = &'a String>) -> Vec<String> {
let mut out = Vec::new();
for value in values {
let Some(cleaned) = clean_string(Some(value)) else {
continue;
};
if out.contains(&cleaned) {
continue;
}
out.push(cleaned);
}
out
}
fn skill_content_for_hash(skill: &SkillRecord) -> String {
skill
.check_prompt
.clone()
.or_else(|| {
if skill.description.trim().is_empty() {
None
} else {
Some(skill.description.clone())
}
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::models::ProviderRecord;
use std::collections::HashMap;
#[test]
fn mask_short_keys_returns_only_bullets() {
assert_eq!(mask_api_key(""), "");
assert_eq!(mask_api_key("ab"), "••");
assert_eq!(mask_api_key("abcd"), "••••");
}
#[test]
fn mask_long_keys_keeps_last_four() {
assert_eq!(mask_api_key("sk-abcdef1234"), "••••1234");
assert_eq!(mask_api_key(" spaced-key-9876 "), "••••9876");
}
fn make_provider(name: &str, key: Option<&str>) -> ProviderRecord {
let mut mapping = HashMap::new();
mapping.insert("review".into(), "claude-3".into());
ProviderRecord {
id: format!("{name}-id"),
name: name.into(),
base_url: format!("https://{name}.example.com"),
api_key: key.map(String::from),
model_mapping: mapping,
is_active: true,
created_at: "2026-04-10T00:00:00Z".into(),
updated_at: "2026-04-10T00:00:00Z".into(),
}
}
#[test]
fn build_entries_masks_keys_and_omits_when_absent() {
let providers = vec![
make_provider("anthropic", Some("sk-ant-1234567890abcd")),
make_provider("local", None),
];
let entries = build_provider_sync_entries(&providers);
assert_eq!(entries.len(), 2);
let first = entries[0].as_object().unwrap();
assert_eq!(first.get("name").unwrap().as_str(), Some("anthropic"));
assert_eq!(
first.get("baseUrl").unwrap().as_str(),
Some("https://anthropic.example.com"),
);
assert_eq!(first.get("maskedKey").unwrap().as_str(), Some("••••abcd"));
assert!(first.get("modelMapping").is_some());
assert_eq!(
first.get("updatedAt").unwrap().as_str(),
Some("2026-04-10T00:00:00Z"),
);
let second = entries[1].as_object().unwrap();
assert!(
second.get("maskedKey").is_none(),
"absent key should not emit maskedKey"
);
}
#[test]
fn build_entries_skips_empty_model_mapping() {
let provider = ProviderRecord {
id: "x".into(),
name: "x".into(),
base_url: "https://x".into(),
api_key: None,
model_mapping: HashMap::new(),
is_active: false,
created_at: "t".into(),
updated_at: "t".into(),
};
let entries = build_provider_sync_entries(&[provider]);
assert!(entries[0].get("modelMapping").is_none());
}
#[test]
fn provider_pull_payload_is_canonical_array_only() {
let array = serde_json::json!([{ "name": "codex" }]);
assert_eq!(
normalize_provider_payload(Some(array.clone())).as_ref(),
Some(&array)
);
let stringified = serde_json::json!(r#"[{"name":"old"}]"#);
assert!(
normalize_provider_payload(Some(stringified)).is_none(),
"stringified provider JSON must fail closed"
);
assert!(normalize_provider_payload(Some(serde_json::json!({}))).is_none());
}
fn cloud_status(
logged_in: bool,
plan: Option<&str>,
team_id: Option<&str>,
team_name: Option<&str>,
) -> CloudStatus {
CloudStatus {
logged_in,
email: None,
plan: plan.map(String::from),
team_id: team_id.map(String::from),
team_name: team_name.map(String::from),
}
}
#[test]
fn cloud_tier_mapping_is_shared_for_cli_surfaces() {
assert_eq!(
cloud_tier_from_status(&cloud_status(false, Some("team"), None, None)),
CloudTier::Free
);
assert_eq!(
cloud_tier_from_status(&cloud_status(true, Some("free"), None, None)),
CloudTier::Free
);
assert_eq!(
cloud_tier_from_status(&cloud_status(true, Some("pro"), None, None)),
CloudTier::Team
);
assert_eq!(
cloud_tier_from_status(&cloud_status(true, Some("team-plus"), None, None)),
CloudTier::TeamPlus
);
assert_eq!(
cloud_tier_from_status(&cloud_status(true, None, Some("team_123"), None)),
CloudTier::Team
);
}
#[test]
fn cloud_plan_label_prefers_team_name() {
let status = cloud_status(true, Some("team"), Some("team_123"), Some("Acme"));
assert_eq!(cloud_plan_label_from_status(&status), "Acme");
let fallback = cloud_status(true, Some("enterprise"), None, None);
assert_eq!(cloud_plan_label_from_status(&fallback), "Cloud Team Plus");
}
#[test]
fn sync_rule_mapping_uses_canonical_source_repo_field_only() {
let val = serde_json::json!({
"id": "rule-1",
"name": "Rule",
"content": "Body",
"source_repo": "acme/retired",
"sourceRepo": "acme/canonical"
});
let mapped = map_synced_rule_value(&val).expect("valid rule");
assert_eq!(mapped.source_repo.as_deref(), Some("acme/canonical"));
let retired_only = serde_json::json!({
"id": "rule-2",
"name": "Rule",
"content": "Body",
"source_repo": "acme/retired"
});
let mapped = map_synced_rule_value(&retired_only).expect("valid rule");
assert_eq!(mapped.source_repo, None);
}
#[test]
fn sync_rule_mapping_rejects_missing_required_id_or_content() {
let no_id = serde_json::json!({ "name": "R", "content": "Body" });
assert!(map_synced_rule_value(&no_id).is_err());
let empty_content = serde_json::json!({ "id": "r", "name": "R", "content": " " });
assert!(map_synced_rule_value(&empty_content).is_err());
let ok = serde_json::json!({ "id": "r", "name": "R", "content": "Body" });
assert!(map_synced_rule_value(&ok).is_ok());
}
#[test]
fn team_sync_rule_mapping_prefers_content_over_description() {
let val = serde_json::json!({
"id": "team-rule-1",
"name": "Rule",
"description": "Summary",
"content": "Full body",
"origin": "conversation"
});
let mapped = map_team_synced_rule_value(&val).expect("valid team rule");
assert_eq!(mapped.description, "Summary");
assert_eq!(mapped.content, "Full body");
assert_eq!(mapped.origin.as_deref(), Some("conversation"));
}
#[test]
fn team_sync_rule_mapping_falls_back_to_description_for_old_clouds() {
let val = serde_json::json!({
"id": "team-rule-2",
"name": "Rule",
"description": "Summary"
});
let mapped = map_team_synced_rule_value(&val).expect("valid legacy team rule");
assert_eq!(mapped.content, "Summary");
assert_eq!(mapped.origin.as_deref(), Some("team"));
}
#[test]
fn skill_content_hash_uses_db_fields_without_skill_file() {
let skill = SkillRecord {
id: "missing-cloud-rule".into(),
name: "Missing cloud rule".into(),
source: "cloud".into(),
directory: "missing-cloud-rule".into(),
version: "1.0.0".into(),
description: "description fallback".into(),
r#type: "review_standard".into(),
engines: vec![],
tags: vec![],
trigger: None,
check_prompt: Some("prefer check prompt for hashing".into()),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
enabled_for_codex: true,
enabled_for_claude: true,
enabled_for_gemini: true,
enabled_for_cursor: true,
installed_at: "2026-05-11T00:00:00Z".into(),
updated_at: "2026-05-11T00:00:00Z".into(),
enforcement: None,
origin: "pr_review".into(),
};
let expected_payload = r#"{"content":"prefer check prompt for hashing","filePatterns":[],"sourceRepo":null,"tags":[],"version":"1.0.0"}"#;
assert_eq!(
rule_sync_hash_payload_json(&skill, None, "prefer check prompt for hashing"),
expected_payload
);
let expected = {
let digest = sha2::Sha256::digest(expected_payload.as_bytes());
use std::fmt::Write as _;
digest
.iter()
.fold(String::with_capacity(digest.len() * 2), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
};
assert_eq!(skill_content_hash(&skill, None), expected);
}
#[test]
fn skill_content_hash_changes_when_scope_metadata_changes() {
let skill = SkillRecord {
id: "scoped-cloud-rule".into(),
name: "Scoped cloud rule".into(),
source: "cloud".into(),
directory: "missing-cloud-rule".into(),
version: "1.0.0".into(),
description: "same body".into(),
r#type: "review_standard".into(),
engines: vec![],
tags: vec!["origin:session_mined".into()],
trigger: None,
check_prompt: Some("same body".into()),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
enabled_for_codex: true,
enabled_for_claude: true,
enabled_for_gemini: true,
enabled_for_cursor: true,
installed_at: "2026-05-11T00:00:00Z".into(),
updated_at: "2026-05-11T00:00:00Z".into(),
enforcement: None,
origin: "pr_review".into(),
};
let rust_scope = SyncHashScope {
file_patterns: vec!["src/**/*.rs".into()],
source_repo: Some("acme/widgets".into()),
};
let ts_scope = SyncHashScope {
file_patterns: vec!["packages/**/*.ts".into()],
source_repo: Some("acme/widgets".into()),
};
assert_ne!(
skill_content_hash(&skill, Some(&rust_scope)),
skill_content_hash(&skill, Some(&ts_scope))
);
}
}