1use std::collections::VecDeque;
2use std::fs::DirEntry;
3#[cfg(not(target_arch = "wasm32"))]
4use std::os::unix::fs::PermissionsExt;
5use std::path::Path;
6use std::path::PathBuf;
7use std::process::Command;
8use std::process::Stdio;
9
10use jiff::Timestamp;
11use jiff::tz::Offset;
12use rootcause::prelude::ResultExt;
13use rootcause::report;
14use serde::Serialize;
15use ytil_cmd::CmdExt;
16
17#[derive(Clone, Serialize)]
19pub enum FileCmdOutput {
20 BinaryFile(String),
22 TextFile(String),
24 Directory(String),
26 NotFound(String),
28 Unknown(String),
30}
31
32pub fn exec_file_cmd(path: &str) -> rootcause::Result<FileCmdOutput> {
47 let stdout_bytes = Command::new("file").args(["-I", path]).exec()?.stdout;
48 let stdout = std::str::from_utf8(&stdout_bytes)?.to_lowercase();
49 if stdout.contains(" inode/directory;") {
50 return Ok(FileCmdOutput::Directory(path.to_owned()));
51 }
52 if stdout.contains(" text/") || stdout.contains(" application/json") {
53 return Ok(FileCmdOutput::TextFile(path.to_owned()));
54 }
55 if stdout.contains(" application/") {
56 return Ok(FileCmdOutput::BinaryFile(path.to_owned()));
57 }
58 if stdout.contains(" no such file or directory") {
59 return Ok(FileCmdOutput::NotFound(path.to_owned()));
60 }
61 Ok(FileCmdOutput::Unknown(path.to_owned()))
62}
63
64pub fn ln_sf<P: AsRef<Path>>(target: &P, link: &P) -> rootcause::Result<()> {
71 match std::fs::remove_file(link.as_ref()) {
73 Ok(()) => {}
74 Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
75 Err(e) => {
76 Err(e)
77 .context("error removing existing link")
78 .attach_with(|| format!("link={}", link.as_ref().display()))?;
79 }
80 }
81 #[cfg(not(target_arch = "wasm32"))]
82 std::os::unix::fs::symlink(target.as_ref(), link.as_ref())
83 .context("error creating symlink")
84 .attach_with(|| format!("target={} link={}", target.as_ref().display(), link.as_ref().display()))?;
85 #[cfg(target_arch = "wasm32")]
86 Err(report!("symlink is not supported on wasm32"))
87 .attach_with(|| format!("target={} link={}", target.as_ref().display(), link.as_ref().display()))?;
88 Ok(())
89}
90
91pub fn ln_sf_files_in_dir<P: AsRef<std::path::Path>>(target_dir: P, link_dir: P) -> rootcause::Result<()> {
98 for target in std::fs::read_dir(&target_dir)
99 .context("error reading directory")
100 .attach_with(|| format!("path={}", target_dir.as_ref().display()))?
101 {
102 let target = target.context("error getting target entry")?.path();
103 if target.is_file() {
104 let target_name = target
105 .file_name()
106 .ok_or_else(|| report!("error missing filename for target"))
107 .attach_with(|| format!("path={}", target.display()))?;
108 let link = link_dir.as_ref().join(target_name);
109 ln_sf(&target, &link)
110 .context("error linking file from directory")
111 .attach_with(|| format!("target={} link={}", target.display(), link.display()))?;
112 }
113 }
114 Ok(())
115}
116
117pub fn cp_to_system_clipboard(content: &mut &[u8]) -> rootcause::Result<()> {
123 let cmd = "pbcopy";
124
125 let mut pbcopy_child = ytil_cmd::silent_cmd(cmd)
126 .stdin(Stdio::piped())
127 .spawn()
128 .context("error spawning cmd")
129 .attach_with(|| format!("cmd={cmd:?}"))?;
130
131 std::io::copy(
132 content,
133 pbcopy_child
134 .stdin
135 .as_mut()
136 .ok_or_else(|| report!("error getting cmd child stdin"))
137 .attach_with(|| format!("cmd={cmd:?}"))?,
138 )
139 .context("error copying content to stdin")
140 .attach_with(|| format!("cmd={cmd:?}"))?;
141
142 if !pbcopy_child
143 .wait()
144 .context("error waiting for cmd")
145 .attach_with(|| format!("cmd={cmd:?}"))?
146 .success()
147 {
148 Err(report!("error copying to system clipboard"))
149 .attach_with(|| format!("cmd={cmd:?} content={content:#?}"))?;
150 }
151
152 Ok(())
153}
154
155pub fn chmod_x<P: AsRef<Path>>(path: P) -> rootcause::Result<()> {
162 #[cfg(target_arch = "wasm32")]
163 {
164 let _ = path;
165 Err(report!("chmod_x is not supported on wasm32"))?;
166 }
167
168 #[cfg(not(target_arch = "wasm32"))]
169 let mut perms = std::fs::metadata(&path)
170 .context("error reading metadata")
171 .attach_with(|| format!("path={}", path.as_ref().display()))?
172 .permissions();
173
174 #[cfg(not(target_arch = "wasm32"))]
175 perms.set_mode(0o755);
176
177 #[cfg(not(target_arch = "wasm32"))]
178 std::fs::set_permissions(&path, perms)
179 .context("error setting permissions")
180 .attach_with(|| format!("path={}", path.as_ref().display()))?;
181
182 Ok(())
183}
184
185pub fn chmod_x_files_in_dir<P: AsRef<Path>>(dir: P) -> rootcause::Result<()> {
192 for target_res in std::fs::read_dir(&dir)
193 .context("error reading directory")
194 .attach_with(|| format!("path={}", dir.as_ref().display()))?
195 {
196 let target = target_res.context("error getting directory entry")?.path();
197 if target.is_file() {
198 chmod_x(&target)
199 .context("error setting file permissions in directory")
200 .attach_with(|| format!("path={}", target.display()))?;
201 }
202 }
203 Ok(())
204}
205
206pub fn atomic_cp(from: &Path, to: &Path) -> rootcause::Result<()> {
219 let tmp_name = format!(
222 "{}.tmp-{}-{}",
223 to.file_name()
224 .ok_or_else(|| report!("error getting file name"))
225 .attach_with(|| format!("path={}", to.display()))?
226 .to_string_lossy(),
227 std::process::id(),
228 Timestamp::now().display_with_offset(Offset::UTC)
229 );
230 let tmp_path = to
231 .parent()
232 .ok_or_else(|| report!("error missing parent directory"))
233 .attach_with(|| format!("path={}", to.display()))?
234 .join(tmp_name);
235
236 std::fs::copy(from, &tmp_path)
237 .context("error copying file to temp")
238 .attach_with(|| format!("from={} temp={}", from.display(), tmp_path.display()))?;
239 std::fs::rename(&tmp_path, to)
240 .context("error renaming file")
241 .attach_with(|| format!("from={} to={}", tmp_path.display(), to.display()))?;
242
243 Ok(())
244}
245
246pub fn find_matching_recursively_in_dir(
254 dir: &Path,
255 matching_file_fn: impl Fn(&DirEntry) -> bool,
256 skip_dir_fn: impl Fn(&DirEntry) -> bool,
257) -> rootcause::Result<Vec<PathBuf>> {
258 let mut manifests = Vec::new();
259 let mut queue = VecDeque::from([dir.to_path_buf()]);
260
261 while let Some(dir) = queue.pop_front() {
262 for entry in std::fs::read_dir(&dir)
263 .context("error reading directory")
264 .attach_with(|| format!("path={}", dir.display()))?
265 {
266 let entry = entry.context("error getting entry")?;
267 let path = entry.path();
268 let file_type = entry
269 .file_type()
270 .context("error getting file type")
271 .attach_with(|| format!("entry={}", path.display()))?;
272
273 if file_type.is_file() {
274 if matching_file_fn(&entry) {
275 manifests.push(path);
276 }
277 continue;
278 }
279
280 if !file_type.is_dir() {
281 continue;
282 }
283
284 if skip_dir_fn(&entry) {
285 continue;
286 }
287 queue.push_back(path);
288 }
289 }
290
291 Ok(manifests)
292}
293
294#[cfg(test)]
295mod tests {
296 use test_that::prelude::*;
297
298 use super::*;
299
300 #[test]
301 fn test_atomic_cp_copies_file_contents() {
302 let dir = tempfile::tempdir().unwrap();
303 let src = dir.path().join("src.txt");
304 let dst = dir.path().join("dst.txt");
305 std::fs::write(&src, b"hello").unwrap();
306
307 let res = atomic_cp(&src, &dst);
308
309 assert_that!(res, ok(eq(())));
310 assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
311 }
312
313 #[test]
314 fn test_atomic_cp_errors_when_missing_source() {
315 let dir = tempfile::tempdir().unwrap();
316 let src = dir.path().join("missing.txt");
317 let dst = dir.path().join("dst.txt");
318
319 let res = atomic_cp(&src, &dst);
320
321 assert_that!(res, err(displays_as(contains_substring("error copying file to temp"))));
322 }
323
324 #[test]
325 fn test_find_matching_recursively_in_dir_returns_the_expected_paths() {
326 let dir = tempfile::tempdir().unwrap();
327 std::fs::create_dir(dir.path().join("a")).unwrap();
329 std::fs::create_dir(dir.path().join("a/b")).unwrap();
330 std::fs::write(dir.path().join("c.txt"), b"c").unwrap();
331 std::fs::write(dir.path().join("a/b/d.txt"), b"d").unwrap();
332
333 let res = find_matching_recursively_in_dir(
334 dir.path(),
335 |e| e.path().extension().and_then(|s| s.to_str()) == Some("txt"),
336 |_| false,
337 );
338 assert_that!(res.as_ref().map(|_| ()), ok(eq(())));
339 let mut found = res.expect("recursive file search should succeed");
340 found.sort();
341
342 let mut expected = vec![dir.path().join("c.txt"), dir.path().join("a/b/d.txt")];
343 expected.sort();
344 assert_eq!(found, expected);
345 }
346
347 #[test]
348 fn test_find_matching_recursively_in_dir_skips_symlink_entries() {
349 #[cfg(not(target_arch = "wasm32"))]
350 {
351 let dir = tempfile::tempdir().unwrap();
352 std::fs::write(dir.path().join("c.txt"), b"c").unwrap();
353 std::fs::create_dir(dir.path().join("nested")).unwrap();
354 std::os::unix::fs::symlink(dir.path().join("nested"), dir.path().join("nested-link")).unwrap();
355
356 let res = find_matching_recursively_in_dir(
357 dir.path(),
358 |e| e.path().extension().and_then(|s| s.to_str()) == Some("txt"),
359 |_| false,
360 );
361
362 assert_that!(res, ok(eq(vec![dir.path().join("c.txt")])));
363 }
364 }
365}