dodot_lib/commands/
fill.rs1use 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
17struct 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
72pub fn fill(pack_name: &str, ctx: &ExecutionContext) -> Result<FillResult> {
78 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 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 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 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 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 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}