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