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 = file_checksum(self.fs, &m.absolute_path)?;
66 let filename = m
67 .relative_path
68 .file_name()
69 .unwrap_or_default()
70 .to_string_lossy();
71 let sentinel = format!("{filename}-{checksum}");
72
73 intents.push(HandlerIntent::Run {
74 pack: m.pack.clone(),
75 handler: HANDLER_INSTALL.into(),
76 executable: interpreter_for(&m.absolute_path).into(),
77 arguments: vec!["--".into(), m.absolute_path.to_string_lossy().into_owned()],
78 sentinel,
79 });
80 }
81
82 Ok(intents)
83 }
84
85 fn check_status(
86 &self,
87 file: &Path,
88 pack: &str,
89 datastore: &dyn DataStore,
90 ) -> Result<HandlerStatus> {
91 let checksum = file_checksum(self.fs, file)?;
92 let filename = file.file_name().unwrap_or_default().to_string_lossy();
93 let sentinel = format!("{filename}-{checksum}");
94 let has_sentinel = datastore.has_sentinel(pack, HANDLER_INSTALL, &sentinel)?;
95
96 Ok(HandlerStatus {
97 file: file.to_string_lossy().into_owned(),
98 handler: HANDLER_INSTALL.into(),
99 deployed: has_sentinel,
100 message: if has_sentinel {
101 "installed".into()
102 } else {
103 "never run".into()
104 },
105 })
106 }
107}
108
109fn interpreter_for(path: &Path) -> &'static str {
114 match path.extension().and_then(|e| e.to_str()) {
115 Some("zsh") => "zsh",
116 _ => "bash",
117 }
118}
119
120fn file_checksum(fs: &dyn Fs, path: &Path) -> Result<String> {
122 let mut reader = fs.open_read(path)?;
123 let mut hasher = Sha256::new();
124 let mut buf = [0u8; 8192];
125 loop {
126 let n = reader.read(&mut buf).map_err(|e| crate::DodotError::Fs {
127 path: path.to_path_buf(),
128 source: e,
129 })?;
130 if n == 0 {
131 break;
132 }
133 hasher.update(&buf[..n]);
134 }
135 let hash = hasher.finalize();
136 Ok(hex::encode(&hash[..8]))
138}
139
140mod hex {
142 pub fn encode(bytes: &[u8]) -> String {
143 bytes.iter().map(|b| format!("{b:02x}")).collect()
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::testing::TempEnvironment;
151
152 #[test]
153 fn checksum_is_deterministic() {
154 let env = TempEnvironment::builder()
155 .pack("test")
156 .file("install.sh", "#!/bin/sh\necho hello")
157 .done()
158 .build();
159
160 let path = env.dotfiles_root.join("test/install.sh");
161 let c1 = file_checksum(env.fs.as_ref(), &path).unwrap();
162 let c2 = file_checksum(env.fs.as_ref(), &path).unwrap();
163 assert_eq!(c1, c2);
164 assert_eq!(c1.len(), 16); }
166
167 #[test]
168 fn checksum_changes_with_content() {
169 let env = TempEnvironment::builder()
170 .pack("test")
171 .file("a.sh", "version 1")
172 .file("b.sh", "version 2")
173 .done()
174 .build();
175
176 let ca = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/a.sh")).unwrap();
177 let cb = file_checksum(env.fs.as_ref(), &env.dotfiles_root.join("test/b.sh")).unwrap();
178 assert_ne!(ca, cb);
179 }
180
181 #[test]
182 fn to_intents_produces_run_with_sentinel() {
183 let env = TempEnvironment::builder()
184 .pack("vim")
185 .file("install.sh", "#!/bin/sh\nsetup")
186 .done()
187 .build();
188
189 let handler = InstallHandler::new(env.fs.as_ref());
190 let matches = vec![crate::rules::RuleMatch {
191 relative_path: "install.sh".into(),
192 absolute_path: env.dotfiles_root.join("vim/install.sh"),
193 pack: "vim".into(),
194 handler: "install".into(),
195 is_dir: false,
196 options: std::collections::HashMap::new(),
197 preprocessor_source: None,
198 }];
199
200 let pather = crate::paths::XdgPather::builder()
201 .home(&env.home)
202 .dotfiles_root(&env.dotfiles_root)
203 .build()
204 .unwrap();
205
206 let intents = handler
207 .to_intents(
208 &matches,
209 &HandlerConfig::default(),
210 &pather,
211 env.fs.as_ref(),
212 )
213 .unwrap();
214
215 assert_eq!(intents.len(), 1);
216 match &intents[0] {
217 HandlerIntent::Run {
218 executable,
219 arguments,
220 sentinel,
221 ..
222 } => {
223 assert_eq!(executable, "bash");
224 assert_eq!(arguments[0], "--");
225 assert!(arguments[1].contains("install.sh"));
226 assert!(sentinel.starts_with("install.sh-"));
227 assert_eq!(sentinel.len(), "install.sh-".len() + 16);
228 }
229 other => panic!("expected Run, got {other:?}"),
230 }
231 }
232
233 #[test]
234 fn interpreter_for_selects_by_extension() {
235 assert_eq!(interpreter_for(Path::new("install.sh")), "bash");
236 assert_eq!(interpreter_for(Path::new("install.bash")), "bash");
237 assert_eq!(interpreter_for(Path::new("install.zsh")), "zsh");
238 assert_eq!(interpreter_for(Path::new("install")), "bash");
240 assert_eq!(interpreter_for(Path::new("install.ksh")), "bash");
241 assert_eq!(interpreter_for(Path::new("/a/b/install.zsh")), "zsh");
243 }
244
245 #[test]
246 fn to_intents_picks_interpreter_per_script() {
247 let env = TempEnvironment::builder()
248 .pack("vim")
249 .file("install.sh", "echo sh")
250 .file("install.bash", "echo bash")
251 .file("install.zsh", "echo zsh")
252 .done()
253 .build();
254
255 let handler = InstallHandler::new(env.fs.as_ref());
256 let make_match = |name: &str| crate::rules::RuleMatch {
257 relative_path: name.into(),
258 absolute_path: env.dotfiles_root.join(format!("vim/{name}")),
259 pack: "vim".into(),
260 handler: "install".into(),
261 is_dir: false,
262 options: std::collections::HashMap::new(),
263 preprocessor_source: None,
264 };
265 let matches = vec![
266 make_match("install.sh"),
267 make_match("install.bash"),
268 make_match("install.zsh"),
269 ];
270
271 let pather = crate::paths::XdgPather::builder()
272 .home(&env.home)
273 .dotfiles_root(&env.dotfiles_root)
274 .build()
275 .unwrap();
276
277 let intents = handler
278 .to_intents(
279 &matches,
280 &HandlerConfig::default(),
281 &pather,
282 env.fs.as_ref(),
283 )
284 .unwrap();
285
286 let chosen: Vec<(String, String)> = intents
287 .iter()
288 .map(|i| match i {
289 HandlerIntent::Run {
290 executable,
291 arguments,
292 ..
293 } => (
294 executable.clone(),
295 arguments
296 .last()
297 .cloned()
298 .and_then(|p| {
299 std::path::Path::new(&p)
300 .file_name()
301 .map(|n| n.to_string_lossy().into_owned())
302 })
303 .unwrap_or_default(),
304 ),
305 other => panic!("expected Run, got {other:?}"),
306 })
307 .collect();
308
309 assert!(chosen.contains(&("bash".into(), "install.sh".into())));
310 assert!(chosen.contains(&("bash".into(), "install.bash".into())));
311 assert!(chosen.contains(&("zsh".into(), "install.zsh".into())));
312 }
313}