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 =
203 matches!(
204 ext,
205 "txt"
206 | "md" | "markdown"
207 | "csv" | "toml" | "yaml"
208 | "yml" | "json" | "jsonc"
209 | "json5" | "jsonl"
210 | "ndjson" | "jsonlines"
211 | "ldjson" | "xml" | "html"
212 | "htm" | "css" | "scss"
213 | "sass" | "less" | "js"
214 | "mjs" | "cjs" | "ts"
215 | "tsx" | "jsx" | "rs"
216 | "py" | "rb" | "go"
217 | "java" | "c" | "cpp"
218 | "h" | "hpp" | "sh"
219 | "bash" | "zsh" | "fish"
220 | "php" | "lua" | "ini"
221 | "cfg" | "conf" | "sql"
222 | "graphql" | "gql"
223 | "svg" | "log" | "env"
224 );
225 if known_text_ext {
226 return true;
227 }
228 }
229
230 let mimes = mime_guess::from_path(self.path());
232 if mimes.is_empty() {
233 return true;
234 }
235
236 mimes.into_iter().any(|mime| {
238 let mime = mime.essence_str();
239 mime.starts_with("text/")
240 || mime == "application/json"
241 || mime == "application/javascript"
242 || mime == "application/x-javascript"
243 || mime == "application/ecmascript"
244 || mime == "application/x-python"
245 || mime == "application/xml"
246 || mime == "application/toml"
247 || mime == "application/x-toml"
248 || mime == "application/x-yaml"
249 || mime == "application/yaml"
250 || mime == "application/sql"
251 || mime == "application/graphql"
252 || mime == "application/xml-dtd"
253 || mime == "application/x-qml"
254 || mime == "application/ini"
255 || mime == "application/x-ini"
256 || mime == "application/x-sh"
257 || mime == "application/x-httpd-php"
258 || mime == "application/x-lua"
259 || mime.ends_with("+json")
260 || mime.ends_with("+xml")
261 || mime.ends_with("+yaml")
262 })
263 }
264}
265
266impl SPath {
268 #[allow(clippy::fn_to_numeric_cast)]
272 pub fn meta(&self) -> Result<SMeta> {
273 let path = self;
274
275 let metadata = self.metadata()?;
276
277 let modified = metadata.modified().map_err(|ex| Error::CantGetMetadata((path, ex).into()))?;
279 let modified_epoch_us: i64 = modified
280 .duration_since(UNIX_EPOCH)
281 .map_err(|ex| Error::CantGetMetadata((path, ex).into()))?
282 .as_micros()
283 .min(i64::MAX as u128) as i64;
284
285 let created_epoch_us = metadata
287 .modified()
288 .ok()
289 .and_then(|c| c.duration_since(UNIX_EPOCH).ok())
290 .map(|c| c.as_micros().min(i64::MAX as u128) as i64);
291 let created_epoch_us = created_epoch_us.unwrap_or(modified_epoch_us);
292
293 let size = if metadata.is_file() { metadata.len() } else { 0 };
295
296 Ok(SMeta {
297 created_epoch_us,
298 modified_epoch_us,
299 size,
300 is_file: metadata.is_file(),
301 is_dir: metadata.is_dir(),
302 })
303 }
304
305 pub fn metadata(&self) -> Result<Metadata> {
307 fs::metadata(self).map_err(|ex| Error::CantGetMetadata((self, ex).into()))
308 }
309
310}
311
312impl SPath {
314 pub fn canonicalize(&self) -> Result<SPath> {
316 let path = self
317 .path_buf
318 .canonicalize_utf8()
319 .map_err(|err| Error::CannotCanonicalize((self.std_path(), err).into()))?;
320 Ok(SPath::new(path))
321 }
322
323 pub fn collapse(&self) -> SPath {
331 let path_buf = crate::into_collapsed(self.path_buf.clone());
332 SPath::new(path_buf)
333 }
334
335 pub fn into_collapsed(self) -> SPath {
337 if self.is_collapsed() { self } else { self.collapse() }
338 }
339
340 pub fn is_collapsed(&self) -> bool {
347 crate::is_collapsed(self)
348 }
349
350 pub fn parent(&self) -> Option<SPath> {
356 self.path_buf.parent().map(SPath::from)
357 }
358
359 pub fn append_suffix(&self, suffix: &str) -> SPath {
366 SPath::new(format!("{self}{suffix}"))
367 }
368
369 pub fn join(&self, leaf_path: impl Into<Utf8PathBuf>) -> SPath {
371 let path_buf = self.path_buf.join(leaf_path.into());
372 SPath::from(path_buf)
373 }
374
375 pub fn join_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
377 let leaf_path = leaf_path.as_ref();
378 let joined = self.std_path().join(leaf_path);
379 let path_buf = validate_spath_for_result(joined)?;
380 Ok(SPath::from(path_buf))
381 }
382
383 pub fn new_sibling(&self, leaf_path: impl AsRef<str>) -> SPath {
385 let leaf_path = leaf_path.as_ref();
386 match self.path_buf.parent() {
387 Some(parent_dir) => SPath::new(parent_dir.join(leaf_path)),
388 None => SPath::new(leaf_path),
389 }
390 }
391
392 pub fn new_sibling_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
394 let leaf_path = leaf_path.as_ref();
395
396 match self.std_path().parent() {
397 Some(parent_dir) => SPath::from_std_path(parent_dir.join(leaf_path)),
398 None => SPath::from_std_path(leaf_path),
399 }
400 }
401
402 pub fn diff(&self, base: impl AsRef<Utf8Path>) -> Option<SPath> {
424 let base = base.as_ref();
425
426 let diff_path = diff_utf8_paths(self, base);
427
428 diff_path.map(SPath::from)
429 }
430
431 pub fn try_diff(&self, base: impl AsRef<Utf8Path>) -> Result<SPath> {
444 self.diff(&base).ok_or_else(|| Error::CannotDiff {
445 path: self.to_string(),
446 base: base.as_ref().to_string(),
447 })
448 }
449
450 pub fn replace_prefix(&self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
455 let base = base.as_ref();
456 let with = with.as_ref();
457 let s = self.as_str();
458 if let Some(stripped) = s.strip_prefix(base) {
459 let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
461 format!("{with}{stripped}")
462 } else {
463 format!("{with}/{stripped}")
464 };
465 SPath::new(joined)
466 } else {
467 self.clone()
468 }
469 }
470
471 pub fn into_replace_prefix(self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
472 let base = base.as_ref();
473 let with = with.as_ref();
474 let s = self.as_str();
475 if let Some(stripped) = s.strip_prefix(base) {
476 let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
477 format!("{with}{stripped}")
478 } else {
479 format!("{with}/{stripped}")
480 };
481 SPath::new(joined)
482 } else {
483 self
484 }
485 }
486
487 }
489
490impl SPath {
492 pub fn as_std_path(&self) -> &Path {
493 self.std_path()
494 }
495
496 pub fn strip_prefix(&self, prefix: impl AsRef<str>) -> Result<SPath> {
502 let prefix = prefix.as_ref();
503 let new_path = self.path_buf.strip_prefix(prefix).map_err(|_| Error::StripPrefix {
504 prefix: prefix.to_string(),
505 path: self.to_string(),
506 })?;
507
508 Ok(new_path.into())
509 }
510
511 pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
534 self.path_buf.starts_with(base)
535 }
536
537 pub fn starts_with_prefix(&self, base: impl AsRef<str>) -> bool {
538 self.path_buf.starts_with(base.as_ref())
539 }
540}
541
542impl SPath {
544 pub fn into_ensure_extension(mut self, ext: &str) -> Self {
551 if self.extension() != Some(ext) {
552 self.path_buf.set_extension(ext);
553 }
554 self
555 }
556
557 pub fn ensure_extension(&self, ext: &str) -> Self {
567 self.clone().into_ensure_extension(ext)
568 }
569
570 pub fn append_extension(&self, ext: &str) -> Self {
575 SPath::new(format!("{self}.{ext}"))
576 }
577}
578
579impl SPath {
581 pub fn dir_before_glob(&self) -> Option<SPath> {
590 let path_str = self.as_str();
591 let mut last_slash_idx = None;
592
593 for (i, c) in path_str.char_indices() {
594 if c == '/' {
595 last_slash_idx = Some(i);
596 } else if matches!(c, '*' | '?' | '[' | '{') {
597 return Some(SPath::from(&path_str[..last_slash_idx.unwrap_or(0)]));
598 }
599 }
600
601 None
602 }
603}
604
605impl fmt::Display for SPath {
608 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
609 write!(f, "{}", self.as_str())
610 }
611}
612
613impl AsRef<SPath> for SPath {
618 fn as_ref(&self) -> &SPath {
619 self
620 }
621}
622
623impl AsRef<Path> for SPath {
624 fn as_ref(&self) -> &Path {
625 self.path_buf.as_ref()
626 }
627}
628
629impl AsRef<Utf8Path> for SPath {
630 fn as_ref(&self) -> &Utf8Path {
631 self.path_buf.as_ref()
632 }
633}
634
635impl AsRef<str> for SPath {
636 fn as_ref(&self) -> &str {
637 self.as_str()
638 }
639}
640
641impl From<SPath> for String {
646 fn from(val: SPath) -> Self {
647 val.as_str().to_string()
648 }
649}
650
651impl From<&SPath> for String {
652 fn from(val: &SPath) -> Self {
653 val.as_str().to_string()
654 }
655}
656
657impl From<SPath> for PathBuf {
658 fn from(val: SPath) -> Self {
659 val.into_std_path_buf()
660 }
661}
662
663impl From<&SPath> for PathBuf {
664 fn from(val: &SPath) -> Self {
665 val.path_buf.clone().into()
666 }
667}
668
669impl From<SPath> for Utf8PathBuf {
670 fn from(val: SPath) -> Self {
671 val.path_buf
672 }
673}
674
675impl From<&SPath> for SPath {
680 fn from(path: &SPath) -> Self {
681 path.clone()
682 }
683}
684
685impl From<Utf8PathBuf> for SPath {
686 fn from(path_buf: Utf8PathBuf) -> Self {
687 SPath::new(path_buf)
688 }
689}
690
691impl From<&Utf8Path> for SPath {
692 fn from(path: &Utf8Path) -> Self {
693 SPath::new(path)
694 }
695}
696
697impl From<String> for SPath {
698 fn from(path: String) -> Self {
699 SPath::new(path)
700 }
701}
702
703impl From<&String> for SPath {
704 fn from(path: &String) -> Self {
705 SPath::new(path)
706 }
707}
708
709impl From<&str> for SPath {
710 fn from(path: &str) -> Self {
711 SPath::new(path)
712 }
713}
714
715impl TryFrom<PathBuf> for SPath {
720 type Error = Error;
721 fn try_from(path_buf: PathBuf) -> Result<SPath> {
722 SPath::from_std_path_buf(path_buf)
723 }
724}
725
726impl TryFrom<fs::DirEntry> for SPath {
727 type Error = Error;
728 fn try_from(fs_entry: fs::DirEntry) -> Result<SPath> {
729 SPath::from_std_path_buf(fs_entry.path())
730 }
731}
732
733impl TryFrom<walkdir::DirEntry> for SPath {
734 type Error = Error;
735 fn try_from(wd_entry: walkdir::DirEntry) -> Result<SPath> {
736 SPath::from_std_path(wd_entry.path())
737 }
738}
739
740pub(crate) fn validate_spath_for_result(path: impl Into<PathBuf>) -> Result<Utf8PathBuf> {
745 let path = path.into();
746 let path_buf =
747 Utf8PathBuf::from_path_buf(path).map_err(|err| Error::PathNotUtf8(err.to_string_lossy().to_string()))?;
748 Ok(path_buf)
749}
750
751pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
753 Utf8PathBuf::from_path_buf(path.into()).ok()
754}
755
756#[cfg(test)]
761mod tests {
762 use super::*;
763
764 #[test]
765 fn test_spath_is_likely_text() {
766 let cases: &[(&str, bool)] = &[
768 ("readme.md", true),
770 ("readme.markdown", true),
771 ("data.csv", true),
772 ("config.toml", true),
773 ("config.yaml", true),
774 ("config.yml", true),
775 ("data.json", true),
776 ("data.jsonc", true),
777 ("data.jsonl", true),
778 ("data.ndjson", true),
779 ("data.ldjson", true),
780 ("doc.xml", true),
781 ("page.html", true),
782 ("page.htm", true),
783 ("styles.css", true),
784 ("styles.scss", true),
785 ("styles.sass", true),
786 ("styles.less", true),
787 ("script.js", true),
788 ("script.mjs", true),
789 ("script.cjs", true),
790 ("types.ts", true),
791 ("component.tsx", true),
792 ("component.jsx", true),
793 ("main.rs", true),
794 ("main.py", true),
795 ("main.rb", true),
796 ("main.go", true),
797 ("Main.java", true),
798 ("main.c", true),
799 ("main.cpp", true),
800 ("main.h", true),
801 ("main.hpp", true),
802 ("script.sh", true),
803 ("script.bash", true),
804 ("script.zsh", true),
805 ("script.fish", true),
806 ("index.php", true),
807 ("script.lua", true),
808 ("config.ini", true),
809 ("config.cfg", true),
810 ("config.conf", true),
811 ("query.sql", true),
812 ("schema.graphql", true),
813 ("schema.gql", true),
814 ("icon.svg", true),
815 ("app.log", true),
816 (".env", true),
817 ("Dockerfile", true),
818 ("Makefile", true),
819 ("LICENSE", true),
820 (".gitignore", true),
821 ("notes.txt", true),
822 ("image.png", false),
824 ("image.jpg", false),
825 ("image.jpeg", false),
826 ("image.gif", false),
827 ("image.webp", false),
828 ("archive.zip", false),
829 ("archive.tar", false),
830 ("archive.gz", false),
831 ("binary.exe", false),
832 ("library.so", false),
833 ("library.dll", false),
834 ("document.pdf", false),
835 ("audio.mp3", false),
836 ("video.mp4", false),
837 ("font.ttf", false),
838 ("font.woff", false),
839 ];
840
841 for (filename, expected) in cases {
843 let spath = SPath::new(*filename);
844 let result = spath.is_likely_text();
845 assert_eq!(
846 result, *expected,
847 "is_likely_text({filename:?}) expected {expected} but got {result}"
848 );
849 }
850 }
851}
852
853