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::{SystemTime, UNIX_EPOCH};
8
9#[derive(Debug, Clone, Eq, PartialEq, Hash)]
15pub struct SPath {
16 pub(crate) path_buf: Utf8PathBuf,
17}
18
19impl SPath {
21 pub fn new(path: impl Into<Utf8PathBuf>) -> Self {
24 let path_buf = path.into();
25 let path_buf = reshape::into_normalized(path_buf);
26 Self { path_buf }
27 }
28
29 pub fn from_std_path_buf(path_buf: PathBuf) -> Result<Self> {
31 let path_buf = validate_spath_for_result(path_buf)?;
32 Ok(SPath::new(path_buf))
33 }
34
35 pub fn from_std_path(path: impl AsRef<Path>) -> Result<Self> {
37 let path = path.as_ref();
38 let path_buf = validate_spath_for_result(path)?;
39 Ok(SPath::new(path_buf))
40 }
41
42 pub fn from_walkdir_entry(wd_entry: walkdir::DirEntry) -> Result<Self> {
44 let path = wd_entry.into_path();
45 let path_buf = validate_spath_for_result(path)?;
46 Ok(SPath::new(path_buf))
47 }
48
49 pub fn from_std_path_ok(path: impl AsRef<Path>) -> Option<Self> {
53 let path = path.as_ref();
54 let path_buf = validate_spath_for_option(path)?;
55 Some(SPath::new(path_buf))
56 }
57
58 pub fn from_std_path_buf_ok(path_buf: PathBuf) -> Option<Self> {
61 let path_buf = validate_spath_for_option(&path_buf)?;
62 Some(SPath::new(path_buf))
63 }
64
65 pub fn from_fs_entry_ok(fs_entry: fs::DirEntry) -> Option<Self> {
68 let path_buf = fs_entry.path();
69 let path_buf = validate_spath_for_option(&path_buf)?;
70 Some(SPath::new(path_buf))
71 }
72
73 pub fn from_walkdir_entry_ok(wd_entry: walkdir::DirEntry) -> Option<Self> {
76 let path_buf = validate_spath_for_option(wd_entry.path())?;
77 Some(SPath::new(path_buf))
78 }
79}
80
81impl SPath {
83 pub fn into_std_path_buf(self) -> PathBuf {
85 self.path_buf.into()
86 }
87
88 pub fn std_path(&self) -> &Path {
90 self.path_buf.as_std_path()
91 }
92
93 pub fn path(&self) -> &Utf8Path {
95 &self.path_buf
96 }
97}
98
99impl SPath {
101 #[deprecated(note = "use as_str()")]
106 pub fn to_str(&self) -> &str {
107 self.path_buf.as_str()
108 }
109
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 #[deprecated = "use spath.meta()"]
313 pub fn modified(&self) -> Result<SystemTime> {
314 let path = self.std_path();
315 let metadata = fs::metadata(path).map_err(|ex| Error::CantGetMetadata((path, ex).into()))?;
316 let last_modified = metadata
317 .modified()
318 .map_err(|ex| Error::CantGetMetadataModified((path, ex).into()))?;
319 Ok(last_modified)
320 }
321
322 #[deprecated = "use spath.meta()"]
326 pub fn modified_us(&self) -> Result<i64> {
327 Ok(self.meta()?.modified_epoch_us)
328 }
329}
330
331impl SPath {
333 pub fn canonicalize(&self) -> Result<SPath> {
335 let path = self
336 .path_buf
337 .canonicalize_utf8()
338 .map_err(|err| Error::CannotCanonicalize((self.std_path(), err).into()))?;
339 Ok(SPath::new(path))
340 }
341
342 pub fn collapse(&self) -> SPath {
350 let path_buf = crate::into_collapsed(self.path_buf.clone());
351 SPath::new(path_buf)
352 }
353
354 pub fn into_collapsed(self) -> SPath {
356 if self.is_collapsed() { self } else { self.collapse() }
357 }
358
359 pub fn is_collapsed(&self) -> bool {
366 crate::is_collapsed(self)
367 }
368
369 pub fn parent(&self) -> Option<SPath> {
375 self.path_buf.parent().map(SPath::from)
376 }
377
378 pub fn append_suffix(&self, suffix: &str) -> SPath {
385 SPath::new(format!("{self}{suffix}"))
386 }
387
388 pub fn join(&self, leaf_path: impl Into<Utf8PathBuf>) -> SPath {
390 let path_buf = self.path_buf.join(leaf_path.into());
391 SPath::from(path_buf)
392 }
393
394 pub fn join_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
396 let leaf_path = leaf_path.as_ref();
397 let joined = self.std_path().join(leaf_path);
398 let path_buf = validate_spath_for_result(joined)?;
399 Ok(SPath::from(path_buf))
400 }
401
402 pub fn new_sibling(&self, leaf_path: impl AsRef<str>) -> SPath {
404 let leaf_path = leaf_path.as_ref();
405 match self.path_buf.parent() {
406 Some(parent_dir) => SPath::new(parent_dir.join(leaf_path)),
407 None => SPath::new(leaf_path),
408 }
409 }
410
411 pub fn new_sibling_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
413 let leaf_path = leaf_path.as_ref();
414
415 match self.std_path().parent() {
416 Some(parent_dir) => SPath::from_std_path(parent_dir.join(leaf_path)),
417 None => SPath::from_std_path(leaf_path),
418 }
419 }
420
421 pub fn diff(&self, base: impl AsRef<Utf8Path>) -> Option<SPath> {
443 let base = base.as_ref();
444
445 let diff_path = diff_utf8_paths(self, base);
446
447 diff_path.map(SPath::from)
448 }
449
450 pub fn try_diff(&self, base: impl AsRef<Utf8Path>) -> Result<SPath> {
463 self.diff(&base).ok_or_else(|| Error::CannotDiff {
464 path: self.to_string(),
465 base: base.as_ref().to_string(),
466 })
467 }
468
469 pub fn replace_prefix(&self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
474 let base = base.as_ref();
475 let with = with.as_ref();
476 let s = self.as_str();
477 if let Some(stripped) = s.strip_prefix(base) {
478 let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
480 format!("{with}{stripped}")
481 } else {
482 format!("{with}/{stripped}")
483 };
484 SPath::new(joined)
485 } else {
486 self.clone()
487 }
488 }
489
490 pub fn into_replace_prefix(self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
491 let base = base.as_ref();
492 let with = with.as_ref();
493 let s = self.as_str();
494 if let Some(stripped) = s.strip_prefix(base) {
495 let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
496 format!("{with}{stripped}")
497 } else {
498 format!("{with}/{stripped}")
499 };
500 SPath::new(joined)
501 } else {
502 self
503 }
504 }
505
506 }
508
509impl SPath {
511 pub fn as_std_path(&self) -> &Path {
512 self.std_path()
513 }
514
515 pub fn strip_prefix(&self, prefix: impl AsRef<Path>) -> Result<SPath> {
521 let prefix = prefix.as_ref();
522 let new_path = self.path_buf.strip_prefix(prefix).map_err(|_| Error::StripPrefix {
523 prefix: prefix.to_string_lossy().to_string(),
524 path: self.to_string(),
525 })?;
526
527 Ok(new_path.into())
528 }
529
530 pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
553 self.path_buf.starts_with(base)
554 }
555}
556
557impl SPath {
559 pub fn into_ensure_extension(mut self, ext: &str) -> Self {
566 if self.extension() != Some(ext) {
567 self.path_buf.set_extension(ext);
568 }
569 self
570 }
571
572 pub fn ensure_extension(&self, ext: &str) -> Self {
582 self.clone().into_ensure_extension(ext)
583 }
584
585 pub fn append_extension(&self, ext: &str) -> Self {
590 SPath::new(format!("{self}.{ext}"))
591 }
592}
593
594impl SPath {
596 pub fn dir_before_glob(&self) -> Option<SPath> {
605 let path_str = self.as_str();
606 let mut last_slash_idx = None;
607
608 for (i, c) in path_str.char_indices() {
609 if c == '/' {
610 last_slash_idx = Some(i);
611 } else if matches!(c, '*' | '?' | '[' | '{') {
612 return Some(SPath::from(&path_str[..last_slash_idx.unwrap_or(0)]));
613 }
614 }
615
616 None
617 }
618}
619
620impl fmt::Display for SPath {
623 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
624 write!(f, "{}", self.as_str())
625 }
626}
627
628impl AsRef<SPath> for SPath {
633 fn as_ref(&self) -> &SPath {
634 self
635 }
636}
637
638impl AsRef<Path> for SPath {
639 fn as_ref(&self) -> &Path {
640 self.path_buf.as_ref()
641 }
642}
643
644impl AsRef<Utf8Path> for SPath {
645 fn as_ref(&self) -> &Utf8Path {
646 self.path_buf.as_ref()
647 }
648}
649
650impl AsRef<str> for SPath {
651 fn as_ref(&self) -> &str {
652 self.as_str()
653 }
654}
655
656impl From<SPath> for String {
661 fn from(val: SPath) -> Self {
662 val.as_str().to_string()
663 }
664}
665
666impl From<&SPath> for String {
667 fn from(val: &SPath) -> Self {
668 val.as_str().to_string()
669 }
670}
671
672impl From<SPath> for PathBuf {
673 fn from(val: SPath) -> Self {
674 val.into_std_path_buf()
675 }
676}
677
678impl From<&SPath> for PathBuf {
679 fn from(val: &SPath) -> Self {
680 val.path_buf.clone().into()
681 }
682}
683
684impl From<SPath> for Utf8PathBuf {
685 fn from(val: SPath) -> Self {
686 val.path_buf
687 }
688}
689
690impl From<&SPath> for SPath {
695 fn from(path: &SPath) -> Self {
696 path.clone()
697 }
698}
699
700impl From<Utf8PathBuf> for SPath {
701 fn from(path_buf: Utf8PathBuf) -> Self {
702 SPath::new(path_buf)
703 }
704}
705
706impl From<&Utf8Path> for SPath {
707 fn from(path: &Utf8Path) -> Self {
708 SPath::new(path)
709 }
710}
711
712impl From<String> for SPath {
713 fn from(path: String) -> Self {
714 SPath::new(path)
715 }
716}
717
718impl From<&String> for SPath {
719 fn from(path: &String) -> Self {
720 SPath::new(path)
721 }
722}
723
724impl From<&str> for SPath {
725 fn from(path: &str) -> Self {
726 SPath::new(path)
727 }
728}
729
730impl TryFrom<PathBuf> for SPath {
735 type Error = Error;
736 fn try_from(path_buf: PathBuf) -> Result<SPath> {
737 SPath::from_std_path_buf(path_buf)
738 }
739}
740
741impl TryFrom<fs::DirEntry> for SPath {
742 type Error = Error;
743 fn try_from(fs_entry: fs::DirEntry) -> Result<SPath> {
744 SPath::from_std_path_buf(fs_entry.path())
745 }
746}
747
748impl TryFrom<walkdir::DirEntry> for SPath {
749 type Error = Error;
750 fn try_from(wd_entry: walkdir::DirEntry) -> Result<SPath> {
751 SPath::from_std_path(wd_entry.path())
752 }
753}
754
755pub(crate) fn validate_spath_for_result(path: impl Into<PathBuf>) -> Result<Utf8PathBuf> {
760 let path = path.into();
761 let path_buf =
762 Utf8PathBuf::from_path_buf(path).map_err(|err| Error::PathNotUtf8(err.to_string_lossy().to_string()))?;
763 Ok(path_buf)
764}
765
766pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
768 Utf8PathBuf::from_path_buf(path.into()).ok()
769}
770
771#[cfg(test)]
776mod tests {
777 use super::*;
778
779 #[test]
780 fn test_spath_is_likely_text() {
781 let cases: &[(&str, bool)] = &[
783 ("readme.md", true),
785 ("readme.markdown", true),
786 ("data.csv", true),
787 ("config.toml", true),
788 ("config.yaml", true),
789 ("config.yml", true),
790 ("data.json", true),
791 ("data.jsonc", true),
792 ("data.jsonl", true),
793 ("data.ndjson", true),
794 ("data.ldjson", true),
795 ("doc.xml", true),
796 ("page.html", true),
797 ("page.htm", true),
798 ("styles.css", true),
799 ("styles.scss", true),
800 ("styles.sass", true),
801 ("styles.less", true),
802 ("script.js", true),
803 ("script.mjs", true),
804 ("script.cjs", true),
805 ("types.ts", true),
806 ("component.tsx", true),
807 ("component.jsx", true),
808 ("main.rs", true),
809 ("main.py", true),
810 ("main.rb", true),
811 ("main.go", true),
812 ("Main.java", true),
813 ("main.c", true),
814 ("main.cpp", true),
815 ("main.h", true),
816 ("main.hpp", true),
817 ("script.sh", true),
818 ("script.bash", true),
819 ("script.zsh", true),
820 ("script.fish", true),
821 ("index.php", true),
822 ("script.lua", true),
823 ("config.ini", true),
824 ("config.cfg", true),
825 ("config.conf", true),
826 ("query.sql", true),
827 ("schema.graphql", true),
828 ("schema.gql", true),
829 ("icon.svg", true),
830 ("app.log", true),
831 (".env", true),
832 ("Dockerfile", true),
833 ("Makefile", true),
834 ("LICENSE", true),
835 (".gitignore", true),
836 ("notes.txt", true),
837 ("image.png", false),
839 ("image.jpg", false),
840 ("image.jpeg", false),
841 ("image.gif", false),
842 ("image.webp", false),
843 ("archive.zip", false),
844 ("archive.tar", false),
845 ("archive.gz", false),
846 ("binary.exe", false),
847 ("library.so", false),
848 ("library.dll", false),
849 ("document.pdf", false),
850 ("audio.mp3", false),
851 ("video.mp4", false),
852 ("font.ttf", false),
853 ("font.woff", false),
854 ];
855
856 for (filename, expected) in cases {
858 let spath = SPath::new(*filename);
859 let result = spath.is_likely_text();
860 assert_eq!(
861 result, *expected,
862 "is_likely_text({filename:?}) expected {expected} but got {result}"
863 );
864 }
865 }
866}
867
868