systemprompt_sync/diff/
playbooks.rs1use crate::models::{DiffStatus, DiskPlaybook, PlaybookDiffItem, PlaybooksDiffResult};
2use anyhow::{anyhow, Result};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::path::Path;
6use std::sync::Arc;
7use systemprompt_agent::models::Playbook;
8use systemprompt_agent::repository::content::PlaybookRepository;
9use systemprompt_database::DatabaseProvider;
10use tracing::warn;
11
12#[derive(Debug)]
13pub struct PlaybooksDiffCalculator {
14 playbook_repo: PlaybookRepository,
15}
16
17impl PlaybooksDiffCalculator {
18 pub fn new(db: Arc<dyn DatabaseProvider>) -> Self {
19 Self {
20 playbook_repo: PlaybookRepository::new(db),
21 }
22 }
23
24 pub async fn calculate_diff(&self, playbooks_path: &Path) -> Result<PlaybooksDiffResult> {
25 let db_playbooks = self.playbook_repo.list_all().await?;
26 let db_map: HashMap<String, Playbook> = db_playbooks
27 .into_iter()
28 .map(|p| (p.playbook_id.as_str().to_string(), p))
29 .collect();
30
31 let disk_playbooks = Self::scan_disk_playbooks(playbooks_path)?;
32
33 let mut result = PlaybooksDiffResult::default();
34
35 for (playbook_id, disk_playbook) in &disk_playbooks {
36 let disk_hash = compute_playbook_hash(disk_playbook);
37
38 match db_map.get(playbook_id.as_str()) {
39 None => {
40 result.added.push(PlaybookDiffItem {
41 playbook_id: playbook_id.clone(),
42 file_path: disk_playbook.file_path.clone(),
43 category: disk_playbook.category.clone(),
44 domain: disk_playbook.domain.clone(),
45 status: DiffStatus::Added,
46 disk_hash: Some(disk_hash),
47 db_hash: None,
48 name: Some(disk_playbook.name.clone()),
49 });
50 },
51 Some(db_playbook) => {
52 let db_hash = compute_db_playbook_hash(db_playbook);
53 if db_hash == disk_hash {
54 result.unchanged += 1;
55 } else {
56 result.modified.push(PlaybookDiffItem {
57 playbook_id: playbook_id.clone(),
58 file_path: disk_playbook.file_path.clone(),
59 category: disk_playbook.category.clone(),
60 domain: disk_playbook.domain.clone(),
61 status: DiffStatus::Modified,
62 disk_hash: Some(disk_hash),
63 db_hash: Some(db_hash),
64 name: Some(disk_playbook.name.clone()),
65 });
66 }
67 },
68 }
69 }
70
71 for (playbook_id, db_playbook) in &db_map {
72 if !disk_playbooks.contains_key(playbook_id.as_str()) {
73 result.removed.push(PlaybookDiffItem {
74 playbook_id: playbook_id.clone(),
75 file_path: db_playbook.file_path.clone(),
76 category: db_playbook.category.clone(),
77 domain: db_playbook.domain.clone(),
78 status: DiffStatus::Removed,
79 disk_hash: None,
80 db_hash: Some(compute_db_playbook_hash(db_playbook)),
81 name: Some(db_playbook.name.clone()),
82 });
83 }
84 }
85
86 Ok(result)
87 }
88
89 fn scan_disk_playbooks(path: &Path) -> Result<HashMap<String, DiskPlaybook>> {
90 let mut playbooks = HashMap::new();
91
92 if !path.exists() {
93 return Ok(playbooks);
94 }
95
96 for category_entry in std::fs::read_dir(path)? {
97 let category_entry = category_entry?;
98 let category_path = category_entry.path();
99
100 if !category_path.is_dir() {
101 continue;
102 }
103
104 let category = category_path
105 .file_name()
106 .and_then(|n| n.to_str())
107 .unwrap_or("")
108 .to_string();
109
110 for file_entry in std::fs::read_dir(&category_path)? {
111 let file_entry = file_entry?;
112 let file_path = file_entry.path();
113
114 if !file_path.is_file() {
115 continue;
116 }
117
118 let extension = file_path.extension().and_then(|e| e.to_str());
119 if extension != Some("md") {
120 continue;
121 }
122
123 let domain = file_path
124 .file_stem()
125 .and_then(|n| n.to_str())
126 .unwrap_or("")
127 .to_string();
128
129 match parse_playbook_file(&file_path, &category, &domain) {
130 Ok(playbook) => {
131 playbooks.insert(playbook.playbook_id.clone(), playbook);
132 },
133 Err(e) => {
134 warn!("Failed to parse playbook at {}: {}", file_path.display(), e);
135 },
136 }
137 }
138 }
139
140 Ok(playbooks)
141 }
142}
143
144fn parse_playbook_file(md_path: &Path, category: &str, domain: &str) -> Result<DiskPlaybook> {
145 let content = std::fs::read_to_string(md_path)?;
146
147 let parts: Vec<&str> = content.splitn(3, "---").collect();
148 if parts.len() < 3 {
149 return Err(anyhow!("Invalid frontmatter format"));
150 }
151
152 let frontmatter: serde_yaml::Value = serde_yaml::from_str(parts[1])?;
153 let instructions = parts[2].trim().to_string();
154
155 let playbook_id = format!("{}_{}", category, domain);
156
157 let name = frontmatter
158 .get("title")
159 .and_then(|v| v.as_str())
160 .ok_or_else(|| anyhow!("Missing title in frontmatter"))?
161 .to_string();
162
163 let description = frontmatter
164 .get("description")
165 .and_then(|v| v.as_str())
166 .unwrap_or("")
167 .to_string();
168
169 Ok(DiskPlaybook {
170 playbook_id,
171 name,
172 description,
173 instructions,
174 category: category.to_string(),
175 domain: domain.to_string(),
176 file_path: md_path.to_string_lossy().to_string(),
177 })
178}
179
180fn compute_playbook_hash(playbook: &DiskPlaybook) -> String {
181 let mut hasher = Sha256::new();
182 hasher.update(playbook.name.as_bytes());
183 hasher.update(playbook.description.as_bytes());
184 hasher.update(playbook.instructions.as_bytes());
185 format!("{:x}", hasher.finalize())
186}
187
188fn compute_db_playbook_hash(playbook: &Playbook) -> String {
189 let mut hasher = Sha256::new();
190 hasher.update(playbook.name.as_bytes());
191 hasher.update(playbook.description.as_bytes());
192 hasher.update(playbook.instructions.as_bytes());
193 format!("{:x}", hasher.finalize())
194}