Skip to main content

difflore_core/cloud/
sync.rs

1use openapi_contract::{ApiClient, Method, api};
2use serde::{Deserialize, Serialize};
3use sha2::Digest;
4
5use super::api_types::{
6    BillingCurrent, Success, SyncProviders, SyncSettings, Team, TeamRuleSummary, UserProfile,
7};
8use super::client::CloudClient;
9use crate::models::SkillRecord;
10use crate::skill_fs::skills_base_dir;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SyncResult {
14    pub created: Vec<SyncedRule>,
15    pub updated: Vec<SyncedRule>,
16    pub deleted: Vec<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TeamSyncResult {
21    pub visible_count: i32,
22    pub synced: SyncResult,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SyncedRule {
27    pub id: String,
28    pub name: String,
29    pub r#type: String,
30    pub description: String,
31    pub version: String,
32    pub engines: Vec<String>,
33    pub tags: Vec<String>,
34    pub trigger: Option<String>,
35    pub check_prompt: Option<String>,
36    pub content: String,
37    pub updated_at: String,
38    pub created_at: String,
39    /// Iter-9 (2026-04-18): glob list (e.g. `["**/*.rs"]`) the CLI's cascade
40    /// uses to drop rules whose patterns don't match the current file.
41    /// Empty / missing = universal rule.
42    #[serde(default)]
43    pub file_patterns: Vec<String>,
44    /// 2026-04-20: input-channel provenance (manual | conversation |
45    /// `pr_review` | extracted). When the cloud doesn't yet emit this
46    /// field, defaults to None and `apply_sync_result` falls back to
47    /// `cloud` so audit pages can still distinguish remotely-fetched
48    /// rules from locally-typed ones.
49    #[serde(default)]
50    pub origin: Option<String>,
51    /// 2026-04-25: GitHub-style `owner/repo` provenance for the cluster
52    /// of extractions that drafted this rule. Mirrors cloud's
53    /// `rules_cloud.source_repo` 1-for-1; the local skills table carries
54    /// the same column in the initial schema.
55    #[serde(default, rename = "sourceRepo")]
56    pub source_repo: Option<String>,
57}
58
59impl SyncResult {
60    pub const fn created_count(&self) -> usize {
61        self.created.len()
62    }
63    pub const fn updated_count(&self) -> usize {
64        self.updated.len()
65    }
66    pub const fn deleted_count(&self) -> usize {
67        self.deleted.len()
68    }
69}
70
71pub async fn sync_skills(
72    client: &CloudClient,
73    skills: &[SkillRecord],
74) -> Result<Option<SyncResult>, crate::CoreError> {
75    sync_skills_filtered(client, skills, &[]).await
76}
77
78/// Like `sync_skills` but filters out local-only ids before hashing so
79/// pending candidates are not recreated as active cloud rules.
80pub async fn sync_skills_filtered(
81    client: &CloudClient,
82    skills: &[SkillRecord],
83    exclude_ids: &[String],
84) -> Result<Option<SyncResult>, crate::CoreError> {
85    let exclude: std::collections::HashSet<&str> = exclude_ids.iter().map(String::as_str).collect();
86    let local_hashes: std::collections::HashMap<String, String> = skills
87        .iter()
88        .filter(|s| !exclude.contains(s.id.as_str()))
89        .map(|skill| (skill.id.clone(), skill_content_hash(skill)))
90        .collect();
91    let payload = serde_json::json!({ "localHashes": local_hashes });
92
93    let resp = client
94        .request(Method::POST, "/rules/sync", None, Some(payload.to_string()))
95        .await?;
96    let status = resp.status();
97    if !status.is_success() {
98        return Err(crate::CoreError::Internal(format!(
99            "rules sync returned {status}; run `difflore doctor --report` for cloud diagnostics"
100        )));
101    }
102    let result: serde_json::Value = resp
103        .json()
104        .await
105        .map_err(|e| crate::CoreError::Internal(format!("rules sync decode error: {e}")))?;
106
107    let created = result
108        .get("created")
109        .and_then(|v| v.as_array())
110        .cloned()
111        .unwrap_or_default();
112    let updated = result
113        .get("updated")
114        .and_then(|v| v.as_array())
115        .cloned()
116        .unwrap_or_default();
117    let deleted: Vec<String> = result
118        .get("deleted")
119        .and_then(|v| v.as_array())
120        .map(|arr| {
121            arr.iter()
122                .filter_map(|v| v.as_str().map(String::from))
123                .collect()
124        })
125        .unwrap_or_default();
126
127    // Fail the whole sync (before any local DB mutation) if the cloud sent a
128    // malformed rule — never partially apply a junk / empty-id rule.
129    let created = created
130        .iter()
131        .map(map_synced_rule_value)
132        .collect::<Result<Vec<_>, _>>()
133        .map_err(|e| {
134            crate::CoreError::Internal(format!("rules sync: malformed `created` rule: {e}"))
135        })?;
136    let updated = updated
137        .iter()
138        .map(map_synced_rule_value)
139        .collect::<Result<Vec<_>, _>>()
140        .map_err(|e| {
141            crate::CoreError::Internal(format!("rules sync: malformed `updated` rule: {e}"))
142        })?;
143    if deleted.iter().any(|id| id.trim().is_empty()) {
144        return Err(crate::CoreError::Internal(
145            "rules sync: `deleted` contained an empty rule id".to_owned(),
146        ));
147    }
148
149    Ok(Some(SyncResult {
150        created,
151        updated,
152        deleted,
153    }))
154}
155
156fn map_synced_rule_value(val: &serde_json::Value) -> Result<SyncedRule, String> {
157    // `id` and `content` are REQUIRED — a missing/empty value would create or
158    // overwrite a local rule keyed on an empty id (corrupting the store). Cloud
159    // schema drift must fail the whole sync, never silently apply a junk rule.
160    let required = |key: &str| -> Result<String, String> {
161        match val.get(key).and_then(|v| v.as_str()) {
162            Some(s) if !s.trim().is_empty() => Ok(s.to_owned()),
163            _ => Err(format!("missing or empty required field `{key}`")),
164        }
165    };
166    Ok(SyncedRule {
167        id: required("id")?,
168        name: val
169            .get("name")
170            .and_then(|v| v.as_str())
171            .unwrap_or_default()
172            .to_owned(),
173        r#type: val
174            .get("type")
175            .and_then(|v| v.as_str())
176            .unwrap_or("review_standard")
177            .to_owned(),
178        description: val
179            .get("description")
180            .and_then(|v| v.as_str())
181            .unwrap_or_default()
182            .to_owned(),
183        version: val
184            .get("version")
185            .and_then(|v| v.as_str())
186            .unwrap_or("1.0.0")
187            .to_owned(),
188        engines: val
189            .get("engines")
190            .and_then(|v| v.as_array())
191            .map(|arr| {
192                arr.iter()
193                    .filter_map(|v| v.as_str().map(String::from))
194                    .collect()
195            })
196            .unwrap_or_default(),
197        tags: val
198            .get("tags")
199            .and_then(|v| v.as_array())
200            .map(|arr| {
201                arr.iter()
202                    .filter_map(|v| v.as_str().map(String::from))
203                    .collect()
204            })
205            .unwrap_or_default(),
206        trigger: val
207            .get("trigger")
208            .and_then(|v| v.as_str())
209            .map(String::from),
210        check_prompt: val
211            .get("checkPrompt")
212            .and_then(|v| v.as_str())
213            .map(String::from),
214        content: required("content")?,
215        updated_at: val
216            .get("updatedAt")
217            .and_then(|v| v.as_str())
218            .unwrap_or_default()
219            .to_owned(),
220        created_at: val
221            .get("createdAt")
222            .and_then(|v| v.as_str())
223            .unwrap_or_default()
224            .to_owned(),
225        file_patterns: val
226            .get("filePatterns")
227            .and_then(|v| v.as_array())
228            .map(|arr| {
229                arr.iter()
230                    .filter_map(|v| v.as_str().map(String::from))
231                    .collect()
232            })
233            .unwrap_or_default(),
234        origin: val.get("origin").and_then(|v| v.as_str()).map(String::from),
235        source_repo: val
236            .get("sourceRepo")
237            .and_then(|v| v.as_str())
238            .map(String::from),
239    })
240}
241
242pub async fn sync_team_skills(client: &CloudClient) -> Result<TeamSyncResult, crate::CoreError> {
243    let skills_json: Vec<serde_json::Value> = api!(GET "/rules/team").fetch(client).await?;
244    let skills: Vec<TeamRuleSummary> = skills_json
245        .into_iter()
246        .map(serde_json::from_value)
247        .collect::<Result<_, _>>()?;
248    let visible_count = i32::try_from(skills.len()).unwrap_or(i32::MAX);
249    let created = skills
250        .into_iter()
251        .map(|rule| SyncedRule {
252            id: rule.id,
253            name: rule.name,
254            r#type: rule.r#type,
255            description: rule.description.clone(),
256            version: rule.version,
257            engines: rule.engines,
258            tags: rule.tags,
259            trigger: rule.trigger,
260            check_prompt: rule.check_prompt,
261            content: rule.description,
262            updated_at: rule.updated_at,
263            created_at: rule.created_at,
264            file_patterns: rule.file_patterns,
265            origin: Some("team".to_owned()),
266            source_repo: rule.source_repo,
267        })
268        .collect();
269    Ok(TeamSyncResult {
270        visible_count,
271        synced: SyncResult {
272            created,
273            updated: vec![],
274            deleted: vec![],
275        },
276    })
277}
278
279pub async fn sync_settings(
280    client: &CloudClient,
281    settings: &serde_json::Value,
282) -> Result<(), crate::CoreError> {
283    let payload = serde_json::json!({ "settings": settings });
284    let _: Success = api!(PUT "/sync/settings", body = &payload)
285        .fetch(client)
286        .await?;
287    Ok(())
288}
289
290/// Mask an API key for cross-device sync. The cloud never stores the secret —
291/// only an opaque hint (e.g. last 4 chars) so users can recognize which key
292/// they previously used on another device.
293pub fn mask_api_key(key: &str) -> String {
294    let trimmed = key.trim();
295    if trimmed.len() <= 4 {
296        "•".repeat(trimmed.len())
297    } else {
298        let visible = &trimmed[trimmed.len().saturating_sub(4)..];
299        format!("••••{visible}")
300    }
301}
302
303/// Build a structured provider sync payload from local provider records.
304/// Used by `sync_providers`; exposed for callers that want to inspect what
305/// will be sent before pushing.
306pub fn build_provider_sync_entries(
307    providers: &[crate::models::ProviderRecord],
308) -> Vec<serde_json::Value> {
309    providers
310        .iter()
311        .map(|p| {
312            let mut obj = serde_json::Map::new();
313            obj.insert("name".into(), serde_json::Value::String(p.name.clone()));
314            obj.insert(
315                "baseUrl".into(),
316                serde_json::Value::String(p.base_url.clone()),
317            );
318            if let Some(key) = p.api_key.as_deref() {
319                obj.insert(
320                    "maskedKey".into(),
321                    serde_json::Value::String(mask_api_key(key)),
322                );
323            }
324            if !p.model_mapping.is_empty() {
325                obj.insert(
326                    "modelMapping".into(),
327                    serde_json::to_value(&p.model_mapping).unwrap_or(serde_json::Value::Null),
328                );
329            }
330            obj.insert(
331                "updatedAt".into(),
332                serde_json::Value::String(p.updated_at.clone()),
333            );
334            serde_json::Value::Object(obj)
335        })
336        .collect()
337}
338
339pub async fn sync_providers(
340    client: &CloudClient,
341    providers: &[serde_json::Value],
342) -> Result<(), crate::CoreError> {
343    let payload = serde_json::json!({ "providers": providers });
344    let _: Success = api!(PUT "/sync/providers", body = &payload)
345        .fetch(client)
346        .await?;
347    Ok(())
348}
349
350/// Fetch cloud-side settings blob. Returns (`settings_value`, `updated_at`) or
351/// None if the cloud has no settings stored yet for this user.
352pub async fn pull_settings(
353    client: &CloudClient,
354) -> Result<Option<(serde_json::Value, Option<String>)>, crate::CoreError> {
355    let result: SyncSettings = api!(GET "/sync/settings").fetch(client).await?;
356    let val = serde_json::to_value(&result).unwrap_or_default();
357    let settings = val
358        .get("settings")
359        .cloned()
360        .unwrap_or(serde_json::json!({}));
361    let updated_at = val
362        .get("updatedAt")
363        .and_then(|v| v.as_str())
364        .map(String::from);
365    if settings.is_null() || settings.as_object().is_none_or(serde_json::Map::is_empty) {
366        Ok(None)
367    } else {
368        Ok(Some((settings, updated_at)))
369    }
370}
371
372/// Fetch cloud-side providers as a structured array. Returns the parsed JSON
373/// array and optional `updated_at`, or None if not set. Cloud holds masked keys
374/// only — callers must NOT trust `maskedKey` as a usable secret.
375pub async fn pull_providers(
376    client: &CloudClient,
377) -> Result<Option<(serde_json::Value, Option<String>)>, crate::CoreError> {
378    let result: SyncProviders = api!(GET "/sync/providers").fetch(client).await?;
379    let val = serde_json::to_value(&result).unwrap_or_default();
380    let providers = val.get("providers").cloned();
381    let updated_at = val
382        .get("updatedAt")
383        .and_then(|v| v.as_str())
384        .map(String::from);
385    Ok(normalize_provider_payload(providers).map(|providers| (providers, updated_at)))
386}
387
388fn normalize_provider_payload(providers: Option<serde_json::Value>) -> Option<serde_json::Value> {
389    match providers {
390        Some(arr @ serde_json::Value::Array(_)) => Some(arr),
391        None | Some(_) => None,
392    }
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct CloudStatus {
397    pub logged_in: bool,
398    pub email: Option<String>,
399    pub plan: Option<String>,
400    pub team_id: Option<String>,
401    pub team_name: Option<String>,
402}
403
404/// Fetch the user's cloud status (profile + billing plan + active team).
405/// Safe to call when not logged in — returns a `logged_in: false` response.
406pub async fn fetch_cloud_status(client: &CloudClient) -> CloudStatus {
407    if !client.is_logged_in() {
408        return CloudStatus {
409            logged_in: false,
410            email: None,
411            plan: None,
412            team_id: None,
413            team_name: None,
414        };
415    }
416
417    let mut status_client = client.clone();
418    let mut profile_result: Result<UserProfile, _> = api!(GET "/auth/profile").fetch(client).await;
419    if profile_result.is_err() && CloudClient::refresh_saved_token().await.is_some() {
420        status_client = CloudClient::create().await;
421        profile_result = api!(GET "/auth/profile").fetch(&status_client).await;
422    }
423    let Ok(profile) = profile_result else {
424        return CloudStatus {
425            logged_in: false,
426            email: None,
427            plan: None,
428            team_id: None,
429            team_name: None,
430        };
431    };
432
433    let email = serde_json::to_value(&profile)
434        .ok()
435        .and_then(|v| v.get("email").and_then(|e| e.as_str()).map(String::from));
436
437    let billing_result: Result<BillingCurrent, _> =
438        api!(GET "/billing/current").fetch(&status_client).await;
439    let plan = billing_result
440        .ok()
441        .and_then(|b| serde_json::to_value(&b).ok())
442        .and_then(|v| v.get("planId").and_then(|p| p.as_str()).map(String::from));
443
444    let team_result: Result<Option<Team>, _> = api!(GET "/teams/my").fetch(&status_client).await;
445    let team_value = team_result
446        .ok()
447        .flatten()
448        .and_then(|t| serde_json::to_value(&t).ok());
449    let team_id = team_value.as_ref().and_then(|v| {
450        v.get("id")
451            .or_else(|| v.get("teamId"))
452            .and_then(|id| id.as_str())
453            .map(String::from)
454    });
455    let team_name = team_value
456        .as_ref()
457        .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(String::from));
458
459    CloudStatus {
460        logged_in: true,
461        email,
462        plan,
463        team_id,
464        team_name,
465    }
466}
467
468fn skill_content_hash(skill: &SkillRecord) -> String {
469    let skill_md_path = match skills_base_dir() {
470        Ok(base) => Some(
471            base.join(&skill.source)
472                .join(&skill.directory)
473                .join("SKILL.md"),
474        ),
475        Err(e) => {
476            warn_skill_hash_fallback(&format!(
477                "failed to resolve skills dir for {}: {e}",
478                skill.id
479            ));
480            None
481        }
482    };
483
484    let content = match skill_md_path {
485        Some(path) => match std::fs::read_to_string(&path) {
486            Ok(markdown) => extract_skill_content_body(&markdown),
487            Err(e) => {
488                if e.kind() != std::io::ErrorKind::NotFound {
489                    warn_skill_hash_fallback(&format!(
490                        "failed to read {} for {}: {e}",
491                        path.display(),
492                        skill.id
493                    ));
494                }
495                fallback_skill_content_for_hash(skill)
496            }
497        },
498        None => fallback_skill_content_for_hash(skill),
499    };
500
501    let digest = sha2::Sha256::digest(content.as_bytes());
502    use std::fmt::Write as _;
503    digest
504        .iter()
505        .fold(String::with_capacity(digest.len() * 2), |mut acc, b| {
506            let _ = write!(acc, "{b:02x}");
507            acc
508        })
509}
510
511fn warn_skill_hash_fallback(message: &str) {
512    if std::env::var_os("DIFFLORE_DEBUG_SYNC_HASH").is_some() {
513        eprintln!("[difflore] rules sync hash fallback: {message}");
514    }
515}
516
517fn fallback_skill_content_for_hash(skill: &SkillRecord) -> String {
518    // Keep the sync contract centered on "content" semantics rather than full metadata.
519    skill
520        .check_prompt
521        .clone()
522        .or_else(|| {
523            if skill.description.trim().is_empty() {
524                None
525            } else {
526                Some(skill.description.clone())
527            }
528        })
529        .unwrap_or_default()
530}
531
532fn extract_skill_content_body(markdown: &str) -> String {
533    let mut lines = markdown.lines();
534    if lines.next().map(str::trim) != Some("---") {
535        return markdown.trim().to_owned();
536    }
537
538    let mut in_frontmatter = true;
539    let mut body_lines: Vec<&str> = Vec::new();
540    for line in markdown.lines().skip(1) {
541        if in_frontmatter {
542            if line.trim() == "---" {
543                in_frontmatter = false;
544            }
545            continue;
546        }
547        body_lines.push(line);
548    }
549
550    if in_frontmatter {
551        markdown.trim().to_owned()
552    } else {
553        body_lines.join("\n").trim().to_owned()
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::models::ProviderRecord;
561    use std::collections::HashMap;
562
563    #[test]
564    fn mask_short_keys_returns_only_bullets() {
565        assert_eq!(mask_api_key(""), "");
566        assert_eq!(mask_api_key("ab"), "••");
567        assert_eq!(mask_api_key("abcd"), "••••");
568    }
569
570    #[test]
571    fn mask_long_keys_keeps_last_four() {
572        assert_eq!(mask_api_key("sk-abcdef1234"), "••••1234");
573        assert_eq!(mask_api_key("  spaced-key-9876  "), "••••9876");
574    }
575
576    fn make_provider(name: &str, key: Option<&str>) -> ProviderRecord {
577        let mut mapping = HashMap::new();
578        mapping.insert("review".into(), "claude-3".into());
579        ProviderRecord {
580            id: format!("{name}-id"),
581            name: name.into(),
582            base_url: format!("https://{name}.example.com"),
583            api_key: key.map(String::from),
584            model_mapping: mapping,
585            is_active: true,
586            created_at: "2026-04-10T00:00:00Z".into(),
587            updated_at: "2026-04-10T00:00:00Z".into(),
588        }
589    }
590
591    #[test]
592    fn build_entries_masks_keys_and_omits_when_absent() {
593        let providers = vec![
594            make_provider("anthropic", Some("sk-ant-1234567890abcd")),
595            make_provider("local", None),
596        ];
597        let entries = build_provider_sync_entries(&providers);
598        assert_eq!(entries.len(), 2);
599
600        let first = entries[0].as_object().unwrap();
601        assert_eq!(first.get("name").unwrap().as_str(), Some("anthropic"));
602        assert_eq!(
603            first.get("baseUrl").unwrap().as_str(),
604            Some("https://anthropic.example.com"),
605        );
606        assert_eq!(first.get("maskedKey").unwrap().as_str(), Some("••••abcd"));
607        assert!(first.get("modelMapping").is_some());
608        assert_eq!(
609            first.get("updatedAt").unwrap().as_str(),
610            Some("2026-04-10T00:00:00Z"),
611        );
612
613        let second = entries[1].as_object().unwrap();
614        assert!(
615            second.get("maskedKey").is_none(),
616            "absent key should not emit maskedKey"
617        );
618    }
619
620    #[test]
621    fn build_entries_skips_empty_model_mapping() {
622        let provider = ProviderRecord {
623            id: "x".into(),
624            name: "x".into(),
625            base_url: "https://x".into(),
626            api_key: None,
627            model_mapping: HashMap::new(),
628            is_active: false,
629            created_at: "t".into(),
630            updated_at: "t".into(),
631        };
632        let entries = build_provider_sync_entries(&[provider]);
633        assert!(entries[0].get("modelMapping").is_none());
634    }
635
636    #[test]
637    fn provider_pull_payload_is_canonical_array_only() {
638        let array = serde_json::json!([{ "name": "codex" }]);
639        assert_eq!(
640            normalize_provider_payload(Some(array.clone())).as_ref(),
641            Some(&array)
642        );
643
644        let stringified = serde_json::json!(r#"[{"name":"old"}]"#);
645        assert!(
646            normalize_provider_payload(Some(stringified)).is_none(),
647            "stringified provider JSON must fail closed"
648        );
649        assert!(normalize_provider_payload(Some(serde_json::json!({}))).is_none());
650    }
651
652    #[test]
653    fn sync_rule_mapping_uses_canonical_source_repo_field_only() {
654        let val = serde_json::json!({
655            "id": "rule-1",
656            "name": "Rule",
657            "content": "Body",
658            "source_repo": "acme/retired",
659            "sourceRepo": "acme/canonical"
660        });
661        let mapped = map_synced_rule_value(&val).expect("valid rule");
662        assert_eq!(mapped.source_repo.as_deref(), Some("acme/canonical"));
663
664        let retired_only = serde_json::json!({
665            "id": "rule-2",
666            "name": "Rule",
667            "content": "Body",
668            "source_repo": "acme/retired"
669        });
670        let mapped = map_synced_rule_value(&retired_only).expect("valid rule");
671        assert_eq!(mapped.source_repo, None);
672    }
673
674    #[test]
675    fn sync_rule_mapping_rejects_missing_required_id_or_content() {
676        // Missing id → rejected (would otherwise create an empty-id local rule).
677        let no_id = serde_json::json!({ "name": "R", "content": "Body" });
678        assert!(map_synced_rule_value(&no_id).is_err());
679        // Empty/whitespace content → rejected.
680        let empty_content = serde_json::json!({ "id": "r", "name": "R", "content": "  " });
681        assert!(map_synced_rule_value(&empty_content).is_err());
682        // Both present → accepted.
683        let ok = serde_json::json!({ "id": "r", "name": "R", "content": "Body" });
684        assert!(map_synced_rule_value(&ok).is_ok());
685    }
686
687    #[test]
688    fn skill_content_hash_falls_back_silently_when_skill_file_is_missing() {
689        let skill = SkillRecord {
690            id: "missing-cloud-rule".into(),
691            name: "Missing cloud rule".into(),
692            source: "cloud".into(),
693            directory: "missing-cloud-rule".into(),
694            version: "1.0.0".into(),
695            description: "description fallback".into(),
696            r#type: "review_standard".into(),
697            engines: vec![],
698            tags: vec![],
699            trigger: None,
700            check_prompt: Some("prefer check prompt for hashing".into()),
701            repo_owner: None,
702            repo_name: None,
703            repo_branch: None,
704            readme_url: None,
705            enabled_for_codex: true,
706            enabled_for_claude: true,
707            enabled_for_gemini: true,
708            enabled_for_cursor: true,
709            installed_at: "2026-05-11T00:00:00Z".into(),
710            updated_at: "2026-05-11T00:00:00Z".into(),
711            enforcement: None,
712            origin: "pr_review".into(),
713        };
714
715        let expected = {
716            let digest = sha2::Sha256::digest(b"prefer check prompt for hashing");
717            use std::fmt::Write as _;
718            digest
719                .iter()
720                .fold(String::with_capacity(digest.len() * 2), |mut acc, b| {
721                    let _ = write!(acc, "{b:02x}");
722                    acc
723                })
724        };
725
726        assert_eq!(skill_content_hash(&skill), expected);
727    }
728}