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
15pub 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 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 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 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 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 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
91pub 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 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 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
170pub 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
180fn 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}