1use std::io::Read;
20use std::path::Path;
21
22use sha2::{Digest, Sha256};
23
24use crate::datastore::DataStore;
25use crate::fs::Fs;
26use crate::handlers::{ExecutionPhase, Handler, HandlerConfig, HandlerStatus, HANDLER_INSTALL};
27use crate::operations::HandlerIntent;
28use crate::paths::Pather;
29use crate::rules::RuleMatch;
30use crate::Result;
31
32pub struct InstallHandler<'a> {
33 fs: &'a dyn Fs,
34}
35
36impl<'a> InstallHandler<'a> {
37 pub fn new(fs: &'a dyn Fs) -> Self {
38 Self { fs }
39 }
40}
41
42impl Handler for InstallHandler<'_> {
43 fn name(&self) -> &str {
44 HANDLER_INSTALL
45 }
46
47 fn phase(&self) -> ExecutionPhase {
48 ExecutionPhase::Setup
49 }
50
51 fn to_intents(
52 &self,
53 matches: &[RuleMatch],
54 _config: &HandlerConfig,
55 _paths: &dyn Pather,
56 _fs: &dyn Fs,
57 ) -> Result<Vec<HandlerIntent>> {
58 let mut intents = Vec::new();
59
60 for m in matches {
61 if m.is_dir {
62 continue;
63 }
64
65 let checksum = match m.rendered_bytes.as_deref() {
82 Some(bytes) => file_checksum_bytes(bytes),
83 None => match self.fs.exists(&m.absolute_path) {
84 true => file_checksum(self.fs, &m.absolute_path)?,
85 false => {
86 tracing::debug!(
87 pack = %m.pack,
88 file = %m.absolute_path.display(),
89 "skipping install intent — no rendered bytes and no on-disk file \
90 (first-time-pack passive placeholder)"
91 );
92 continue;
93 }
94 },
95 };
96 let filename = m
97 .relative_path
98 .file_name()
99 .unwrap_or_default()
100 .to_string_lossy();
101 let sentinel = format!("{filename}-{checksum}");
102
103 intents.push(HandlerIntent::Run {
104 pack: m.pack.clone(),
105 handler: HANDLER_INSTALL.into(),
106 executable: interpreter_for(&m.absolute_path).into(),
107 arguments: vec!["--".into(), m.absolute_path.to_string_lossy().into_owned()],
108 sentinel,
109 });
110 }
111
112 Ok(intents)
113 }
114
115 fn check_status(
116 &self,
117 file: &Path,
118 pack: &str,
119 datastore: &dyn DataStore,
120 ) -> Result<HandlerStatus> {
121 let checksum = file_checksum(self.fs, file)?;
122 let filename = file.file_name().unwrap_or_default().to_string_lossy();
123 let sentinel = format!("{filename}-{checksum}");
124 let has_sentinel = datastore.has_sentinel(pack, HANDLER_INSTALL, &sentinel)?;
125
126 Ok(HandlerStatus {
127 file: file.to_string_lossy().into_owned(),
128 handler: HANDLER_INSTALL.into(),
129 deployed: has_sentinel,
130 message: if has_sentinel {
131 "installed".into()
132 } else {
133 "never run".into()
134 },
135 })
136 }
137}
138
139fn interpreter_for(path: &Path) -> &'static str {
144 match path.extension().and_then(|e| e.to_str()) {
145 Some("zsh") => "zsh",
146 _ => "bash",
147 }
148}
149
150fn file_checksum(fs: &dyn Fs, path: &Path) -> Result<String> {
152 let mut reader = fs.open_read(path)?;
153 let mut hasher = Sha256::new();
154 let mut buf = [0u8; 8192];
155 loop {
156 let n = reader.read(&mut buf).map_err(|e| crate::DodotError::Fs {
157 path: path.to_path_buf(),
158 source: e,
159 })?;
160 if n == 0 {
161 break;
162 }
163 hasher.update(&buf[..n]);
164 }
165 let hash = hasher.finalize();
166 Ok(hex::encode(&hash[..8]))
168}
169
170fn file_checksum_bytes(bytes: &[u8]) -> String {
174 let mut hasher = Sha256::new();
175 hasher.update(bytes);
176 let hash = hasher.finalize();
177 hex::encode(&hash[..8])
178}
179
180mod hex {
182 pub fn encode(bytes: &[u8]) -> String {
183 bytes.iter().map(|b| format!("{b:02x}")).collect()
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::testing::TempEnvironment;
191
192 #[test]
193 fn checksum_is_deterministic() {
194 let env = TempEnvironment::builder()
195 .pack("test")
196 .file("install.sh", "#!/bin/sh\necho hello")
197 .done()
198 .build();
199
200 let path = env.dotfiles_root.join("test/install.sh");
201 let c1 = file_checksum(env.fs.as_ref(), &path).unwrap();
202 let c2 = file_checksum(env.fs.as_ref(), &path).unwrap();
203 assert_eq!(c1, c2);
204 assert_eq!(c1.len(), 16); }
206
207 #[test]
208 fn checksum_changes_with_content() {
209 let env = TempEnvironment::builder()
210 .pack("test")
211 .file("a.sh", "version 1")
212 .file("b.sh", "version 2")
213 .done()
214 .build();
215
216 let ca = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/a.sh")).unwrap();
217 let cb = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/b.sh")).unwrap();
218 assert_ne!(ca, cb);
219 }
220
221 #[test]
222 fn to_intents_produces_run_with_sentinel() {
223 let env = TempEnvironment::builder()
224 .pack("vim")
225 .file("install.sh", "#!/bin/sh\nsetup")
226 .done()
227 .build();
228
229 let handler = InstallHandler::new(env.fs.as_ref());
230 let matches = vec![crate::rules::RuleMatch {
231 relative_path: "install.sh".into(),
232 absolute_path: env.dotfiles_root.join("vim/install.sh"),
233 pack: "vim".into(),
234 handler: "install".into(),
235 is_dir: false,
236 options: std::collections::HashMap::new(),
237 preprocessor_source: None,
238 rendered_bytes: None,
239 }];
240
241 let pather = crate::paths::XdgPather::builder()
242 .home(&env.home)
243 .dotfiles_root(&env.dotfiles_root)
244 .build()
245 .unwrap();
246
247 let intents = handler
248 .to_intents(
249 &matches,
250 &HandlerConfig::default(),
251 &pather,
252 env.fs.as_ref(),
253 )
254 .unwrap();
255
256 assert_eq!(intents.len(), 1);
257 match &intents[0] {
258 HandlerIntent::Run {
259 executable,
260 arguments,
261 sentinel,
262 ..
263 } => {
264 assert_eq!(executable, "bash");
265 assert_eq!(arguments[0], "--");
266 assert!(arguments[1].contains("install.sh"));
267 assert!(sentinel.starts_with("install.sh-"));
268 assert_eq!(sentinel.len(), "install.sh-".len() + 16);
269 }
270 other => panic!("expected Run, got {other:?}"),
271 }
272 }
273
274 #[test]
275 fn interpreter_for_selects_by_extension() {
276 assert_eq!(interpreter_for(Path::new("install.sh")), "bash");
277 assert_eq!(interpreter_for(Path::new("install.bash")), "bash");
278 assert_eq!(interpreter_for(Path::new("install.zsh")), "zsh");
279 assert_eq!(interpreter_for(Path::new("install")), "bash");
281 assert_eq!(interpreter_for(Path::new("install.ksh")), "bash");
282 assert_eq!(interpreter_for(Path::new("/a/b/install.zsh")), "zsh");
284 }
285
286 #[test]
287 fn to_intents_picks_interpreter_per_script() {
288 let env = TempEnvironment::builder()
289 .pack("vim")
290 .file("install.sh", "echo sh")
291 .file("install.bash", "echo bash")
292 .file("install.zsh", "echo zsh")
293 .done()
294 .build();
295
296 let handler = InstallHandler::new(env.fs.as_ref());
297 let make_match = |name: &str| crate::rules::RuleMatch {
298 relative_path: name.into(),
299 absolute_path: env.dotfiles_root.join(format!("vim/{name}")),
300 pack: "vim".into(),
301 handler: "install".into(),
302 is_dir: false,
303 options: std::collections::HashMap::new(),
304 preprocessor_source: None,
305 rendered_bytes: None,
306 };
307 let matches = vec![
308 make_match("install.sh"),
309 make_match("install.bash"),
310 make_match("install.zsh"),
311 ];
312
313 let pather = crate::paths::XdgPather::builder()
314 .home(&env.home)
315 .dotfiles_root(&env.dotfiles_root)
316 .build()
317 .unwrap();
318
319 let intents = handler
320 .to_intents(
321 &matches,
322 &HandlerConfig::default(),
323 &pather,
324 env.fs.as_ref(),
325 )
326 .unwrap();
327
328 let chosen: Vec<(String, String)> = intents
329 .iter()
330 .map(|i| match i {
331 HandlerIntent::Run {
332 executable,
333 arguments,
334 ..
335 } => (
336 executable.clone(),
337 arguments
338 .last()
339 .cloned()
340 .and_then(|p| {
341 std::path::Path::new(&p)
342 .file_name()
343 .map(|n| n.to_string_lossy().into_owned())
344 })
345 .unwrap_or_default(),
346 ),
347 other => panic!("expected Run, got {other:?}"),
348 })
349 .collect();
350
351 assert!(chosen.contains(&("bash".into(), "install.sh".into())));
352 assert!(chosen.contains(&("bash".into(), "install.bash".into())));
353 assert!(chosen.contains(&("zsh".into(), "install.zsh".into())));
354 }
355}