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 #[serde(default)]
43 pub file_patterns: Vec<String>,
44 #[serde(default)]
50 pub origin: Option<String>,
51 #[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
78pub 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 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 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
290pub 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
303pub 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
350pub 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
372pub 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
404pub 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 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 let no_id = serde_json::json!({ "name": "R", "content": "Body" });
678 assert!(map_synced_rule_value(&no_id).is_err());
679 let empty_content = serde_json::json!({ "id": "r", "name": "R", "content": " " });
681 assert!(map_synced_rule_value(&empty_content).is_err());
682 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}