Skip to main content

joy_core/
embedded.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Embedded file sync: hash-based diff and install for files shipped inside the Joy binary.
5
6use std::fs;
7use std::path::Path;
8
9use sha2::{Digest, Sha256};
10
11use crate::error::JoyError;
12use crate::store;
13
14/// An embedded file that ships with the Joy binary.
15pub struct EmbeddedFile {
16    /// Content (from `include_str!`).
17    pub content: &'static str,
18    /// Target path relative to `.joy/` (e.g. `hooks/commit-msg`).
19    pub target: &'static str,
20    /// Whether the file should be executable (Unix only).
21    pub executable: bool,
22}
23
24/// Status of an installed file compared to the embedded version.
25#[derive(Debug, PartialEq, Eq)]
26pub enum FileStatus {
27    UpToDate,
28    Outdated,
29    Missing,
30}
31
32/// Result of a sync operation for one file.
33#[derive(Debug)]
34pub struct SyncAction {
35    pub target: &'static str,
36    pub action: &'static str, // "updated", "created", "up to date"
37}
38
39fn sha256_hex(data: &str) -> String {
40    let mut hasher = Sha256::new();
41    hasher.update(data.as_bytes());
42    format!("{:x}", hasher.finalize())
43}
44
45/// Compare installed files against embedded versions.
46/// Returns a list of (target_path, status) for files that are not up to date.
47pub fn diff_files(
48    root: &Path,
49    files: &[EmbeddedFile],
50) -> Result<Vec<(&'static str, FileStatus)>, JoyError> {
51    let joy_dir = store::joy_dir(root);
52    let mut results = Vec::new();
53
54    for file in files {
55        let installed_path = joy_dir.join(file.target);
56        let expected_hash = sha256_hex(file.content);
57
58        let status = if installed_path.is_file() {
59            let installed =
60                fs::read_to_string(&installed_path).map_err(|e| JoyError::ReadFile {
61                    path: installed_path.clone(),
62                    source: e,
63                })?;
64            if sha256_hex(&installed) == expected_hash {
65                FileStatus::UpToDate
66            } else {
67                FileStatus::Outdated
68            }
69        } else {
70            FileStatus::Missing
71        };
72
73        results.push((file.target, status));
74    }
75
76    Ok(results)
77}
78
79/// Sync embedded files to disk. Only writes files that are outdated or missing.
80/// Returns a list of actions taken.
81pub fn sync_files(root: &Path, files: &[EmbeddedFile]) -> Result<Vec<SyncAction>, JoyError> {
82    let joy_dir = store::joy_dir(root);
83    let diffs = diff_files(root, files)?;
84    let mut actions = Vec::new();
85
86    for (file, (_target, status)) in files.iter().zip(diffs.iter()) {
87        let action = match status {
88            FileStatus::UpToDate => {
89                actions.push(SyncAction {
90                    target: file.target,
91                    action: "up to date",
92                });
93                continue;
94            }
95            FileStatus::Outdated => "updated",
96            FileStatus::Missing => "created",
97        };
98
99        let installed_path = joy_dir.join(file.target);
100        if let Some(parent) = installed_path.parent() {
101            fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
102                path: parent.to_path_buf(),
103                source: e,
104            })?;
105        }
106
107        fs::write(&installed_path, file.content).map_err(|e| JoyError::WriteFile {
108            path: installed_path.clone(),
109            source: e,
110        })?;
111
112        #[cfg(unix)]
113        if file.executable {
114            use std::os::unix::fs::PermissionsExt;
115            let perms = fs::Permissions::from_mode(0o755);
116            fs::set_permissions(&installed_path, perms).map_err(|e| JoyError::WriteFile {
117                path: installed_path,
118                source: e,
119            })?;
120        }
121
122        actions.push(SyncAction {
123            target: file.target,
124            action,
125        });
126    }
127
128    Ok(actions)
129}
130
131/// Check if all files are up to date (no outdated or missing files).
132pub fn all_up_to_date(root: &Path, files: &[EmbeddedFile]) -> Result<bool, JoyError> {
133    let diffs = diff_files(root, files)?;
134    Ok(diffs.iter().all(|(_, s)| *s == FileStatus::UpToDate))
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use tempfile::tempdir;
141
142    fn setup_project(dir: &Path) {
143        let joy_dir = dir.join(".joy");
144        fs::create_dir_all(&joy_dir).unwrap();
145        fs::write(joy_dir.join("project.yaml"), "name: test\nacronym: TP\n").unwrap();
146        fs::write(joy_dir.join("config.defaults.yaml"), "version: 1\n").unwrap();
147    }
148
149    #[test]
150    fn diff_missing_file() {
151        let dir = tempdir().unwrap();
152        setup_project(dir.path());
153
154        let files = [EmbeddedFile {
155            content: "hello",
156            target: "test/file.txt",
157            executable: false,
158        }];
159
160        let diffs = diff_files(dir.path(), &files).unwrap();
161        assert_eq!(diffs.len(), 1);
162        assert_eq!(diffs[0].1, FileStatus::Missing);
163    }
164
165    #[test]
166    fn sync_creates_and_reports() {
167        let dir = tempdir().unwrap();
168        setup_project(dir.path());
169
170        let files = [EmbeddedFile {
171            content: "hello",
172            target: "test/file.txt",
173            executable: false,
174        }];
175
176        let actions = sync_files(dir.path(), &files).unwrap();
177        assert_eq!(actions.len(), 1);
178        assert_eq!(actions[0].action, "created");
179
180        // Second sync should be up to date
181        let actions = sync_files(dir.path(), &files).unwrap();
182        assert_eq!(actions[0].action, "up to date");
183    }
184
185    #[test]
186    fn sync_detects_outdated() {
187        let dir = tempdir().unwrap();
188        setup_project(dir.path());
189
190        let files = [EmbeddedFile {
191            content: "new content",
192            target: "test/file.txt",
193            executable: false,
194        }];
195
196        // Write old content
197        let path = dir.path().join(".joy/test");
198        fs::create_dir_all(&path).unwrap();
199        fs::write(path.join("file.txt"), "old content").unwrap();
200
201        let diffs = diff_files(dir.path(), &files).unwrap();
202        assert_eq!(diffs[0].1, FileStatus::Outdated);
203
204        let actions = sync_files(dir.path(), &files).unwrap();
205        assert_eq!(actions[0].action, "updated");
206
207        // Verify content was updated
208        let content = fs::read_to_string(path.join("file.txt")).unwrap();
209        assert_eq!(content, "new content");
210    }
211
212    #[test]
213    fn all_up_to_date_check() {
214        let dir = tempdir().unwrap();
215        setup_project(dir.path());
216
217        let files = [EmbeddedFile {
218            content: "hello",
219            target: "test/file.txt",
220            executable: false,
221        }];
222
223        assert!(!all_up_to_date(dir.path(), &files).unwrap());
224        sync_files(dir.path(), &files).unwrap();
225        assert!(all_up_to_date(dir.path(), &files).unwrap());
226    }
227}