Skip to main content

dodot_lib/commands/
fill.rs

1//! `fill` command — add placeholder files to an existing pack.
2//!
3//! Creates template files for each configured handler pattern so the
4//! user has a starting point. Files that already exist are skipped.
5
6use serde::Serialize;
7
8use crate::packs::orchestration::{self, ExecutionContext};
9use crate::{DodotError, Result};
10
11#[derive(Debug, Clone, Serialize)]
12pub struct FillResult {
13    pub message: String,
14    pub details: Vec<String>,
15}
16
17/// Handler template content keyed by the filename to create.
18struct FillTemplate {
19    filename: &'static str,
20    content: &'static str,
21}
22
23const TEMPLATES: &[FillTemplate] = &[
24    FillTemplate {
25        filename: "install.sh",
26        content: r#"#!/usr/bin/env bash
27# Install script for PACK_NAME
28#
29# Runs ONCE during `dodot up`. Re-runs only if this file changes
30# (tracked by content checksum). Should be idempotent.
31
32set -euo pipefail
33
34echo "Installing PACK_NAME..."
35
36# Add your installation commands below
37# Examples:
38# mkdir -p "$HOME/.config/PACK_NAME"
39# curl -fsSL https://example.com/install.sh | bash
40"#,
41    },
42    FillTemplate {
43        filename: "aliases.sh",
44        content: r#"#!/usr/bin/env sh
45# Shell aliases for PACK_NAME
46#
47# Sourced into your shell on every session via dodot-init.sh.
48# Changes take effect in new shells or after `dodot up`.
49
50# Add your aliases below
51# Examples:
52# alias ll='ls -la'
53# alias g='git'
54"#,
55    },
56    FillTemplate {
57        filename: "Brewfile",
58        content: r#"# Homebrew dependencies for PACK_NAME
59#
60# Processed during `dodot up`. Re-runs only if this file changes.
61# Uses standard Brewfile syntax:
62# https://github.com/Homebrew/homebrew-bundle
63
64# Examples:
65# brew 'git'
66# brew 'tmux'
67# cask 'firefox'
68"#,
69    },
70];
71
72/// Add placeholder files to an existing pack.
73///
74/// Creates template files for install.sh, aliases.sh, and Brewfile.
75/// Skips files that already exist. Replaces `PACK_NAME` in templates
76/// with the actual pack name.
77pub fn fill(pack_name: &str, ctx: &ExecutionContext) -> Result<FillResult> {
78    // Resolve the user's input (display or raw on-disk name) to the
79    // actual directory.
80    let pack_dir = orchestration::resolve_pack_dir_name(pack_name, ctx)?;
81    let pack_path = ctx.paths.pack_path(&pack_dir);
82
83    if !ctx.fs.exists(&pack_path) {
84        return Err(DodotError::PackNotFound {
85            name: pack_name.into(),
86        });
87    }
88
89    // Templates substitute `PACK_NAME` with the pack's *display name* —
90    // a user-facing identifier in shell snippets and install scripts.
91    let display = crate::packs::display_name_for(&pack_dir);
92
93    let mut details = Vec::new();
94    let mut created = 0;
95
96    for template in TEMPLATES {
97        let file_path = pack_path.join(template.filename);
98
99        if ctx.fs.exists(&file_path) {
100            details.push(format!("  {} (exists, skipped)", template.filename));
101            continue;
102        }
103
104        let content = template.content.replace("PACK_NAME", display);
105        ctx.fs.write_file(&file_path, content.as_bytes())?;
106
107        // Make install.sh executable
108        if template.filename.ends_with(".sh") {
109            ctx.fs.set_permissions(&file_path, 0o755)?;
110        }
111
112        details.push(format!("  {} (created)", template.filename));
113        created += 1;
114    }
115
116    let message = if created == 0 {
117        format!("Pack '{display}' already has all template files.")
118    } else {
119        format!("Added {created} template file(s) to '{display}'.")
120    };
121
122    Ok(FillResult { message, details })
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::config::ConfigManager;
129    use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
130    use crate::fs::Fs;
131    use crate::paths::Pather;
132    use crate::testing::TempEnvironment;
133    use std::sync::Arc;
134
135    struct NoopRunner;
136    impl CommandRunner for NoopRunner {
137        fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
138            Ok(CommandOutput {
139                exit_code: 0,
140                stdout: String::new(),
141                stderr: String::new(),
142            })
143        }
144    }
145
146    fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
147        let runner: Arc<dyn crate::datastore::CommandRunner> = Arc::new(NoopRunner);
148        let datastore = Arc::new(FilesystemDataStore::new(
149            env.fs.clone(),
150            env.paths.clone(),
151            runner.clone(),
152        ));
153        let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
154        ExecutionContext {
155            fs: env.fs.clone() as Arc<dyn Fs>,
156            datastore,
157            paths: env.paths.clone() as Arc<dyn Pather>,
158            config_manager,
159            syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
160            command_runner: runner,
161            dry_run: false,
162            no_provision: false,
163            provision_rerun: false,
164            force: false,
165            view_mode: crate::commands::ViewMode::Full,
166            group_mode: crate::commands::GroupMode::Name,
167            verbose: false,
168        }
169    }
170
171    #[test]
172    fn fill_creates_template_files() {
173        let env = TempEnvironment::builder()
174            .pack("vim")
175            .file("vimrc", "x")
176            .done()
177            .build();
178        let ctx = make_ctx(&env);
179
180        let result = fill("vim", &ctx).unwrap();
181        assert!(result.message.contains("3 template"));
182
183        env.assert_exists(&env.dotfiles_root.join("vim/install.sh"));
184        env.assert_exists(&env.dotfiles_root.join("vim/aliases.sh"));
185        env.assert_exists(&env.dotfiles_root.join("vim/Brewfile"));
186
187        // install.sh should contain pack name
188        let content = env
189            .fs
190            .read_to_string(&env.dotfiles_root.join("vim/install.sh"))
191            .unwrap();
192        assert!(content.contains("vim"), "should contain pack name");
193        assert!(!content.contains("PACK_NAME"), "should replace placeholder");
194    }
195
196    #[test]
197    fn fill_skips_existing_files() {
198        let env = TempEnvironment::builder()
199            .pack("vim")
200            .file("install.sh", "#!/bin/sh\nmy custom script")
201            .done()
202            .build();
203        let ctx = make_ctx(&env);
204
205        let result = fill("vim", &ctx).unwrap();
206        // Only 2 created (aliases.sh + Brewfile), install.sh skipped
207        assert!(result.message.contains("2 template"));
208        assert!(result
209            .details
210            .iter()
211            .any(|d| d.contains("install.sh") && d.contains("skipped")));
212
213        // Original content preserved
214        let content = env
215            .fs
216            .read_to_string(&env.dotfiles_root.join("vim/install.sh"))
217            .unwrap();
218        assert_eq!(content, "#!/bin/sh\nmy custom script");
219    }
220
221    #[test]
222    fn fill_all_existing_reports_correctly() {
223        let env = TempEnvironment::builder()
224            .pack("vim")
225            .file("install.sh", "x")
226            .file("aliases.sh", "x")
227            .file("Brewfile", "x")
228            .done()
229            .build();
230        let ctx = make_ctx(&env);
231
232        let result = fill("vim", &ctx).unwrap();
233        assert!(result.message.contains("already has all"));
234    }
235
236    #[test]
237    fn fill_nonexistent_pack_errors() {
238        let env = TempEnvironment::builder().build();
239        let ctx = make_ctx(&env);
240
241        let err = fill("nonexistent", &ctx).unwrap_err();
242        assert!(matches!(err, DodotError::PackNotFound { .. }));
243    }
244}