1use std::fmt::Write;
23use std::path::PathBuf;
24
25use crate::fs::Fs;
26use crate::paths::Pather;
27use crate::Result;
28
29pub fn generate_init_script(fs: &dyn Fs, paths: &dyn Pather) -> Result<String> {
37 let mut script = String::new();
38
39 writeln!(script, "#!/bin/sh").unwrap();
40 writeln!(script, "# Generated by dodot — do not edit manually.").unwrap();
41 writeln!(script, "# Regenerated on every `dodot up` / `dodot down`.").unwrap();
42 writeln!(script).unwrap();
43
44 let packs_dir = paths.data_dir().join("packs");
46 if !fs.exists(&packs_dir) {
47 return Ok(script);
48 }
49
50 let pack_entries = fs.read_dir(&packs_dir)?;
51
52 let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); for pack_entry in &pack_entries {
58 if !pack_entry.is_dir {
59 continue;
60 }
61 let pack_name = &pack_entry.name;
62
63 let shell_dir = paths.handler_data_dir(pack_name, "shell");
65 if fs.is_dir(&shell_dir) {
66 if let Ok(entries) = fs.read_dir(&shell_dir) {
67 for entry in entries {
68 if !entry.is_symlink {
69 continue;
70 }
71 let target = fs.readlink(&entry.path)?;
73 shell_sources.push((pack_name.clone(), target));
74 }
75 }
76 }
77
78 let path_dir = paths.handler_data_dir(pack_name, "path");
80 if fs.is_dir(&path_dir) {
81 if let Ok(entries) = fs.read_dir(&path_dir) {
82 for entry in entries {
83 if !entry.is_symlink {
84 continue;
85 }
86 let target = fs.readlink(&entry.path)?;
87 path_additions.push((pack_name.clone(), target));
88 }
89 }
90 }
91 }
92
93 if !path_additions.is_empty() {
95 writeln!(script, "# PATH additions").unwrap();
96 for (pack, target) in &path_additions {
97 writeln!(script, "# [{pack}]").unwrap();
98 writeln!(script, "export PATH=\"{}:$PATH\"", target.display()).unwrap();
99 }
100 writeln!(script).unwrap();
101 }
102
103 if !shell_sources.is_empty() {
105 writeln!(script, "# Shell scripts").unwrap();
106 for (pack, target) in &shell_sources {
107 writeln!(script, "# [{pack}]").unwrap();
108 writeln!(
109 script,
110 "[ -f \"{}\" ] && . \"{}\"",
111 target.display(),
112 target.display()
113 )
114 .unwrap();
115 }
116 writeln!(script).unwrap();
117 }
118
119 Ok(script)
120}
121
122pub fn write_init_script(fs: &dyn Fs, paths: &dyn Pather) -> Result<PathBuf> {
126 let script_content = generate_init_script(fs, paths)?;
127 let script_path = paths.init_script_path();
128
129 fs.mkdir_all(paths.shell_dir())?;
130 fs.write_file(&script_path, script_content.as_bytes())?;
131 fs.set_permissions(&script_path, 0o755)?;
132
133 Ok(script_path)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
140 use crate::testing::TempEnvironment;
141 use std::sync::Arc;
142
143 struct NoopRunner;
144 impl CommandRunner for NoopRunner {
145 fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
146 Ok(CommandOutput {
147 exit_code: 0,
148 stdout: String::new(),
149 stderr: String::new(),
150 })
151 }
152 }
153
154 fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
155 FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
156 }
157
158 #[test]
159 fn empty_datastore_produces_minimal_script() {
160 let env = TempEnvironment::builder().build();
161 let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
162
163 assert!(script.starts_with("#!/bin/sh"));
164 assert!(script.contains("Generated by dodot"));
165 assert!(!script.contains("export PATH"));
167 assert!(!script.contains(". \""));
168 }
169
170 #[test]
171 fn shell_handler_state_produces_source_lines() {
172 let env = TempEnvironment::builder()
173 .pack("vim")
174 .file("aliases.sh", "alias vi=vim")
175 .done()
176 .build();
177
178 let ds = make_datastore(&env);
179 let source = env.dotfiles_root.join("vim/aliases.sh");
180 ds.create_data_link("vim", "shell", &source).unwrap();
181
182 let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
183
184 assert!(script.contains("# Shell scripts"), "script:\n{script}");
185 assert!(script.contains("# [vim]"), "script:\n{script}");
186 assert!(
187 script.contains(&format!(
188 "[ -f \"{}\" ] && . \"{}\"",
189 source.display(),
190 source.display()
191 )),
192 "script:\n{script}"
193 );
194 }
195
196 #[test]
197 fn path_handler_state_produces_path_lines() {
198 let env = TempEnvironment::builder()
199 .pack("vim")
200 .file("bin/myscript", "#!/bin/sh")
201 .done()
202 .build();
203
204 let ds = make_datastore(&env);
205 let source = env.dotfiles_root.join("vim/bin");
206 ds.create_data_link("vim", "path", &source).unwrap();
207
208 let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
209
210 assert!(script.contains("# PATH additions"), "script:\n{script}");
211 assert!(script.contains("# [vim]"), "script:\n{script}");
212 assert!(
213 script.contains(&format!("export PATH=\"{}:$PATH\"", source.display())),
214 "script:\n{script}"
215 );
216 }
217
218 #[test]
219 fn multiple_packs_combined() {
220 let env = TempEnvironment::builder()
221 .pack("git")
222 .file("aliases.sh", "alias gs='git status'")
223 .done()
224 .pack("vim")
225 .file("aliases.sh", "alias vi=vim")
226 .file("bin/vimrun", "#!/bin/sh")
227 .done()
228 .build();
229
230 let ds = make_datastore(&env);
231
232 ds.create_data_link("git", "shell", &env.dotfiles_root.join("git/aliases.sh"))
234 .unwrap();
235 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
236 .unwrap();
237
238 ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
240 .unwrap();
241
242 let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
243
244 assert!(script.contains("# [git]"), "script:\n{script}");
246 assert!(script.contains("# [vim]"), "script:\n{script}");
247 assert!(script.contains("export PATH="), "script:\n{script}");
249 let source_count = script.matches(". \"").count();
251 assert_eq!(
252 source_count, 2,
253 "expected 2 source lines, script:\n{script}"
254 );
255 }
256
257 #[test]
258 fn write_init_script_creates_executable_file() {
259 let env = TempEnvironment::builder()
260 .pack("vim")
261 .file("aliases.sh", "alias vi=vim")
262 .done()
263 .build();
264
265 let ds = make_datastore(&env);
266 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
267 .unwrap();
268
269 let script_path = write_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
270
271 assert_eq!(script_path, env.paths.init_script_path());
272 env.assert_exists(&script_path);
273
274 let content = env.fs.read_to_string(&script_path).unwrap();
275 assert!(content.starts_with("#!/bin/sh"));
276 assert!(content.contains("aliases.sh"));
277
278 let meta = std::fs::metadata(&script_path).unwrap();
280 use std::os::unix::fs::PermissionsExt;
281 assert_eq!(meta.permissions().mode() & 0o111, 0o111);
282 }
283
284 #[test]
285 fn script_regenerated_reflects_current_state() {
286 let env = TempEnvironment::builder()
287 .pack("vim")
288 .file("aliases.sh", "alias vi=vim")
289 .done()
290 .build();
291
292 let ds = make_datastore(&env);
293
294 let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
296 assert!(!script1.contains("aliases.sh"));
297
298 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
300 .unwrap();
301
302 let script2 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
303 assert!(script2.contains("aliases.sh"));
304
305 ds.remove_state("vim", "shell").unwrap();
307
308 let script3 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
309 assert!(!script3.contains("aliases.sh"));
310 }
311
312 #[test]
313 fn ignores_non_symlink_files_in_handler_dirs() {
314 let env = TempEnvironment::builder().build();
315
316 let shell_dir = env.paths.handler_data_dir("vim", "shell");
318 env.fs.mkdir_all(&shell_dir).unwrap();
319 env.fs
320 .write_file(&shell_dir.join("not-a-symlink"), b"noise")
321 .unwrap();
322
323 let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
324 assert!(!script.contains("not-a-symlink"));
325 }
326
327 #[test]
328 fn path_additions_come_before_shell_sources() {
329 let env = TempEnvironment::builder()
330 .pack("vim")
331 .file("aliases.sh", "alias vi=vim")
332 .file("bin/myscript", "#!/bin/sh")
333 .done()
334 .build();
335
336 let ds = make_datastore(&env);
337 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
338 .unwrap();
339 ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
340 .unwrap();
341
342 let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
343
344 let path_pos = script.find("# PATH additions").unwrap();
345 let shell_pos = script.find("# Shell scripts").unwrap();
346 assert!(
347 path_pos < shell_pos,
348 "PATH additions should come before shell sources"
349 );
350 }
351}