1use crate::{Error, Result, SMeta, reshape};
2use camino::{Utf8Path, Utf8PathBuf};
3use core::fmt;
4use pathdiff::diff_utf8_paths;
5use std::fs::{self, Metadata};
6use std::path::{Path, PathBuf};
7use std::time::UNIX_EPOCH;
8
9#[derive(Debug, Clone, Eq, PartialEq, Hash)]
16pub struct SPath {
17 pub(crate) path_buf: Utf8PathBuf,
18}
19
20impl SPath {
22 pub fn new(path: impl Into<Utf8PathBuf>) -> Self {
26 let path_buf = path.into();
27 let path_buf = reshape::into_normalized(path_buf);
28 Self { path_buf }
29 }
30
31 pub fn from_std_path_buf(path_buf: PathBuf) -> Result<Self> {
33 let path_buf = validate_spath_for_result(path_buf)?;
34 Ok(SPath::new(path_buf))
35 }
36
37 pub fn from_std_path(path: impl AsRef<Path>) -> Result<Self> {
39 let path = path.as_ref();
40 let path_buf = validate_spath_for_result(path)?;
41 Ok(SPath::new(path_buf))
42 }
43
44 pub fn from_walkdir_entry(wd_entry: walkdir::DirEntry) -> Result<Self> {
46 let path = wd_entry.into_path();
47 let path_buf = validate_spath_for_result(path)?;
48 Ok(SPath::new(path_buf))
49 }
50
51 pub fn from_fs_entry(fs_entry: fs::DirEntry) -> Result<Self> {
53 let path = fs_entry.path();
54 let path_buf = validate_spath_for_result(path)?;
55 Ok(SPath::new(path_buf))
56 }
57
58 pub fn from_std_path_ok(path: impl AsRef<Path>) -> Option<Self> {
62 let path = path.as_ref();
63 let path_buf = validate_spath_for_option(path)?;
64 Some(SPath::new(path_buf))
65 }
66
67 pub fn from_std_path_buf_ok(path_buf: PathBuf) -> Option<Self> {
70 let path_buf = validate_spath_for_option(&path_buf)?;
71 Some(SPath::new(path_buf))
72 }
73
74 pub fn from_fs_entry_ok(fs_entry: fs::DirEntry) -> Option<Self> {
77 let path_buf = fs_entry.path();
78 let path_buf = validate_spath_for_option(&path_buf)?;
79 Some(SPath::new(path_buf))
80 }
81
82 pub fn from_walkdir_entry_ok(wd_entry: walkdir::DirEntry) -> Option<Self> {
85 let path_buf = validate_spath_for_option(wd_entry.path())?;
86 Some(SPath::new(path_buf))
87 }
88}
89
90impl SPath {
92 pub fn into_std_path_buf(self) -> PathBuf {
94 self.path_buf.into()
95 }
96
97 pub fn std_path(&self) -> &Path {
99 self.path_buf.as_std_path()
100 }
101
102 pub fn path(&self) -> &Utf8Path {
104 &self.path_buf
105 }
106}
107
108impl SPath {
110 pub fn as_str(&self) -> &str {
112 self.path_buf.as_str()
113 }
114
115 pub fn file_name(&self) -> Option<&str> {
118 self.path_buf.file_name()
119 }
120
121 pub fn name(&self) -> &str {
125 self.file_name().unwrap_or_default()
126 }
127
128 pub fn parent_name(&self) -> &str {
130 self.path_buf.parent().and_then(|p| p.file_name()).unwrap_or_default()
131 }
132
133 pub fn file_stem(&self) -> Option<&str> {
137 self.path_buf.file_stem()
138 }
139
140 pub fn stem(&self) -> &str {
144 self.file_stem().unwrap_or_default()
145 }
146
147 pub fn extension(&self) -> Option<&str> {
152 self.path_buf.extension()
153 }
154
155 pub fn ext(&self) -> &str {
157 self.extension().unwrap_or_default()
158 }
159
160 pub fn is_dir(&self) -> bool {
162 self.path_buf.is_dir()
163 }
164
165 pub fn is_file(&self) -> bool {
167 self.path_buf.is_file()
168 }
169
170 pub fn exists(&self) -> bool {
172 self.path_buf.exists()
173 }
174
175 pub fn is_absolute(&self) -> bool {
177 self.path_buf.is_absolute()
178 }
179
180 pub fn is_relative(&self) -> bool {
182 self.path_buf.is_relative()
183 }
184}
185
186impl SPath {
188 pub fn mime_type(&self) -> Option<&'static str> {
192 mime_guess::from_path(self.path()).first_raw()
193 }
194
195 pub fn is_likely_text(&self) -> bool {
200 if let Some(ext) = self.extension() {
202 let known_text_ext =
204 matches!(
205 ext,
206 "txt"
207 | "md" | "markdown"
208 | "csv" | "toml" | "yaml"
209 | "yml" | "json" | "jsonc"
210 | "json5" | "jsonl"
211 | "ndjson" | "jsonlines"
212 | "ldjson" | "xml" | "html"
213 | "htm" | "css" | "scss"
214 | "sass" | "less" | "js"
215 | "mjs" | "cjs" | "ts"
216 | "tsx" | "jsx" | "rs"
217 | "dart" | "py" | "rb"
218 | "go" | "java" | "c"
219 | "cpp" | "h" | "hpp"
220 | "sh" | "bash" | "zsh"
221 | "fish" | "php" | "lua"
222 | "ini" | "cfg" | "conf"
223 | "sql" | "graphql"
224 | "gql" | "svg" | "log"
225 | "env" | "tex"
226 );
227 if known_text_ext {
228 return true;
229 }
230
231 let known_binary_ext = matches!(ext, "lockb");
233 if known_binary_ext {
234 return false;
235 }
236 }
237
238 let mimes = mime_guess::from_path(self.path());
240 if mimes.is_empty() {
241 return true;
242 }
243
244 mimes.into_iter().any(|mime| {
246 let mime = mime.essence_str();
247 mime.starts_with("text/")
248 || mime == "application/json"
249 || mime == "application/javascript"
250 || mime == "application/x-javascript"
251 || mime == "application/ecmascript"
252 || mime == "application/x-python"
253 || mime == "application/xml"
254 || mime == "application/toml"
255 || mime == "application/x-toml"
256 || mime == "application/x-yaml"
257 || mime == "application/yaml"
258 || mime == "application/sql"
259 || mime == "application/graphql"
260 || mime == "application/xml-dtd"
261 || mime == "application/x-qml"
262 || mime == "application/ini"
263 || mime == "application/x-ini"
264 || mime == "application/x-sh"
265 || mime == "application/x-httpd-php"
266 || mime == "application/x-lua"
267 || mime == "application/vnd.dart"
268 || mime.ends_with("+json")
269 || mime.ends_with("+xml")
270 || mime.ends_with("+yaml")
271 })
272 }
273}
274
275impl SPath {
277 #[allow(clippy::fn_to_numeric_cast)]
281 pub fn meta(&self) -> Result<SMeta> {
282 let path = self;
283
284 let metadata = self.metadata()?;
285
286 let modified = metadata.modified().map_err(|ex| Error::CantGetMetadata((path, ex).into()))?;
288 let modified_epoch_us: i64 = modified
289 .duration_since(UNIX_EPOCH)
290 .map_err(|ex| Error::CantGetMetadata((path, ex).into()))?
291 .as_micros()
292 .min(i64::MAX as u128) as i64;
293
294 let created_epoch_us = metadata
296 .modified()
297 .ok()
298 .and_then(|c| c.duration_since(UNIX_EPOCH).ok())
299 .map(|c| c.as_micros().min(i64::MAX as u128) as i64);
300 let created_epoch_us = created_epoch_us.unwrap_or(modified_epoch_us);
301
302 let size = if metadata.is_file() { metadata.len() } else { 0 };
304
305 Ok(SMeta {
306 created_epoch_us,
307 modified_epoch_us,
308 size,
309 is_file: metadata.is_file(),
310 is_dir: metadata.is_dir(),
311 })
312 }
313
314 pub fn metadata(&self) -> Result<Metadata> {
316 fs::metadata(self).map_err(|ex| Error::CantGetMetadata((self, ex).into()))
317 }
318}
319
320impl SPath {
322 pub fn canonicalize(&self) -> Result<SPath> {
324 let path = self
325 .path_buf
326 .canonicalize_utf8()
327 .map_err(|err| Error::CannotCanonicalize((self.std_path(), err).into()))?;
328 Ok(SPath::new(path))
329 }
330
331 pub fn collapse(&self) -> SPath {
339 let path_buf = crate::into_collapsed(self.path_buf.clone());
340 SPath::new(path_buf)
341 }
342
343 pub fn into_collapsed(self) -> SPath {
345 if self.is_collapsed() { self } else { self.collapse() }
346 }
347
348 pub fn is_collapsed(&self) -> bool {
355 crate::is_collapsed(self)
356 }
357
358 pub fn parent(&self) -> Option<SPath> {
364 self.path_buf.parent().map(SPath::from)
365 }
366
367 pub fn append_suffix(&self, suffix: &str) -> SPath {
374 SPath::new(format!("{self}{suffix}"))
375 }
376
377 pub fn join(&self, leaf_path: impl Into<Utf8PathBuf>) -> SPath {
379 let path_buf = self.path_buf.join(leaf_path.into());
380 SPath::from(path_buf)
381 }
382
383 pub fn join_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
385 let leaf_path = leaf_path.as_ref();
386 let joined = self.std_path().join(leaf_path);
387 let path_buf = validate_spath_for_result(joined)?;
388 Ok(SPath::from(path_buf))
389 }
390
391 pub fn new_sibling(&self, leaf_path: impl AsRef<str>) -> SPath {
393 let leaf_path = leaf_path.as_ref();
394 match self.path_buf.parent() {
395 Some(parent_dir) => SPath::new(parent_dir.join(leaf_path)),
396 None => SPath::new(leaf_path),
397 }
398 }
399
400 pub fn new_sibling_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
402 let leaf_path = leaf_path.as_ref();
403
404 match self.std_path().parent() {
405 Some(parent_dir) => SPath::from_std_path(parent_dir.join(leaf_path)),
406 None => SPath::from_std_path(leaf_path),
407 }
408 }
409
410 pub fn diff(&self, base: impl AsRef<Utf8Path>) -> Option<SPath> {
432 let base = base.as_ref();
433
434 let diff_path = diff_utf8_paths(self, base);
435
436 diff_path.map(SPath::from)
437 }
438
439 pub fn try_diff(&self, base: impl AsRef<Utf8Path>) -> Result<SPath> {
452 self.diff(&base).ok_or_else(|| Error::CannotDiff {
453 path: self.to_string(),
454 base: base.as_ref().to_string(),
455 })
456 }
457
458 pub fn replace_prefix(&self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
463 let base = base.as_ref();
464 let with = with.as_ref();
465 let s = self.as_str();
466 if let Some(stripped) = s.strip_prefix(base) {
467 let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
469 format!("{with}{stripped}")
470 } else {
471 format!("{with}/{stripped}")
472 };
473 SPath::new(joined)
474 } else {
475 self.clone()
476 }
477 }
478
479 pub fn into_replace_prefix(self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
480 let base = base.as_ref();
481 let with = with.as_ref();
482 let s = self.as_str();
483 if let Some(stripped) = s.strip_prefix(base) {
484 let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
485 format!("{with}{stripped}")
486 } else {
487 format!("{with}/{stripped}")
488 };
489 SPath::new(joined)
490 } else {
491 self
492 }
493 }
494
495 }
497
498impl SPath {
500 pub fn as_std_path(&self) -> &Path {
501 self.std_path()
502 }
503
504 pub fn strip_prefix(&self, prefix: impl AsRef<str>) -> Result<SPath> {
510 let prefix = prefix.as_ref();
511 let new_path = self.path_buf.strip_prefix(prefix).map_err(|_| Error::StripPrefix {
512 prefix: prefix.to_string(),
513 path: self.to_string(),
514 })?;
515
516 Ok(new_path.into())
517 }
518
519 pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
542 self.path_buf.starts_with(base)
543 }
544
545 pub fn starts_with_prefix(&self, base: impl AsRef<str>) -> bool {
546 self.path_buf.starts_with(base.as_ref())
547 }
548}
549
550impl SPath {
552 pub fn into_ensure_extension(mut self, ext: &str) -> Self {
559 if self.extension() != Some(ext) {
560 self.path_buf.set_extension(ext);
561 }
562 self
563 }
564
565 pub fn ensure_extension(&self, ext: &str) -> Self {
575 self.clone().into_ensure_extension(ext)
576 }
577
578 pub fn append_extension(&self, ext: &str) -> Self {
583 SPath::new(format!("{self}.{ext}"))
584 }
585}
586
587impl SPath {
589 pub fn dir_before_glob(&self) -> Option<SPath> {
598 let path_str = self.as_str();
599 let mut last_slash_idx = None;
600
601 for (i, c) in path_str.char_indices() {
602 if c == '/' {
603 last_slash_idx = Some(i);
604 } else if matches!(c, '*' | '?' | '[' | '{') {
605 return Some(SPath::from(&path_str[..last_slash_idx.unwrap_or(0)]));
606 }
607 }
608
609 None
610 }
611}
612
613impl fmt::Display for SPath {
616 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617 write!(f, "{}", self.as_str())
618 }
619}
620
621impl AsRef<SPath> for SPath {
626 fn as_ref(&self) -> &SPath {
627 self
628 }
629}
630
631impl AsRef<Path> for SPath {
632 fn as_ref(&self) -> &Path {
633 self.path_buf.as_ref()
634 }
635}
636
637impl AsRef<Utf8Path> for SPath {
638 fn as_ref(&self) -> &Utf8Path {
639 self.path_buf.as_ref()
640 }
641}
642
643impl AsRef<str> for SPath {
644 fn as_ref(&self) -> &str {
645 self.as_str()
646 }
647}
648
649impl From<SPath> for String {
654 fn from(val: SPath) -> Self {
655 val.as_str().to_string()
656 }
657}
658
659impl From<&SPath> for String {
660 fn from(val: &SPath) -> Self {
661 val.as_str().to_string()
662 }
663}
664
665impl From<SPath> for PathBuf {
666 fn from(val: SPath) -> Self {
667 val.into_std_path_buf()
668 }
669}
670
671impl From<&SPath> for PathBuf {
672 fn from(val: &SPath) -> Self {
673 val.path_buf.clone().into()
674 }
675}
676
677impl From<SPath> for Utf8PathBuf {
678 fn from(val: SPath) -> Self {
679 val.path_buf
680 }
681}
682
683impl From<&SPath> for SPath {
688 fn from(path: &SPath) -> Self {
689 path.clone()
690 }
691}
692
693impl From<Utf8PathBuf> for SPath {
694 fn from(path_buf: Utf8PathBuf) -> Self {
695 SPath::new(path_buf)
696 }
697}
698
699impl From<&Utf8Path> for SPath {
700 fn from(path: &Utf8Path) -> Self {
701 SPath::new(path)
702 }
703}
704
705impl From<String> for SPath {
706 fn from(path: String) -> Self {
707 SPath::new(path)
708 }
709}
710
711impl From<&String> for SPath {
712 fn from(path: &String) -> Self {
713 SPath::new(path)
714 }
715}
716
717impl From<&str> for SPath {
718 fn from(path: &str) -> Self {
719 SPath::new(path)
720 }
721}
722
723impl TryFrom<PathBuf> for SPath {
728 type Error = Error;
729 fn try_from(path_buf: PathBuf) -> Result<SPath> {
730 SPath::from_std_path_buf(path_buf)
731 }
732}
733
734impl TryFrom<fs::DirEntry> for SPath {
735 type Error = Error;
736 fn try_from(fs_entry: fs::DirEntry) -> Result<SPath> {
737 SPath::from_std_path_buf(fs_entry.path())
738 }
739}
740
741impl TryFrom<walkdir::DirEntry> for SPath {
742 type Error = Error;
743 fn try_from(wd_entry: walkdir::DirEntry) -> Result<SPath> {
744 SPath::from_std_path(wd_entry.path())
745 }
746}
747
748pub(crate) fn validate_spath_for_result(path: impl Into<PathBuf>) -> Result<Utf8PathBuf> {
753 let path = path.into();
754 let path_buf =
755 Utf8PathBuf::from_path_buf(path).map_err(|err| Error::PathNotUtf8(err.to_string_lossy().to_string()))?;
756 Ok(path_buf)
757}
758
759pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
761 Utf8PathBuf::from_path_buf(path.into()).ok()
762}
763
764#[cfg(test)]
769#[path = "_tests/tests_spath.rs"]
770mod tests_spath;
771
772