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) -> HashMap<String, DiskPlaybook> {
90 use walkdir::WalkDir;
91
92 let mut playbooks = HashMap::new();
93
94 if !path.exists() {
95 return playbooks;
96 }
97
98 for entry in WalkDir::new(path)
99 .min_depth(2)
100 .into_iter()
101 .filter_map(Result::ok)
102 .filter(|e| e.file_type().is_file())
103 .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
104 {
105 let file_path = entry.path();
106
107 if let Ok(relative) = file_path.strip_prefix(path) {
108 let components: Vec<&str> = relative
109 .components()
110 .filter_map(|c| c.as_os_str().to_str())
111 .collect();
112
113 if components.len() >= 2 {
114 let category = components[0];
115 let filename = components[components.len() - 1];
116 let domain_name = filename.strip_suffix(".md").unwrap_or(filename);
117
118 let domain_parts: Vec<&str> = components[1..components.len() - 1]
119 .iter()
120 .copied()
121 .chain(std::iter::once(domain_name))
122 .collect();
123 let domain = domain_parts.join("/");
124
125 match parse_playbook_file(file_path, category, &domain) {
126 Ok(playbook) => {
127 playbooks.insert(playbook.playbook_id.clone(), playbook);
128 },
129 Err(e) => {
130 warn!("Failed to parse playbook at {}: {}", file_path.display(), e);
131 },
132 }
133 }
134 }
135 }
136
137 playbooks
138 }
139}
140
141fn parse_playbook_file(md_path: &Path, category: &str, domain: &str) -> Result<DiskPlaybook> {
142 let content = std::fs::read_to_string(md_path)?;
143
144 let parts: Vec<&str> = content.splitn(3, "---").collect();
145 if parts.len() < 3 {
146 return Err(anyhow!("Invalid frontmatter format"));
147 }
148
149 let frontmatter: serde_yaml::Value = serde_yaml::from_str(parts[1])?;
150 let instructions = parts[2].trim().to_string();
151
152 let playbook_id = format!("{}_{}", category, domain.replace('/', "_"));
153
154 let name = frontmatter
155 .get("title")
156 .and_then(|v| v.as_str())
157 .ok_or_else(|| anyhow!("Missing title in frontmatter"))?
158 .to_string();
159
160 let description = frontmatter
161 .get("description")
162 .and_then(|v| v.as_str())
163 .unwrap_or("")
164 .to_string();
165
166 Ok(DiskPlaybook {
167 playbook_id,
168 name,
169 description,
170 instructions,
171 category: category.to_string(),
172 domain: domain.to_string(),
173 file_path: md_path.to_string_lossy().to_string(),
174 })
175}
176
177fn compute_playbook_hash(playbook: &DiskPlaybook) -> String {
178 let mut hasher = Sha256::new();
179 hasher.update(playbook.name.as_bytes());
180 hasher.update(playbook.description.as_bytes());
181 hasher.update(playbook.instructions.as_bytes());
182 format!("{:x}", hasher.finalize())
183}
184
185fn compute_db_playbook_hash(playbook: &Playbook) -> String {
186 let mut hasher = Sha256::new();
187 hasher.update(playbook.name.as_bytes());
188 hasher.update(playbook.description.as_bytes());
189 hasher.update(playbook.instructions.as_bytes());
190 format!("{:x}", hasher.finalize())
191}