Skip to main content

dodot_lib/probe/
deployment_map.rs

1//! The deployment map.
2//!
3//! The deployment map is a plain-text TSV under `<data_dir>/deployment-map.tsv`
4//! with a two-line `#`-comment preamble followed by one TSV row per
5//! datastore entry. An example file:
6//!
7//! ```text
8//! # dodot deployment map v1
9//! # columns: pack\thandler\tkind\tsource\tdatastore
10//! vim\tshell\tsymlink\t/home/alice/dotfiles/vim/aliases.sh\t/home/alice/.local/share/dodot/packs/vim/shell/aliases.sh
11//! git\tsymlink\tsymlink\t/home/alice/dotfiles/git/gitconfig\t/home/alice/.local/share/dodot/packs/git/symlink/gitconfig
12//! ```
13//!
14//! The file is overwritten on every `dodot up` / `dodot down` so it
15//! always matches the current datastore state. Its primary consumers
16//! are:
17//!
18//! - `dodot refresh` (see `docs/proposals/magic.lex`), which needs the
19//!   source→deployed mapping to decide which source templates to
20//!   mtime-touch when a deployed file diverges.
21//! - `dodot probe deployment-map`, the human-facing reader.
22//!
23//! # Sources of truth
24//!
25//! The map is derived *from the datastore alone* — we never re-run the
26//! handlers to regenerate it. This keeps the writer trivial and keeps
27//! the map honest: if the datastore has drifted from what the handlers
28//! would produce today, the map reflects the datastore (which is what
29//! the init script reads), not a hypothetical re-derivation.
30
31use std::path::{Path, PathBuf};
32
33use serde::{Deserialize, Serialize};
34
35use crate::fs::Fs;
36use crate::paths::Pather;
37use crate::Result;
38
39/// How a single datastore entry is materialised on disk.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum DeploymentKind {
43    /// The entry is a symlink in the datastore pointing at a source file
44    /// inside the dotfiles repo. This covers `symlink`, `shell`, and
45    /// `path` handlers.
46    Symlink,
47    /// The entry is a regular file written by dodot (a sentinel for
48    /// `install` / `homebrew`, or a preprocessor's rendered output).
49    File,
50    /// The entry is a directory written by a preprocessor
51    /// (e.g. expanded archive contents).
52    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/// One row in the deployment map.
66///
67/// `source` is the absolute path in the dotfiles repo (for symlink
68/// entries — empty for file / directory entries, which are not backed by
69/// a source file). `datastore` is the absolute path inside `<data_dir>`
70/// where the entry lives.
71#[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
81/// Walk the datastore and collect one [`DeploymentMapEntry`] per
82/// visible entry under `<data_dir>/packs/<pack>/<handler>/`.
83///
84/// The walk is non-recursive within a handler directory: dodot's
85/// data layout is `packs/<pack>/<handler>/<entry>`, and any deeper
86/// structure (preprocessor-expanded subtrees under `rendered` handler
87/// directories, for instance) is represented as a single [`Directory`]
88/// row. Consumers that care about subtree contents can walk from the
89/// `datastore` path themselves.
90///
91/// [`Directory`]: DeploymentKind::Directory
92pub 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                    // readlink may fail on a broken symlink — we still
125                    // want to record the entry so the user can see it.
126                    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        // Fallback: query the fs directly. Shouldn't be reachable in
154        // practice since read_dir populates all three flags.
155        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
163/// Format the deployment map as TSV.
164///
165/// The output is deterministic (entries are emitted in the order
166/// returned by [`collect_deployment_map`], which sorts by pack, then
167/// handler, then entry name).
168pub 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
190/// Collect, format, and write the deployment map to
191/// `<data_dir>/deployment-map.tsv`. Returns the written path.
192pub 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
201/// Read and parse a deployment-map TSV file.
202///
203/// Blank lines and `#`-prefixed comments are ignored. Rows with the
204/// wrong column count are skipped silently — the map is best-effort
205/// and a truncated file should not crash the reader.
206pub 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        // git/symlink comes before vim/{path,shell} (sorted by pack),
326        // and vim/path comes before vim/shell (sorted by handler).
327        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        // Simulate an install handler sentinel: a plain file in the
342        // handler dir, not a symlink.
343        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        // Point at a path that doesn't exist. readlink still works on
369        // broken symlinks, so in this case source is captured. To test
370        // the *unreadable* branch we'd need to simulate a failure;
371        // broken-but-readable is the realistic case.
372        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        // Too few columns and an unknown kind should both be dropped,
436        // not crash.
437        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); // 2 comments + 2 data rows
468        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        // Documenting an invariant rather than testing a path: dodot
486        // never creates paths containing literal tab characters, so we
487        // don't escape them in the TSV. A pack dir named "foo\tbar"
488        // would produce a malformed row — but dodot's pack discovery
489        // rejects such names upstream (ignore list + XDG conventions).
490        //
491        // This test just locks in the current format so a future change
492        // that wants tab-containing paths has to explicitly revisit
493        // escaping.
494        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}