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, Eq, PartialEq, Hash)]
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	/// Returns the relative difference from `base` to this path as an [`SPath`].
346	///
347	/// This delegates to [`pathdiff::diff_utf8_paths`], so it never touches the file system and
348	/// simply subtracts `base` from `self` when `base` is a prefix.
349	/// The returned value preserves the crate-level normalization guarantees and can safely be
350	/// joined back onto `base`.
351	///
352	/// Returns `None` when the inputs cannot be related through a relative path (for example,
353	/// when they reside on different volumes or when normalization prevents a clean prefix match).
354	///
355	/// # Examples
356	/// ```
357	/// # use simple_fs::SPath;
358	/// let base = SPath::new("/workspace/project");
359	/// let file = SPath::new("/workspace/project/src/main.rs");
360	/// assert_eq!(file.diff(&base).map(|p| p.to_string()), Some("src/main.rs".into()));
361	/// ```
362	pub fn diff(&self, base: impl AsRef<Utf8Path>) -> Option<SPath> {
363		let base = base.as_ref();
364
365		let diff_path = diff_utf8_paths(self, base);
366
367		diff_path.map(SPath::from)
368	}
369
370	/// Returns the relative path from `base` to this path or an [`Error::CannotDiff`].
371	///
372	/// This is a fallible counterpart to [`SPath::diff`]. When the paths share a common prefix it
373	/// returns the diff, otherwise it raises [`Error::CannotDiff`] containing the original inputs,
374	/// making failures descriptive.
375	///
376	/// The computation still delegates to [`pathdiff::diff_utf8_paths`], so no filesystem access
377	/// occurs and the resulting [`SPath`] keeps its normalization guarantees.
378	///
379	/// # Errors
380	/// Returns [`Error::CannotDiff`] when `base` is not a prefix of `self` (for example, when the
381	/// inputs live on different volumes).
382	pub fn try_diff(&self, base: impl AsRef<Utf8Path>) -> Result<SPath> {
383		self.diff(&base).ok_or_else(|| Error::CannotDiff {
384			path: self.to_string(),
385			base: base.as_ref().to_string(),
386		})
387	}
388
389	// endregion: --- Diff
390
391	// region:    --- Replace
392
393	pub fn replace_prefix(&self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
394		let base = base.as_ref();
395		let with = with.as_ref();
396		let s = self.as_str();
397		if let Some(stripped) = s.strip_prefix(base) {
398			// Avoid introducing double slashes (is with.is_empty() because do not want to add a / if empty)
399			let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
400				format!("{with}{stripped}")
401			} else {
402				format!("{with}/{stripped}")
403			};
404			SPath::new(joined)
405		} else {
406			self.clone()
407		}
408	}
409
410	pub fn into_replace_prefix(self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
411		let base = base.as_ref();
412		let with = with.as_ref();
413		let s = self.as_str();
414		if let Some(stripped) = s.strip_prefix(base) {
415			let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
416				format!("{with}{stripped}")
417			} else {
418				format!("{with}/{stripped}")
419			};
420			SPath::new(joined)
421		} else {
422			self
423		}
424	}
425
426	// endregion: --- Replace
427}
428
429/// Path/UTF8Path/Camino passthrough
430impl SPath {
431	pub fn as_std_path(&self) -> &Path {
432		self.std_path()
433	}
434
435	/// Returns a path that, when joined onto `base`, yields `self`.
436	///
437	/// # Errors
438	///
439	/// If `base` is not a prefix of `self`
440	pub fn strip_prefix(&self, prefix: impl AsRef<Path>) -> Result<SPath> {
441		let prefix = prefix.as_ref();
442		let new_path = self.path_buf.strip_prefix(prefix).map_err(|_| Error::StripPrefix {
443			prefix: prefix.to_string_lossy().to_string(),
444			path: self.to_string(),
445		})?;
446
447		Ok(new_path.into())
448	}
449
450	/// Determines whether `base` is a prefix of `self`.
451	///
452	/// Only considers whole path components to match.
453	///
454	/// # Examples
455	///
456	/// ```
457	/// use camino::Utf8Path;
458	///
459	/// let path = Utf8Path::new("/etc/passwd");
460	///
461	/// assert!(path.starts_with("/etc"));
462	/// assert!(path.starts_with("/etc/"));
463	/// assert!(path.starts_with("/etc/passwd"));
464	/// assert!(path.starts_with("/etc/passwd/")); // extra slash is okay
465	/// assert!(path.starts_with("/etc/passwd///")); // multiple extra slashes are okay
466	///
467	/// assert!(!path.starts_with("/e"));
468	/// assert!(!path.starts_with("/etc/passwd.txt"));
469	///
470	/// assert!(!Utf8Path::new("/etc/foo.rs").starts_with("/etc/foo"));
471	/// ```
472	pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
473		self.path_buf.starts_with(base)
474	}
475}
476
477/// Extensions
478impl SPath {
479	/// Consumes the SPath and returns one with the given extension ensured:
480	/// - Sets the extension if not already equal.
481	/// - Returns self if the extension is already present.
482	///
483	/// ## Params
484	/// - `ext` e.g. `html` (not . prefixed)
485	pub fn into_ensure_extension(mut self, ext: &str) -> Self {
486		if self.extension() != Some(ext) {
487			self.path_buf.set_extension(ext);
488		}
489		self
490	}
491
492	/// Returns a new SPath with the given extension ensured.
493	///
494	/// - Since this takes a reference, it will return a Clone no matter what.
495	/// - Use [`into_ensure_extension`] to consume and create a new SPath only if needed.
496	///
497	/// Delegates to `into_ensure_extension`.
498	///
499	/// ## Params
500	/// - `ext` e.g. `html` (not . prefixed)
501	pub fn ensure_extension(&self, ext: &str) -> Self {
502		self.clone().into_ensure_extension(ext)
503	}
504
505	/// Appends the extension, even if one already exists or is the same.
506	///
507	/// ## Params
508	/// - `ext` e.g. `html` (not . prefixed)
509	pub fn append_extension(&self, ext: &str) -> Self {
510		SPath::new(format!("{self}.{ext}"))
511	}
512}
513
514/// Other
515impl SPath {
516	/// Returns a new SPath for the eventual directory before the first glob expression.
517	///
518	/// If not a glob, will return none
519	///
520	/// ## Examples
521	/// - `/some/path/**/src/*.rs` → `/some/path`
522	/// - `**/src/*.rs` → `""`
523	/// - `/some/{src,doc}/**/*` → `/some`
524	pub fn dir_before_glob(&self) -> Option<SPath> {
525		let path_str = self.as_str();
526		let mut last_slash_idx = None;
527
528		for (i, c) in path_str.char_indices() {
529			if c == '/' {
530				last_slash_idx = Some(i);
531			} else if matches!(c, '*' | '?' | '[' | '{') {
532				return Some(SPath::from(&path_str[..last_slash_idx.unwrap_or(0)]));
533			}
534		}
535
536		None
537	}
538}
539
540// region:    --- Std Traits Impls
541
542impl fmt::Display for SPath {
543	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
544		write!(f, "{}", self.as_str())
545	}
546}
547
548// endregion: --- Std Traits Impls
549
550// region:    --- AsRefs
551
552impl AsRef<SPath> for SPath {
553	fn as_ref(&self) -> &SPath {
554		self
555	}
556}
557
558impl AsRef<Path> for SPath {
559	fn as_ref(&self) -> &Path {
560		self.path_buf.as_ref()
561	}
562}
563
564impl AsRef<Utf8Path> for SPath {
565	fn as_ref(&self) -> &Utf8Path {
566		self.path_buf.as_ref()
567	}
568}
569
570impl AsRef<str> for SPath {
571	fn as_ref(&self) -> &str {
572		self.as_str()
573	}
574}
575
576// endregion: --- AsRefs
577
578// region:    --- Froms (into other types)
579
580impl From<SPath> for String {
581	fn from(val: SPath) -> Self {
582		val.as_str().to_string()
583	}
584}
585
586impl From<&SPath> for String {
587	fn from(val: &SPath) -> Self {
588		val.as_str().to_string()
589	}
590}
591
592impl From<SPath> for PathBuf {
593	fn from(val: SPath) -> Self {
594		val.into_std_path_buf()
595	}
596}
597
598impl From<&SPath> for PathBuf {
599	fn from(val: &SPath) -> Self {
600		val.path_buf.clone().into()
601	}
602}
603
604impl From<SPath> for Utf8PathBuf {
605	fn from(val: SPath) -> Self {
606		val.path_buf
607	}
608}
609
610// endregion: --- Froms (into other types)
611
612// region:    --- Froms
613
614impl From<Utf8PathBuf> for SPath {
615	fn from(path_buf: Utf8PathBuf) -> Self {
616		SPath::new(path_buf)
617	}
618}
619
620impl From<&Utf8Path> for SPath {
621	fn from(path: &Utf8Path) -> Self {
622		SPath::new(path)
623	}
624}
625
626impl From<String> for SPath {
627	fn from(path: String) -> Self {
628		SPath::new(path)
629	}
630}
631
632impl From<&String> for SPath {
633	fn from(path: &String) -> Self {
634		SPath::new(path)
635	}
636}
637
638impl From<&str> for SPath {
639	fn from(path: &str) -> Self {
640		SPath::new(path)
641	}
642}
643
644// endregion: --- Froms
645
646// region:    --- TryFrom
647
648impl TryFrom<PathBuf> for SPath {
649	type Error = Error;
650	fn try_from(path_buf: PathBuf) -> Result<SPath> {
651		SPath::from_std_path_buf(path_buf)
652	}
653}
654
655impl TryFrom<fs::DirEntry> for SPath {
656	type Error = Error;
657	fn try_from(fs_entry: fs::DirEntry) -> Result<SPath> {
658		SPath::from_std_path_buf(fs_entry.path())
659	}
660}
661
662impl TryFrom<walkdir::DirEntry> for SPath {
663	type Error = Error;
664	fn try_from(wd_entry: walkdir::DirEntry) -> Result<SPath> {
665		SPath::from_std_path(wd_entry.path())
666	}
667}
668
669// endregion: --- TryFrom
670
671// region:    --- Path Validation
672
673pub(crate) fn validate_spath_for_result(path: impl Into<PathBuf>) -> Result<Utf8PathBuf> {
674	let path = path.into();
675	let path_buf =
676		Utf8PathBuf::from_path_buf(path).map_err(|err| Error::PathNotUtf8(err.to_string_lossy().to_string()))?;
677	Ok(path_buf)
678}
679
680/// Validate but without generating an error (good for the _ok constructors)
681pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
682	Utf8PathBuf::from_path_buf(path.into()).ok()
683}
684
685// endregion: --- Path Validation