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    // Handle conflicts on the target path
36    if target_path.exists() || target_path.is_symlink() {
37        if target_path.is_symlink() {
38            std::fs::remove_file(&target_path)
39                .with_context(|| format!("failed to remove existing symlink: {}", target_path.display()))?;
40        } else if force {
41            std::fs::remove_file(&target_path)
42                .with_context(|| format!("failed to remove existing file: {}", target_path.display()))?;
43        } else {
44            return Ok(DeployResult::Conflict(format!(
45                "file already exists and is not managed by dotm: {}",
46                target_path.display()
47            )));
48        }
49    }
50
51    // Create parent directories for both staged and target paths
52    if let Some(parent) = staged_path.parent() {
53        std::fs::create_dir_all(parent)
54            .with_context(|| format!("failed to create staging directory: {}", parent.display()))?;
55    }
56    if let Some(parent) = target_path.parent() {
57        std::fs::create_dir_all(parent)
58            .with_context(|| format!("failed to create target directory: {}", parent.display()))?;
59    }
60
61    // Stage the file (always a real file in staging_dir)
62    match action.kind {
63        EntryKind::Template => {
64            let content = rendered_content.unwrap_or("");
65            std::fs::write(&staged_path, content)
66                .with_context(|| format!("failed to write template to staging: {}", staged_path.display()))?;
67        }
68        EntryKind::Base | EntryKind::Override => {
69            std::fs::copy(&action.source, &staged_path)
70                .with_context(|| format!("failed to copy {} to staging: {}", action.source.display(), staged_path.display()))?;
71            copy_permissions(&action.source, &staged_path)?;
72        }
73    }
74
75    // Symlink from target to the staged file's canonical path
76    let abs_staged = std::fs::canonicalize(&staged_path)
77        .with_context(|| format!("failed to canonicalize staged path: {}", staged_path.display()))?;
78    std::os::unix::fs::symlink(&abs_staged, &target_path)
79        .with_context(|| format!("failed to create symlink: {} -> {}", target_path.display(), abs_staged.display()))?;
80
81    Ok(DeployResult::Created)
82}
83
84/// Deploy a file action by copying directly to the target directory (no staging).
85///
86/// Used for packages with `strategy = "copy"`. Templates get rendered content
87/// written; everything else is copied. Source permissions are preserved.
88pub fn deploy_copy(
89    action: &FileAction,
90    target_dir: &Path,
91    dry_run: bool,
92    force: bool,
93    rendered_content: Option<&str>,
94) -> Result<DeployResult> {
95    let target_path = target_dir.join(&action.target_rel_path);
96
97    if dry_run {
98        return Ok(DeployResult::DryRun);
99    }
100
101    // Handle conflicts on the target path
102    if target_path.exists() || target_path.is_symlink() {
103        if target_path.is_symlink() {
104            std::fs::remove_file(&target_path)
105                .with_context(|| format!("failed to remove existing symlink: {}", target_path.display()))?;
106        } else if force {
107            std::fs::remove_file(&target_path)
108                .with_context(|| format!("failed to remove existing file: {}", target_path.display()))?;
109        } else {
110            return Ok(DeployResult::Conflict(format!(
111                "file already exists and is not managed by dotm: {}",
112                target_path.display()
113            )));
114        }
115    }
116
117    // Create parent directories
118    if let Some(parent) = target_path.parent() {
119        std::fs::create_dir_all(parent)
120            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
121    }
122
123    match action.kind {
124        EntryKind::Template => {
125            let content = rendered_content.unwrap_or("");
126            std::fs::write(&target_path, content)
127                .with_context(|| format!("failed to write template output: {}", target_path.display()))?;
128        }
129        EntryKind::Base | EntryKind::Override => {
130            std::fs::copy(&action.source, &target_path)
131                .with_context(|| format!("failed to copy {} to {}", action.source.display(), target_path.display()))?;
132            copy_permissions(&action.source, &target_path)?;
133        }
134    }
135
136    Ok(DeployResult::Created)
137}
138
139/// Parse an octal mode string (e.g. "755") and apply it to the file at `path`.
140pub fn apply_permission_override(path: &Path, mode_str: &str) -> Result<()> {
141    let mode = u32::from_str_radix(mode_str, 8)
142        .with_context(|| format!("invalid octal permission string: '{mode_str}'"))?;
143    let permissions = std::fs::Permissions::from_mode(mode);
144    std::fs::set_permissions(path, permissions)
145        .with_context(|| format!("failed to set permissions {mode_str} on {}", path.display()))?;
146    Ok(())
147}
148
149/// Copy the Unix file permissions from `source` to `dest`.
150fn copy_permissions(source: &Path, dest: &Path) -> Result<()> {
151    let metadata = std::fs::metadata(source)
152        .with_context(|| format!("failed to read metadata from {}", source.display()))?;
153    std::fs::set_permissions(dest, metadata.permissions())
154        .with_context(|| format!("failed to set permissions on {}", dest.display()))?;
155    Ok(())
156}