1use std::path::{Path, PathBuf};
32
33use serde::{Deserialize, Serialize};
34
35use crate::fs::Fs;
36use crate::paths::Pather;
37use crate::Result;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum DeploymentKind {
43 Symlink,
47 File,
50 Directory,
53}
54
55impl DeploymentKind {
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::Symlink => "symlink",
59 Self::File => "file",
60 Self::Directory => "directory",
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct DeploymentMapEntry {
73 pub pack: String,
74 pub handler: String,
75 pub kind: DeploymentKind,
76 #[serde(default)]
77 pub source: PathBuf,
78 pub datastore: PathBuf,
79}
80
81pub fn collect_deployment_map(fs: &dyn Fs, paths: &dyn Pather) -> Result<Vec<DeploymentMapEntry>> {
93 let packs_dir = paths.data_dir().join("packs");
94 if !fs.is_dir(&packs_dir) {
95 return Ok(Vec::new());
96 }
97
98 let mut entries = Vec::new();
99
100 let mut pack_entries = fs.read_dir(&packs_dir)?;
101 pack_entries.sort_by(|a, b| a.name.cmp(&b.name));
102
103 for pack_dir in pack_entries {
104 if !pack_dir.is_dir {
105 continue;
106 }
107 let pack_name = pack_dir.name.clone();
108
109 let mut handler_dirs = fs.read_dir(&pack_dir.path)?;
110 handler_dirs.sort_by(|a, b| a.name.cmp(&b.name));
111
112 for handler_dir in handler_dirs {
113 if !handler_dir.is_dir {
114 continue;
115 }
116 let handler_name = handler_dir.name.clone();
117
118 let mut items = fs.read_dir(&handler_dir.path)?;
119 items.sort_by(|a, b| a.name.cmp(&b.name));
120
121 for item in items {
122 let kind = classify_entry(fs, &item);
123 let source = if kind == DeploymentKind::Symlink {
124 fs.readlink(&item.path).unwrap_or_default()
127 } else {
128 PathBuf::new()
129 };
130
131 entries.push(DeploymentMapEntry {
132 pack: pack_name.clone(),
133 handler: handler_name.clone(),
134 kind,
135 source,
136 datastore: item.path.clone(),
137 });
138 }
139 }
140 }
141
142 Ok(entries)
143}
144
145fn classify_entry(fs: &dyn Fs, entry: &crate::fs::DirEntry) -> DeploymentKind {
146 if entry.is_symlink {
147 DeploymentKind::Symlink
148 } else if entry.is_dir {
149 DeploymentKind::Directory
150 } else if entry.is_file {
151 DeploymentKind::File
152 } else {
153 match fs.lstat(&entry.path) {
156 Ok(m) if m.is_symlink => DeploymentKind::Symlink,
157 Ok(m) if m.is_dir => DeploymentKind::Directory,
158 _ => DeploymentKind::File,
159 }
160 }
161}
162
163pub fn format_deployment_map(entries: &[DeploymentMapEntry]) -> String {
169 let mut out = String::new();
170 out.push_str("# dodot deployment map v1\n");
171 out.push_str("# columns: pack\thandler\tkind\tsource\tdatastore\n");
172 for e in entries {
173 out.push_str(&format_row(e));
174 out.push('\n');
175 }
176 out
177}
178
179fn format_row(e: &DeploymentMapEntry) -> String {
180 format!(
181 "{}\t{}\t{}\t{}\t{}",
182 e.pack,
183 e.handler,
184 e.kind.as_str(),
185 e.source.display(),
186 e.datastore.display(),
187 )
188}
189
190pub fn write_deployment_map(fs: &dyn Fs, paths: &dyn Pather) -> Result<PathBuf> {
193 let entries = collect_deployment_map(fs, paths)?;
194 let content = format_deployment_map(&entries);
195 let map_path = paths.deployment_map_path();
196 fs.mkdir_all(paths.data_dir())?;
197 fs.write_file(&map_path, content.as_bytes())?;
198 Ok(map_path)
199}
200
201pub fn read_deployment_map(fs: &dyn Fs, path: &Path) -> Result<Vec<DeploymentMapEntry>> {
207 if !fs.exists(path) {
208 return Ok(Vec::new());
209 }
210 let content = fs.read_to_string(path)?;
211 Ok(parse_deployment_map(&content))
212}
213
214fn parse_deployment_map(content: &str) -> Vec<DeploymentMapEntry> {
215 content.lines().filter_map(parse_row).collect()
216}
217
218fn parse_row(line: &str) -> Option<DeploymentMapEntry> {
219 let trimmed = line.trim_end_matches('\r');
220 if trimmed.is_empty() || trimmed.starts_with('#') {
221 return None;
222 }
223 let mut parts = trimmed.splitn(5, '\t');
224 let pack = parts.next()?;
225 let handler = parts.next()?;
226 let kind_str = parts.next()?;
227 let source = parts.next()?;
228 let datastore = parts.next()?;
229 let kind = match kind_str {
230 "symlink" => DeploymentKind::Symlink,
231 "file" => DeploymentKind::File,
232 "directory" => DeploymentKind::Directory,
233 _ => return None,
234 };
235 Some(DeploymentMapEntry {
236 pack: pack.to_string(),
237 handler: handler.to_string(),
238 kind,
239 source: PathBuf::from(source),
240 datastore: PathBuf::from(datastore),
241 })
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
248 use crate::testing::TempEnvironment;
249 use std::sync::Arc;
250
251 struct NoopRunner;
252 impl CommandRunner for NoopRunner {
253 fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
254 Ok(CommandOutput {
255 exit_code: 0,
256 stdout: String::new(),
257 stderr: String::new(),
258 })
259 }
260 }
261
262 fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
263 FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
264 }
265
266 #[test]
267 fn empty_datastore_yields_empty_map() {
268 let env = TempEnvironment::builder().build();
269 let entries = collect_deployment_map(env.fs.as_ref(), env.paths.as_ref()).unwrap();
270 assert!(entries.is_empty());
271 }
272
273 #[test]
274 fn symlink_entries_capture_source_and_datastore() {
275 let env = TempEnvironment::builder()
276 .pack("vim")
277 .file("aliases.sh", "alias vi=vim")
278 .done()
279 .build();
280
281 let ds = make_datastore(&env);
282 let source = env.dotfiles_root.join("vim/aliases.sh");
283 ds.create_data_link("vim", "shell", &source).unwrap();
284
285 let entries = collect_deployment_map(env.fs.as_ref(), env.paths.as_ref()).unwrap();
286 assert_eq!(entries.len(), 1);
287 assert_eq!(entries[0].pack, "vim");
288 assert_eq!(entries[0].handler, "shell");
289 assert_eq!(entries[0].kind, DeploymentKind::Symlink);
290 assert_eq!(entries[0].source, source);
291 assert_eq!(
292 entries[0].datastore,
293 env.paths
294 .handler_data_dir("vim", "shell")
295 .join("aliases.sh")
296 );
297 }
298
299 #[test]
300 fn entries_sort_by_pack_then_handler_then_name() {
301 let env = TempEnvironment::builder()
302 .pack("vim")
303 .file("aliases.sh", "")
304 .file("bin/tool", "#!/bin/sh")
305 .done()
306 .pack("git")
307 .file("gitconfig", "")
308 .done()
309 .build();
310
311 let ds = make_datastore(&env);
312 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
313 .unwrap();
314 ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
315 .unwrap();
316 ds.create_data_link("git", "symlink", &env.dotfiles_root.join("git/gitconfig"))
317 .unwrap();
318
319 let entries = collect_deployment_map(env.fs.as_ref(), env.paths.as_ref()).unwrap();
320
321 let keys: Vec<(String, String)> = entries
322 .iter()
323 .map(|e| (e.pack.clone(), e.handler.clone()))
324 .collect();
325 assert_eq!(
328 keys,
329 vec![
330 ("git".into(), "symlink".into()),
331 ("vim".into(), "path".into()),
332 ("vim".into(), "shell".into()),
333 ]
334 );
335 }
336
337 #[test]
338 fn sentinel_file_classified_as_file_with_no_source() {
339 let env = TempEnvironment::builder().build();
340
341 let handler_dir = env.paths.handler_data_dir("nvim", "install");
344 env.fs.mkdir_all(&handler_dir).unwrap();
345 env.fs
346 .write_file(
347 &handler_dir.join("install.sh-abc123"),
348 b"completed|2026-01-01",
349 )
350 .unwrap();
351
352 let entries = collect_deployment_map(env.fs.as_ref(), env.paths.as_ref()).unwrap();
353 assert_eq!(entries.len(), 1);
354 assert_eq!(entries[0].kind, DeploymentKind::File);
355 assert!(
356 entries[0].source.as_os_str().is_empty(),
357 "sentinels have no source file; got {:?}",
358 entries[0].source
359 );
360 }
361
362 #[test]
363 fn broken_symlink_still_recorded_with_empty_source() {
364 let env = TempEnvironment::builder().build();
365
366 let handler_dir = env.paths.handler_data_dir("nvim", "shell");
367 env.fs.mkdir_all(&handler_dir).unwrap();
368 let broken_target = env.dotfiles_root.join("nvim/gone.sh");
373 env.fs
374 .symlink(&broken_target, &handler_dir.join("gone.sh"))
375 .unwrap();
376
377 let entries = collect_deployment_map(env.fs.as_ref(), env.paths.as_ref()).unwrap();
378 assert_eq!(entries.len(), 1);
379 assert_eq!(entries[0].kind, DeploymentKind::Symlink);
380 assert_eq!(entries[0].source, broken_target);
381 }
382
383 #[test]
384 fn write_and_reread_roundtrip() {
385 let env = TempEnvironment::builder()
386 .pack("vim")
387 .file("aliases.sh", "")
388 .done()
389 .build();
390
391 let ds = make_datastore(&env);
392 let source = env.dotfiles_root.join("vim/aliases.sh");
393 ds.create_data_link("vim", "shell", &source).unwrap();
394
395 let path = write_deployment_map(env.fs.as_ref(), env.paths.as_ref()).unwrap();
396 assert_eq!(path, env.paths.deployment_map_path());
397 env.assert_exists(&path);
398
399 let content = env.fs.read_to_string(&path).unwrap();
400 assert!(content.starts_with("# dodot deployment map v1"));
401 assert!(content.contains("vim\tshell\tsymlink\t"));
402
403 let parsed = read_deployment_map(env.fs.as_ref(), &path).unwrap();
404 assert_eq!(parsed.len(), 1);
405 assert_eq!(parsed[0].pack, "vim");
406 assert_eq!(parsed[0].source, source);
407 }
408
409 #[test]
410 fn read_returns_empty_when_file_missing() {
411 let env = TempEnvironment::builder().build();
412 let parsed =
413 read_deployment_map(env.fs.as_ref(), &env.paths.deployment_map_path()).unwrap();
414 assert!(parsed.is_empty());
415 }
416
417 #[test]
418 fn parser_ignores_comments_and_blank_lines() {
419 let content = "\
420# a comment
421\n\
422vim\tshell\tsymlink\t/src/a\t/ds/a
423# another
424
425git\tsymlink\tsymlink\t/src/b\t/ds/b
426";
427 let parsed = parse_deployment_map(content);
428 assert_eq!(parsed.len(), 2);
429 assert_eq!(parsed[0].pack, "vim");
430 assert_eq!(parsed[1].pack, "git");
431 }
432
433 #[test]
434 fn parser_skips_malformed_rows() {
435 let content = "\
438only-two-cols\tvalue
439vim\tshell\tweird-kind\t/a\t/b
440vim\tshell\tsymlink\t/a\t/b
441";
442 let parsed = parse_deployment_map(content);
443 assert_eq!(parsed.len(), 1);
444 assert_eq!(parsed[0].kind, DeploymentKind::Symlink);
445 }
446
447 #[test]
448 fn format_has_header_and_one_row_per_entry() {
449 let entries = vec![
450 DeploymentMapEntry {
451 pack: "vim".into(),
452 handler: "shell".into(),
453 kind: DeploymentKind::Symlink,
454 source: PathBuf::from("/src/a"),
455 datastore: PathBuf::from("/ds/a"),
456 },
457 DeploymentMapEntry {
458 pack: "vim".into(),
459 handler: "install".into(),
460 kind: DeploymentKind::File,
461 source: PathBuf::new(),
462 datastore: PathBuf::from("/ds/sentinel"),
463 },
464 ];
465 let s = format_deployment_map(&entries);
466 let lines: Vec<&str> = s.lines().collect();
467 assert_eq!(lines.len(), 4); assert!(lines[0].starts_with('#'));
469 assert!(lines[1].starts_with('#'));
470 assert_eq!(lines[2], "vim\tshell\tsymlink\t/src/a\t/ds/a");
471 assert_eq!(lines[3], "vim\tinstall\tfile\t\t/ds/sentinel");
472 }
473
474 #[test]
475 fn empty_input_produces_header_only() {
476 let s = format_deployment_map(&[]);
477 let lines: Vec<&str> = s.lines().collect();
478 assert_eq!(lines.len(), 2);
479 assert!(lines[0].starts_with("# dodot"));
480 assert!(lines[1].starts_with("# columns"));
481 }
482
483 #[test]
484 fn paths_with_tabs_would_break_tsv_but_are_not_produced_by_dodot() {
485 let entry = DeploymentMapEntry {
495 pack: "p".into(),
496 handler: "h".into(),
497 kind: DeploymentKind::Symlink,
498 source: PathBuf::from("/a"),
499 datastore: PathBuf::from("/b"),
500 };
501 let row = format_row(&entry);
502 assert_eq!(row.matches('\t').count(), 4);
503 }
504}