spool/bootstrap/
release.rs1use anyhow::{Context, Result, bail};
13use std::path::{Path, PathBuf};
14
15pub const BUNDLED_BINARIES: &[&str] = &["spool", "spool-mcp", "spool-daemon"];
17
18#[derive(Debug, Clone)]
20pub struct ReleaseReport {
21 pub copied: Vec<PathBuf>,
22 pub skipped: Vec<PathBuf>,
23}
24
25pub fn release_binaries(
33 source_dir: &Path,
34 target_bin_dir: &Path,
35 force: bool,
36) -> Result<ReleaseReport> {
37 if !source_dir.is_dir() {
38 bail!("source directory does not exist: {}", source_dir.display());
39 }
40 std::fs::create_dir_all(target_bin_dir)
41 .with_context(|| format!("creating {}", target_bin_dir.display()))?;
42
43 let mut copied = Vec::new();
44 let mut skipped = Vec::new();
45
46 for name in BUNDLED_BINARIES {
47 let exe_name = if cfg!(windows) {
48 format!("{name}.exe")
49 } else {
50 (*name).to_string()
51 };
52 let source = source_dir.join(&exe_name);
53 let target = target_bin_dir.join(&exe_name);
54
55 if !source.exists() {
56 continue;
58 }
59
60 if !force && target.exists() && files_appear_identical(&source, &target)? {
61 skipped.push(target);
62 continue;
63 }
64
65 copy_binary(&source, &target)?;
66 copied.push(target);
67 }
68
69 Ok(ReleaseReport { copied, skipped })
70}
71
72fn copy_binary(source: &Path, target: &Path) -> Result<()> {
74 std::fs::copy(source, target)
75 .with_context(|| format!("copying {} → {}", source.display(), target.display()))?;
76
77 #[cfg(unix)]
78 {
79 use std::os::unix::fs::PermissionsExt;
80 let mut perms = std::fs::metadata(target)
81 .with_context(|| format!("reading permissions for {}", target.display()))?
82 .permissions();
83 perms.set_mode(0o755);
84 std::fs::set_permissions(target, perms)
85 .with_context(|| format!("setting permissions on {}", target.display()))?;
86 }
87
88 Ok(())
89}
90
91fn files_appear_identical(a: &Path, b: &Path) -> Result<bool> {
94 let meta_a =
95 std::fs::metadata(a).with_context(|| format!("reading metadata for {}", a.display()))?;
96 let meta_b =
97 std::fs::metadata(b).with_context(|| format!("reading metadata for {}", b.display()))?;
98 Ok(meta_a.len() == meta_b.len())
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use std::fs;
105 use tempfile::tempdir;
106
107 fn write_fake_binary(dir: &Path, name: &str, content: &str) -> PathBuf {
108 let exe_name = if cfg!(windows) {
109 format!("{name}.exe")
110 } else {
111 name.to_string()
112 };
113 let path = dir.join(&exe_name);
114 fs::write(&path, content).unwrap();
115 path
116 }
117
118 #[test]
119 fn release_should_copy_existing_bundled_binaries() {
120 let temp = tempdir().unwrap();
121 let source = temp.path().join("source");
122 let target = temp.path().join("target");
123 fs::create_dir_all(&source).unwrap();
124
125 write_fake_binary(&source, "spool", "fake spool");
126 write_fake_binary(&source, "spool-mcp", "fake spool-mcp");
127 let report = release_binaries(&source, &target, false).unwrap();
130 assert_eq!(report.copied.len(), 2);
131 assert!(report.skipped.is_empty());
132
133 let target_spool = if cfg!(windows) { "spool.exe" } else { "spool" };
134 assert!(target.join(target_spool).exists());
135 }
136
137 #[test]
138 fn release_should_skip_when_not_forced_and_target_exists() {
139 let temp = tempdir().unwrap();
140 let source = temp.path().join("source");
141 let target = temp.path().join("target");
142 fs::create_dir_all(&source).unwrap();
143 fs::create_dir_all(&target).unwrap();
144
145 write_fake_binary(&source, "spool", "v1");
146 write_fake_binary(&target, "spool", "v1");
147
148 let report = release_binaries(&source, &target, false).unwrap();
149 assert_eq!(report.copied.len(), 0);
150 assert_eq!(report.skipped.len(), 1);
151 }
152
153 #[test]
154 fn release_should_overwrite_when_forced() {
155 let temp = tempdir().unwrap();
156 let source = temp.path().join("source");
157 let target = temp.path().join("target");
158 fs::create_dir_all(&source).unwrap();
159 fs::create_dir_all(&target).unwrap();
160
161 write_fake_binary(&source, "spool", "new version");
162 write_fake_binary(&target, "spool", "old version");
163
164 let report = release_binaries(&source, &target, true).unwrap();
165 assert_eq!(report.copied.len(), 1);
166
167 let target_spool = if cfg!(windows) { "spool.exe" } else { "spool" };
168 let copied = fs::read_to_string(target.join(target_spool)).unwrap();
169 assert_eq!(copied, "new version");
170 }
171
172 #[test]
173 fn release_should_fail_when_source_dir_missing() {
174 let temp = tempdir().unwrap();
175 let source = temp.path().join("nonexistent");
176 let target = temp.path().join("target");
177 let result = release_binaries(&source, &target, false);
178 assert!(result.is_err());
179 }
180
181 #[cfg(unix)]
182 #[test]
183 fn release_should_set_executable_permissions() {
184 use std::os::unix::fs::PermissionsExt;
185 let temp = tempdir().unwrap();
186 let source = temp.path().join("source");
187 let target = temp.path().join("target");
188 fs::create_dir_all(&source).unwrap();
189
190 write_fake_binary(&source, "spool", "binary");
191 release_binaries(&source, &target, false).unwrap();
192
193 let mode = fs::metadata(target.join("spool"))
194 .unwrap()
195 .permissions()
196 .mode();
197 assert_eq!(mode & 0o755, 0o755);
198 }
199}