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