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