Skip to main content

dotm/
deployer.rs

1use crate::scanner::{EntryKind, FileAction};
2use anyhow::{Context, Result};
3use std::os::unix::fs::PermissionsExt;
4use std::path::Path;
5
6#[derive(Debug)]
7pub enum DeployResult {
8    Created,
9    Updated,
10    Unchanged,
11    Conflict(String),
12    DryRun,
13}
14
15/// Deploy a file action via staging: copy/render the real file into `staging_dir`,
16/// then create a symlink from `target_dir` pointing to the staged file.
17///
18/// For all entry kinds (Base, Override, Template), the staged file is a real file.
19/// The target path is always a symlink to the staged file's canonical path.
20pub fn deploy_staged(
21    action: &FileAction,
22    staging_dir: &Path,
23    target_dir: &Path,
24    dry_run: bool,
25    force: bool,
26    rendered_content: Option<&str>,
27) -> Result<DeployResult> {
28    let staged_path = staging_dir.join(&action.target_rel_path);
29    let target_path = target_dir.join(&action.target_rel_path);
30
31    if dry_run {
32        return Ok(DeployResult::DryRun);
33    }
34
35    // Check if the target already exists (managed symlink or file) before removing
36    let was_existing = target_path.is_symlink() || target_path.exists();
37
38    // Handle conflicts on the target path
39    if target_path.exists() || target_path.is_symlink() {
40        if target_path.is_symlink() {
41            std::fs::remove_file(&target_path)
42                .with_context(|| format!("failed to remove existing symlink: {}", target_path.display()))?;
43        } else if force {
44            std::fs::remove_file(&target_path)
45                .with_context(|| format!("failed to remove existing file: {}", target_path.display()))?;
46        } else {
47            return Ok(DeployResult::Conflict(format!(
48                "file already exists and is not managed by dotm: {}",
49                target_path.display()
50            )));
51        }
52    }
53
54    // Create parent directories for both staged and target paths
55    if let Some(parent) = staged_path.parent() {
56        std::fs::create_dir_all(parent)
57            .with_context(|| format!("failed to create staging directory: {}", parent.display()))?;
58    }
59    if let Some(parent) = target_path.parent() {
60        std::fs::create_dir_all(parent)
61            .with_context(|| format!("failed to create target directory: {}", parent.display()))?;
62    }
63
64    // Stage the file (always a real file in staging_dir)
65    match action.kind {
66        EntryKind::Template => {
67            let content = rendered_content.unwrap_or("");
68            std::fs::write(&staged_path, content)
69                .with_context(|| format!("failed to write template to staging: {}", staged_path.display()))?;
70        }
71        EntryKind::Base | EntryKind::Override => {
72            std::fs::copy(&action.source, &staged_path)
73                .with_context(|| format!("failed to copy {} to staging: {}", action.source.display(), staged_path.display()))?;
74            copy_permissions(&action.source, &staged_path)?;
75        }
76    }
77
78    // Symlink from target to the staged file's canonical path
79    let abs_staged = std::fs::canonicalize(&staged_path)
80        .with_context(|| format!("failed to canonicalize staged path: {}", staged_path.display()))?;
81    std::os::unix::fs::symlink(&abs_staged, &target_path)
82        .with_context(|| format!("failed to create symlink: {} -> {}", target_path.display(), abs_staged.display()))?;
83
84    if was_existing {
85        Ok(DeployResult::Updated)
86    } else {
87        Ok(DeployResult::Created)
88    }
89}
90
91/// Deploy a file action by copying directly to the target directory (no staging).
92///
93/// Used for packages with `strategy = "copy"`. Templates get rendered content
94/// written; everything else is copied. Source permissions are preserved.
95pub fn deploy_copy(
96    action: &FileAction,
97    target_dir: &Path,
98    dry_run: bool,
99    force: bool,
100    rendered_content: Option<&str>,
101) -> Result<DeployResult> {
102    let target_path = target_dir.join(&action.target_rel_path);
103
104    if dry_run {
105        return Ok(DeployResult::DryRun);
106    }
107
108    // Check if the target already exists before removing
109    let was_existing = target_path.is_symlink() || target_path.exists();
110
111    // Handle conflicts on the target path
112    if target_path.exists() || target_path.is_symlink() {
113        if target_path.is_symlink() {
114            std::fs::remove_file(&target_path)
115                .with_context(|| format!("failed to remove existing symlink: {}", target_path.display()))?;
116        } else if force {
117            std::fs::remove_file(&target_path)
118                .with_context(|| format!("failed to remove existing file: {}", target_path.display()))?;
119        } else {
120            return Ok(DeployResult::Conflict(format!(
121                "file already exists and is not managed by dotm: {}",
122                target_path.display()
123            )));
124        }
125    }
126
127    // Create parent directories
128    if let Some(parent) = target_path.parent() {
129        std::fs::create_dir_all(parent)
130            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
131    }
132
133    match action.kind {
134        EntryKind::Template => {
135            let content = rendered_content.unwrap_or("");
136            std::fs::write(&target_path, content)
137                .with_context(|| format!("failed to write template output: {}", target_path.display()))?;
138        }
139        EntryKind::Base | EntryKind::Override => {
140            std::fs::copy(&action.source, &target_path)
141                .with_context(|| format!("failed to copy {} to {}", action.source.display(), target_path.display()))?;
142            copy_permissions(&action.source, &target_path)?;
143        }
144    }
145
146    if was_existing {
147        Ok(DeployResult::Updated)
148    } else {
149        Ok(DeployResult::Created)
150    }
151}
152
153/// Parse an octal mode string (e.g. "755") and apply it to the file at `path`.
154pub fn apply_permission_override(path: &Path, mode_str: &str) -> Result<()> {
155    let mode = u32::from_str_radix(mode_str, 8)
156        .with_context(|| format!("invalid octal permission string: '{mode_str}'"))?;
157    let permissions = std::fs::Permissions::from_mode(mode);
158    std::fs::set_permissions(path, permissions)
159        .with_context(|| format!("failed to set permissions {mode_str} on {}", path.display()))?;
160    Ok(())
161}
162
163/// Copy the Unix file permissions from `source` to `dest`.
164fn copy_permissions(source: &Path, dest: &Path) -> Result<()> {
165    let metadata = std::fs::metadata(source)
166        .with_context(|| format!("failed to read metadata from {}", source.display()))?;
167    std::fs::set_permissions(dest, metadata.permissions())
168        .with_context(|| format!("failed to set permissions on {}", dest.display()))?;
169    Ok(())
170}