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