simple_fs/
spath.rs

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/// An SPath is a posix normalized Path using camino Utf8PathBuf as strogate.
10/// It can be constructed from a String, Path, io::DirEntry, or walkdir::DirEntry
11///
12/// - It's Posix normalized `/`, all redundant `//` and `/./` are removed
13/// - Garanteed to be UTF8
14#[derive(Debug, Clone)]
15pub struct SPath {
16	pub(crate) path_buf: Utf8PathBuf,
17}
18
19/// Constructors that guarantee the SPath contract described in the struct
20impl SPath {
21	/// Constructor for SPath accepting anything that implements Into<Utf8PathBuf>.
22	/// IMPORTANT: This will normalize the path (posix style
23	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	/// Constructor from standard PathBuf.
30	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	/// Constructor from standard Path and all impl AsRef<Path>.
36	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	/// Constructor from walkdir::DirEntry
43	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	/// Constructor for anything that implements AsRef<Path>.
50	///
51	/// Returns Option<SPath>. Useful for filter_map.
52	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	/// Constructed from PathBuf returns an Option, none if validation fails.
59	/// Useful for filter_map.
60	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	/// Constructor from fs::DirEntry returning an Option, none if validation fails.
66	/// Useful for filter_map.
67	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	/// Constructor from walkdir::DirEntry returning an Option, none if validation fails.
74	/// Useful for filter_map.
75	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
81/// Public into path
82impl SPath {
83	/// Consumes the SPath and returns its PathBuf.
84	pub fn into_std_path_buf(self) -> PathBuf {
85		self.path_buf.into()
86	}
87
88	/// Returns a reference to the internal std Path.
89	pub fn std_path(&self) -> &Path {
90		self.path_buf.as_std_path()
91	}
92
93	/// Returns a reference to the internal Utf8Path.
94	pub fn path(&self) -> &Utf8Path {
95		&self.path_buf
96	}
97}
98
99/// Public getters
100impl SPath {
101	/// Returns the &str of the path.
102	///
103	/// NOTE: We know that this must be Some() since the SPath constructor guarantees that
104	///       the path.as_str() is valid.
105	#[deprecated(note = "use as_str()")]
106	pub fn to_str(&self) -> &str {
107		self.path_buf.as_str()
108	}
109
110	/// Returns the &str of the path.
111	pub fn as_str(&self) -> &str {
112		self.path_buf.as_str()
113	}
114
115	/// Returns the Option<&str> representation of the `path.file_name()`
116	///
117	pub fn file_name(&self) -> Option<&str> {
118		self.path_buf.file_name()
119	}
120
121	/// Returns the &str representation of the `path.file_name()`
122	///
123	/// Note: If no file name will be an empty string
124	pub fn name(&self) -> &str {
125		self.file_name().unwrap_or_default()
126	}
127
128	/// Returns the parent name, and empty static &str if no present
129	pub fn parent_name(&self) -> &str {
130		self.path_buf.parent().and_then(|p| p.file_name()).unwrap_or_default()
131	}
132
133	/// Returns the Option<&str> representation of the file_stem()
134	///
135	/// Note: if the `OsStr` cannot be made into utf8 will be None
136	pub fn file_stem(&self) -> Option<&str> {
137		self.path_buf.file_stem()
138	}
139
140	/// Returns the &str representation of the `file_name()`
141	///
142	/// Note: If no stem, will be an empty string
143	pub fn stem(&self) -> &str {
144		self.file_stem().unwrap_or_default()
145	}
146
147	/// Returns the Option<&str> representation of the extension().
148	///
149	/// NOTE: This should never be a non-UTF-8 string
150	///       as the path was validated during SPath construction.
151	pub fn extension(&self) -> Option<&str> {
152		self.path_buf.extension()
153	}
154
155	/// Returns the extension or "" if no extension
156	pub fn ext(&self) -> &str {
157		self.extension().unwrap_or_default()
158	}
159
160	/// Returns true if the path represents a directory.
161	pub fn is_dir(&self) -> bool {
162		self.path_buf.is_dir()
163	}
164
165	/// Returns true if the path represents a file.
166	pub fn is_file(&self) -> bool {
167		self.path_buf.is_file()
168	}
169
170	/// Checks if the path exists.
171	pub fn exists(&self) -> bool {
172		self.path_buf.exists()
173	}
174
175	/// Returns true if the internal path is absolute.
176	pub fn is_absolute(&self) -> bool {
177		self.path_buf.is_absolute()
178	}
179
180	/// Returns true if the internal path is relative.
181	pub fn is_relative(&self) -> bool {
182		self.path_buf.is_relative()
183	}
184}
185
186/// Meta
187impl SPath {
188	/// Get a Simple Metadata structure `SMeta` with
189	/// created_epoch_us, modified_epoch_us, and size (all i64)
190	/// (size will be '0' for any none file)
191	#[allow(clippy::fn_to_numeric_cast)]
192	pub fn meta(&self) -> Result<SMeta> {
193		let path = self;
194
195		let metadata = self.metadata()?;
196
197		// -- Get modified (failed if it cannot)
198		let modified = metadata.modified().map_err(|ex| Error::CantGetMetadata((path, ex).into()))?;
199		let modified_epoch_us: i64 = modified
200			.duration_since(UNIX_EPOCH)
201			.map_err(|ex| Error::CantGetMetadata((path, ex).into()))?
202			.as_micros()
203			.min(i64::max as u128) as i64;
204
205		// -- Get created (If not found, will get modified)
206		let created_epoch_us = metadata
207			.modified()
208			.ok()
209			.and_then(|c| c.duration_since(UNIX_EPOCH).ok())
210			.map(|c| c.as_micros().min(i64::max as u128) as i64);
211		let created_epoch_us = created_epoch_us.unwrap_or(modified_epoch_us);
212
213		// -- Get size
214		let size = if metadata.is_file() { metadata.len() } else { 0 };
215
216		Ok(SMeta {
217			created_epoch_us,
218			modified_epoch_us,
219			size,
220			is_file: metadata.is_file(),
221			is_dir: metadata.is_dir(),
222		})
223	}
224
225	/// Returns the std metadata
226	pub fn metadata(&self) -> Result<Metadata> {
227		fs::metadata(self).map_err(|ex| Error::CantGetMetadata((self, ex).into()))
228	}
229
230	/// Returns the path.metadata modified SystemTime
231	///
232	#[deprecated = "use spath.meta()"]
233	pub fn modified(&self) -> Result<SystemTime> {
234		let path = self.std_path();
235		let metadata = fs::metadata(path).map_err(|ex| Error::CantGetMetadata((path, ex).into()))?;
236		let last_modified = metadata
237			.modified()
238			.map_err(|ex| Error::CantGetMetadataModified((path, ex).into()))?;
239		Ok(last_modified)
240	}
241
242	/// Returns the epoch duration in microseconds.
243	/// Note: The maximum UTC date would be approximately `2262-04-11`.
244	///       Thus, for all intents and purposes, it is far enough to not worry.
245	#[deprecated = "use spath.meta()"]
246	pub fn modified_us(&self) -> Result<i64> {
247		Ok(self.meta()?.modified_epoch_us)
248	}
249}
250
251/// Transformers
252impl SPath {
253	/// This perform a OS Canonicalization.
254	pub fn canonicalize(&self) -> Result<SPath> {
255		let path = self
256			.path_buf
257			.canonicalize_utf8()
258			.map_err(|err| Error::CannotCanonicalize((self.std_path(), err).into()))?;
259		Ok(SPath::new(path))
260	}
261
262	// region:    --- Collapse
263
264	/// Collapse a path without performing I/O.
265	///
266	/// All redundant separator and up-level references are collapsed.
267	///
268	/// However, this does not resolve links.
269	pub fn collapse(&self) -> SPath {
270		let path_buf = crate::into_collapsed(self.path_buf.clone());
271		SPath::new(path_buf)
272	}
273
274	/// Same as [`collapse`] but consume and create a new SPath only if needed
275	pub fn into_collapsed(self) -> SPath {
276		if self.is_collapsed() { self } else { self.collapse() }
277	}
278
279	/// Return `true` if the path is collapsed.
280	///
281	/// # Quirk
282	///
283	/// If the path does not start with `./` but contains `./` in the middle,
284	/// then this function might returns `true`.
285	pub fn is_collapsed(&self) -> bool {
286		crate::is_collapsed(self)
287	}
288
289	// endregion: --- Collapse
290
291	// region:    --- Parent & Join
292
293	/// Returns the parent directory as an Option<SPath>.
294	pub fn parent(&self) -> Option<SPath> {
295		self.path_buf.parent().map(SPath::from)
296	}
297
298	/// Returns a new SPath with the given suffix appended to the filename (after the eventual extension)
299	///
300	/// Use [`join`] to join path segments.
301	///
302	/// Example:
303	/// - `foo.rs` + `_backup` → `foo.rs_backup`
304	pub fn append_suffix(&self, suffix: &str) -> SPath {
305		SPath::new(format!("{self}{suffix}"))
306	}
307
308	/// Joins the provided path with the current path and returns an SPath.
309	pub fn join(&self, leaf_path: impl Into<Utf8PathBuf>) -> SPath {
310		let path_buf = self.path_buf.join(leaf_path.into());
311		SPath::from(path_buf)
312	}
313
314	/// Joins a standard Path to the path of this SPath.
315	pub fn join_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
316		let leaf_path = leaf_path.as_ref();
317		let joined = self.std_path().join(leaf_path);
318		let path_buf = validate_spath_for_result(joined)?;
319		Ok(SPath::from(path_buf))
320	}
321
322	/// Creates a new sibling SPath with the given leaf_path.
323	pub fn new_sibling(&self, leaf_path: impl AsRef<str>) -> SPath {
324		let leaf_path = leaf_path.as_ref();
325		match self.path_buf.parent() {
326			Some(parent_dir) => SPath::new(parent_dir.join(leaf_path)),
327			None => SPath::new(leaf_path),
328		}
329	}
330
331	/// Creates a new sibling SPath with the given standard path.
332	pub fn new_sibling_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
333		let leaf_path = leaf_path.as_ref();
334
335		match self.std_path().parent() {
336			Some(parent_dir) => SPath::from_std_path(parent_dir.join(leaf_path)),
337			None => SPath::from_std_path(leaf_path),
338		}
339	}
340
341	// endregion: --- Parent & Join
342
343	// region:    --- Diff
344
345	pub fn diff(&self, base: impl AsRef<Utf8Path>) -> Option<SPath> {
346		let base = base.as_ref();
347
348		let diff_path = diff_utf8_paths(self, base);
349
350		diff_path.map(SPath::from)
351	}
352
353	pub fn try_diff(&self, base: impl AsRef<Utf8Path>) -> Result<SPath> {
354		self.diff(&base).ok_or_else(|| Error::CannotDiff {
355			path: self.to_string(),
356			base: base.as_ref().to_string(),
357		})
358	}
359
360	// endregion: --- Diff
361
362	// region:    --- Replace
363
364	pub fn replace_prefix(&self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
365		let base = base.as_ref();
366		let with = with.as_ref();
367		let s = self.as_str();
368		if let Some(stripped) = s.strip_prefix(base) {
369			// Avoid introducing double slashes (is with.is_empty() because do not want to add a / if empty)
370			let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
371				format!("{with}{stripped}")
372			} else {
373				format!("{with}/{stripped}")
374			};
375			SPath::new(joined)
376		} else {
377			self.clone()
378		}
379	}
380
381	pub fn into_replace_prefix(self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
382		let base = base.as_ref();
383		let with = with.as_ref();
384		let s = self.as_str();
385		if let Some(stripped) = s.strip_prefix(base) {
386			let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
387				format!("{with}{stripped}")
388			} else {
389				format!("{with}/{stripped}")
390			};
391			SPath::new(joined)
392		} else {
393			self
394		}
395	}
396
397	// endregion: --- Replace
398}
399
400/// Path/UTF8Path/Camino passthrough
401impl SPath {
402	pub fn as_std_path(&self) -> &Path {
403		self.std_path()
404	}
405
406	/// Returns a path that, when joined onto `base`, yields `self`.
407	///
408	/// # Errors
409	///
410	/// If `base` is not a prefix of `self`
411	pub fn strip_prefix(&self, prefix: impl AsRef<Path>) -> Result<SPath> {
412		let prefix = prefix.as_ref();
413		let new_path = self.path_buf.strip_prefix(prefix).map_err(|_| Error::StripPrefix {
414			prefix: prefix.to_string_lossy().to_string(),
415			path: self.to_string(),
416		})?;
417
418		Ok(new_path.into())
419	}
420
421	/// Determines whether `base` is a prefix of `self`.
422	///
423	/// Only considers whole path components to match.
424	///
425	/// # Examples
426	///
427	/// ```
428	/// use camino::Utf8Path;
429	///
430	/// let path = Utf8Path::new("/etc/passwd");
431	///
432	/// assert!(path.starts_with("/etc"));
433	/// assert!(path.starts_with("/etc/"));
434	/// assert!(path.starts_with("/etc/passwd"));
435	/// assert!(path.starts_with("/etc/passwd/")); // extra slash is okay
436	/// assert!(path.starts_with("/etc/passwd///")); // multiple extra slashes are okay
437	///
438	/// assert!(!path.starts_with("/e"));
439	/// assert!(!path.starts_with("/etc/passwd.txt"));
440	///
441	/// assert!(!Utf8Path::new("/etc/foo.rs").starts_with("/etc/foo"));
442	/// ```
443	pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
444		self.path_buf.starts_with(base)
445	}
446}
447
448/// Extensions
449impl SPath {
450	/// Consumes the SPath and returns one with the given extension ensured:
451	/// - Sets the extension if not already equal.
452	/// - Returns self if the extension is already present.
453	///
454	/// ## Params
455	/// - `ext` e.g. `html` (not . prefixed)
456	pub fn into_ensure_extension(mut self, ext: &str) -> Self {
457		if self.extension() != Some(ext) {
458			self.path_buf.set_extension(ext);
459		}
460		self
461	}
462
463	/// Returns a new SPath with the given extension ensured.
464	///
465	/// - Since this takes a reference, it will return a Clone no matter what.
466	/// - Use [`into_ensure_extension`] to consume and create a new SPath only if needed.
467	///
468	/// Delegates to `into_ensure_extension`.
469	///
470	/// ## Params
471	/// - `ext` e.g. `html` (not . prefixed)
472	pub fn ensure_extension(&self, ext: &str) -> Self {
473		self.clone().into_ensure_extension(ext)
474	}
475
476	/// Appends the extension, even if one already exists or is the same.
477	///
478	/// ## Params
479	/// - `ext` e.g. `html` (not . prefixed)
480	pub fn append_extension(&self, ext: &str) -> Self {
481		SPath::new(format!("{self}.{ext}"))
482	}
483}
484
485/// Other
486impl SPath {
487	/// Returns a new SPath for the eventual directory before the first glob expression.
488	///
489	/// If not a glob, will return none
490	///
491	/// ## Examples
492	/// - `/some/path/**/src/*.rs` → `/some/path`
493	/// - `**/src/*.rs` → `""`
494	/// - `/some/{src,doc}/**/*` → `/some`
495	pub fn dir_before_glob(&self) -> Option<SPath> {
496		let path_str = self.as_str();
497		let mut last_slash_idx = None;
498
499		for (i, c) in path_str.char_indices() {
500			if c == '/' {
501				last_slash_idx = Some(i);
502			} else if matches!(c, '*' | '?' | '[' | '{') {
503				return Some(SPath::from(&path_str[..last_slash_idx.unwrap_or(0)]));
504			}
505		}
506
507		None
508	}
509}
510
511// region:    --- Std Traits Impls
512
513impl fmt::Display for SPath {
514	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
515		write!(f, "{}", self.as_str())
516	}
517}
518
519// endregion: --- Std Traits Impls
520
521// region:    --- AsRefs
522
523impl AsRef<SPath> for SPath {
524	fn as_ref(&self) -> &SPath {
525		self
526	}
527}
528
529impl AsRef<Path> for SPath {
530	fn as_ref(&self) -> &Path {
531		self.path_buf.as_ref()
532	}
533}
534
535impl AsRef<Utf8Path> for SPath {
536	fn as_ref(&self) -> &Utf8Path {
537		self.path_buf.as_ref()
538	}
539}
540
541impl AsRef<str> for SPath {
542	fn as_ref(&self) -> &str {
543		self.as_str()
544	}
545}
546
547// endregion: --- AsRefs
548
549// region:    --- Froms (into other types)
550
551impl From<SPath> for String {
552	fn from(val: SPath) -> Self {
553		val.as_str().to_string()
554	}
555}
556
557impl From<&SPath> for String {
558	fn from(val: &SPath) -> Self {
559		val.as_str().to_string()
560	}
561}
562
563impl From<SPath> for PathBuf {
564	fn from(val: SPath) -> Self {
565		val.into_std_path_buf()
566	}
567}
568
569impl From<&SPath> for PathBuf {
570	fn from(val: &SPath) -> Self {
571		val.path_buf.clone().into()
572	}
573}
574
575impl From<SPath> for Utf8PathBuf {
576	fn from(val: SPath) -> Self {
577		val.path_buf
578	}
579}
580
581// endregion: --- Froms (into other types)
582
583// region:    --- Froms
584
585impl From<Utf8PathBuf> for SPath {
586	fn from(path_buf: Utf8PathBuf) -> Self {
587		SPath::new(path_buf)
588	}
589}
590
591impl From<&Utf8Path> for SPath {
592	fn from(path: &Utf8Path) -> Self {
593		SPath::new(path)
594	}
595}
596
597impl From<String> for SPath {
598	fn from(path: String) -> Self {
599		SPath::new(path)
600	}
601}
602
603impl From<&String> for SPath {
604	fn from(path: &String) -> Self {
605		SPath::new(path)
606	}
607}
608
609impl From<&str> for SPath {
610	fn from(path: &str) -> Self {
611		SPath::new(path)
612	}
613}
614
615// endregion: --- Froms
616
617// region:    --- TryFrom
618
619impl TryFrom<PathBuf> for SPath {
620	type Error = Error;
621	fn try_from(path_buf: PathBuf) -> Result<SPath> {
622		SPath::from_std_path_buf(path_buf)
623	}
624}
625
626impl TryFrom<fs::DirEntry> for SPath {
627	type Error = Error;
628	fn try_from(fs_entry: fs::DirEntry) -> Result<SPath> {
629		SPath::from_std_path_buf(fs_entry.path())
630	}
631}
632
633impl TryFrom<walkdir::DirEntry> for SPath {
634	type Error = Error;
635	fn try_from(wd_entry: walkdir::DirEntry) -> Result<SPath> {
636		SPath::from_std_path(wd_entry.path())
637	}
638}
639
640// endregion: --- TryFrom
641
642// region:    --- Path Validation
643
644pub(crate) fn validate_spath_for_result(path: impl Into<PathBuf>) -> Result<Utf8PathBuf> {
645	let path = path.into();
646	let path_buf =
647		Utf8PathBuf::from_path_buf(path).map_err(|err| Error::PathNotUtf8(err.to_string_lossy().to_string()))?;
648	Ok(path_buf)
649}
650
651/// Validate but without generating an error (good for the _ok constructors)
652pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
653	Utf8PathBuf::from_path_buf(path.into()).ok()
654}
655
656// endregion: --- Path Validation