Skip to main content

systemprompt_sync/diff/
playbooks.rs

1use 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}