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