1use std::fs;
7use std::path::Path;
8
9use sha2::{Digest, Sha256};
10
11use crate::error::JoyError;
12use crate::store;
13
14pub struct EmbeddedFile {
16 pub content: &'static str,
18 pub target: &'static str,
20 pub executable: bool,
22}
23
24#[derive(Debug, PartialEq, Eq)]
26pub enum FileStatus {
27 UpToDate,
28 Outdated,
29 Missing,
30}
31
32#[derive(Debug)]
34pub struct SyncAction {
35 pub target: &'static str,
36 pub action: &'static str, }
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
45pub 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
79pub 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
131pub 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 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 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 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}