Skip to main content

dotm/
deployer.rs

1use crate::scanner::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 by creating a symlink from target to the source file.
16///
17/// Used for user-mode Base and Override files. The symlink points to the
18/// canonicalized absolute path of the source file in packages/.
19pub fn deploy_symlink(
20    action: &FileAction,
21    target_dir: &Path,
22    dry_run: bool,
23    force: bool,
24) -> Result<DeployResult> {
25    let target_path = target_dir.join(&action.target_rel_path);
26
27    if dry_run {
28        return Ok(DeployResult::DryRun);
29    }
30
31    let was_existing = target_path.is_symlink() || target_path.exists();
32
33    // Directory at target is an error
34    if target_path.is_dir() && !target_path.is_symlink() {
35        return Ok(DeployResult::Conflict(format!(
36            "target is a directory (remove it manually): {}",
37            target_path.display()
38        )));
39    }
40
41    // Existing symlink (broken or pointing elsewhere): remove it
42    if target_path.is_symlink() {
43        std::fs::remove_file(&target_path).with_context(|| {
44            format!(
45                "failed to remove existing symlink: {}",
46                target_path.display()
47            )
48        })?;
49    } else if target_path.exists() {
50        // Regular file: conflict unless force
51        if force {
52            std::fs::remove_file(&target_path).with_context(|| {
53                format!("failed to remove existing file: {}", target_path.display())
54            })?;
55        } else {
56            return Ok(DeployResult::Conflict(format!(
57                "file already exists and is not managed by dotm: {}",
58                target_path.display()
59            )));
60        }
61    }
62
63    // Create parent directories
64    if let Some(parent) = target_path.parent() {
65        std::fs::create_dir_all(parent)
66            .with_context(|| format!("failed to create target directory: {}", parent.display()))?;
67    }
68
69    // Create symlink to canonicalized source path
70    let abs_source = std::fs::canonicalize(&action.source).with_context(|| {
71        format!(
72            "failed to canonicalize source path: {}",
73            action.source.display()
74        )
75    })?;
76    std::os::unix::fs::symlink(&abs_source, &target_path).with_context(|| {
77        format!(
78            "failed to create symlink: {} -> {}",
79            target_path.display(),
80            abs_source.display()
81        )
82    })?;
83
84    if was_existing {
85        Ok(DeployResult::Updated)
86    } else {
87        Ok(DeployResult::Created)
88    }
89}
90
91/// Deploy a file by copying content directly to the target.
92///
93/// Used for templates (rendered content) and system-mode files.
94/// Templates get rendered content written; base/override files are copied from source.
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    let was_existing = target_path.is_symlink() || target_path.exists();
109
110    // Directory at target is an error
111    if target_path.is_dir() && !target_path.is_symlink() {
112        return Ok(DeployResult::Conflict(format!(
113            "target is a directory (remove it manually): {}",
114            target_path.display()
115        )));
116    }
117
118    if target_path.exists() || target_path.is_symlink() {
119        if target_path.is_symlink() {
120            std::fs::remove_file(&target_path).with_context(|| {
121                format!(
122                    "failed to remove existing symlink: {}",
123                    target_path.display()
124                )
125            })?;
126        } else if force {
127            std::fs::remove_file(&target_path).with_context(|| {
128                format!("failed to remove existing file: {}", target_path.display())
129            })?;
130        } else {
131            return Ok(DeployResult::Conflict(format!(
132                "file already exists and is not managed by dotm: {}",
133                target_path.display()
134            )));
135        }
136    }
137
138    // Create parent directories
139    if let Some(parent) = target_path.parent() {
140        std::fs::create_dir_all(parent)
141            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
142    }
143
144    match action.kind {
145        crate::scanner::EntryKind::Template => {
146            let content = rendered_content.context("template has no rendered content")?;
147            std::fs::write(&target_path, content).with_context(|| {
148                format!("failed to write template output: {}", target_path.display())
149            })?;
150        }
151        crate::scanner::EntryKind::Base | crate::scanner::EntryKind::Override => {
152            std::fs::copy(&action.source, &target_path).with_context(|| {
153                format!(
154                    "failed to copy {} to {}",
155                    action.source.display(),
156                    target_path.display()
157                )
158            })?;
159            copy_permissions(&action.source, &target_path)?;
160        }
161    }
162
163    if was_existing {
164        Ok(DeployResult::Updated)
165    } else {
166        Ok(DeployResult::Created)
167    }
168}
169
170/// Parse an octal mode string (e.g. "755") and apply it to the file at `path`.
171pub fn apply_permission_override(path: &Path, mode_str: &str) -> Result<()> {
172    let mode = u32::from_str_radix(mode_str, 8)
173        .with_context(|| format!("invalid octal permission string: '{mode_str}'"))?;
174    let permissions = std::fs::Permissions::from_mode(mode);
175    std::fs::set_permissions(path, permissions)
176        .with_context(|| format!("failed to set permissions {mode_str} on {}", path.display()))?;
177    Ok(())
178}
179
180/// Copy the Unix file permissions from `source` to `dest`.
181fn copy_permissions(source: &Path, dest: &Path) -> Result<()> {
182    let metadata = std::fs::metadata(source)
183        .with_context(|| format!("failed to read metadata from {}", source.display()))?;
184    std::fs::set_permissions(dest, metadata.permissions())
185        .with_context(|| format!("failed to set permissions on {}", dest.display()))?;
186    Ok(())
187}