Skip to main content

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::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/// - It does not collapse `..` segments by default, use collapse APIs for that
14/// - Garanteed to be UTF8
15#[derive(Debug, Clone, Eq, PartialEq, Hash)]
16pub struct SPath {
17	pub(crate) path_buf: Utf8PathBuf,
18}
19
20/// Constructors that guarantee the SPath contract described in the struct
21impl SPath {
22	/// Constructor for SPath accepting anything that implements Into<Utf8PathBuf>.
23	/// IMPORTANT: This will normalize the path (posix style), but does not collapse `..`
24	/// segments. Use collapse APIs when collapse behavior is desired.
25	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	/// Constructor from standard PathBuf.
32	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	/// Constructor from standard Path and all impl AsRef<Path>.
38	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	/// Constructor from walkdir::DirEntry
45	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	/// Constructor from fs::DirEntry.
52	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	/// Constructor for anything that implements AsRef<Path>.
59	///
60	/// Returns Option<SPath>. Useful for filter_map.
61	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	/// Constructed from PathBuf returns an Option, none if validation fails.
68	/// Useful for filter_map.
69	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	/// Constructor from fs::DirEntry returning an Option, none if validation fails.
75	/// Useful for filter_map.
76	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	/// Constructor from walkdir::DirEntry returning an Option, none if validation fails.
83	/// Useful for filter_map.
84	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
90/// Public into path
91impl SPath {
92	/// Consumes the SPath and returns its PathBuf.
93	pub fn into_std_path_buf(self) -> PathBuf {
94		self.path_buf.into()
95	}
96
97	/// Returns a reference to the internal std Path.
98	pub fn std_path(&self) -> &Path {
99		self.path_buf.as_std_path()
100	}
101
102	/// Returns a reference to the internal Utf8Path.
103	pub fn path(&self) -> &Utf8Path {
104		&self.path_buf
105	}
106}
107
108/// Public getters
109impl SPath {
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/// Mime
187impl SPath {
188	/// Returns the mime type as a &str if found.
189	///
190	/// This uses `mime_guess` under the hood.
191	pub fn mime_type(&self) -> Option<&'static str> {
192		mime_guess::from_path(self.path()).first_raw()
193	}
194
195	/// Returns true if the path is likely a text type.
196	///
197	/// This includes `text/*`, `application/json`, `application/javascript`,
198	/// `application/xml`, `application/toml`, `image/svg+xml`, known text extensions, etc.
199	pub fn is_likely_text(&self) -> bool {
200		// -- Check known text extensions first (fast path, covers gaps in mime_guess)
201		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		// -- Get the mime type and return if found
231		let mimes = mime_guess::from_path(self.path());
232		if mimes.is_empty() {
233			return true;
234		}
235
236		// -- Fall back to mime type detection
237		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
266/// Meta
267impl SPath {
268	/// Get a Simple Metadata structure `SMeta` with
269	/// created_epoch_us, modified_epoch_us, and size (all i64)
270	/// (size will be '0' for any none file)
271	#[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		// -- Get modified (failed if it cannot)
278		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		// -- Get created (If not found, will get modified)
286		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		// -- Get size
294		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	/// Returns the std metadata
306	pub fn metadata(&self) -> Result<Metadata> {
307		fs::metadata(self).map_err(|ex| Error::CantGetMetadata((self, ex).into()))
308	}
309
310}
311
312/// Transformers
313impl SPath {
314	/// This perform a OS Canonicalization.
315	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	// region:    --- Collapse
324
325	/// Collapse a path without performing I/O.
326	///
327	/// All redundant separator and up-level references are collapsed.
328	///
329	/// However, this does not resolve links.
330	pub fn collapse(&self) -> SPath {
331		let path_buf = crate::into_collapsed(self.path_buf.clone());
332		SPath::new(path_buf)
333	}
334
335	/// Same as [`collapse`] but consume and create a new SPath only if needed
336	pub fn into_collapsed(self) -> SPath {
337		if self.is_collapsed() { self } else { self.collapse() }
338	}
339
340	/// Return `true` if the path is collapsed.
341	///
342	/// # Quirk
343	///
344	/// If the path does not start with `./` but contains `./` in the middle,
345	/// then this function might returns `true`.
346	pub fn is_collapsed(&self) -> bool {
347		crate::is_collapsed(self)
348	}
349
350	// endregion: --- Collapse
351
352	// region:    --- Parent & Join
353
354	/// Returns the parent directory as an Option<SPath>.
355	pub fn parent(&self) -> Option<SPath> {
356		self.path_buf.parent().map(SPath::from)
357	}
358
359	/// Returns a new SPath with the given suffix appended to the filename (after the eventual extension)
360	///
361	/// Use [`join`] to join path segments.
362	///
363	/// Example:
364	/// - `foo.rs` + `_backup` → `foo.rs_backup`
365	pub fn append_suffix(&self, suffix: &str) -> SPath {
366		SPath::new(format!("{self}{suffix}"))
367	}
368
369	/// Joins the provided path with the current path and returns an SPath.
370	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	/// Joins a standard Path to the path of this SPath.
376	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	/// Creates a new sibling SPath with the given leaf_path.
384	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	/// Creates a new sibling SPath with the given standard path.
393	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	// endregion: --- Parent & Join
403
404	// region:    --- Diff
405
406	/// Returns the relative difference from `base` to this path as an [`SPath`].
407	///
408	/// This delegates to [`pathdiff::diff_utf8_paths`], so it never touches the file system and
409	/// simply subtracts `base` from `self` when `base` is a prefix.
410	/// The returned value preserves the crate-level normalization guarantees and can safely be
411	/// joined back onto `base`.
412	///
413	/// Returns `None` when the inputs cannot be related through a relative path (for example,
414	/// when they reside on different volumes or when normalization prevents a clean prefix match).
415	///
416	/// # Examples
417	/// ```
418	/// # use simple_fs::SPath;
419	/// let base = SPath::new("/workspace/project");
420	/// let file = SPath::new("/workspace/project/src/main.rs");
421	/// assert_eq!(file.diff(&base).map(|p| p.to_string()), Some("src/main.rs".into()));
422	/// ```
423	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	/// Returns the relative path from `base` to this path or an [`Error::CannotDiff`].
432	///
433	/// This is a fallible counterpart to [`SPath::diff`]. When the paths share a common prefix it
434	/// returns the diff, otherwise it raises [`Error::CannotDiff`] containing the original inputs,
435	/// making failures descriptive.
436	///
437	/// The computation still delegates to [`pathdiff::diff_utf8_paths`], so no filesystem access
438	/// occurs and the resulting [`SPath`] keeps its normalization guarantees.
439	///
440	/// # Errors
441	/// Returns [`Error::CannotDiff`] when `base` is not a prefix of `self` (for example, when the
442	/// inputs live on different volumes).
443	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	// endregion: --- Diff
451
452	// region:    --- Replace
453
454	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			// Avoid introducing double slashes (is with.is_empty() because do not want to add a / if empty)
460			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	// endregion: --- Replace
488}
489
490/// Path/UTF8Path/Camino passthrough
491impl SPath {
492	pub fn as_std_path(&self) -> &Path {
493		self.std_path()
494	}
495
496	/// Returns a path that, when joined onto `base`, yields `self`.
497	///
498	/// # Errors
499	///
500	/// If `base` is not a prefix of `self`
501	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	/// Determines whether `base` is a prefix of `self`.
512	///
513	/// Only considers whole path components to match.
514	///
515	/// # Examples
516	///
517	/// ```
518	/// use camino::Utf8Path;
519	///
520	/// let path = Utf8Path::new("/etc/passwd");
521	///
522	/// assert!(path.starts_with("/etc"));
523	/// assert!(path.starts_with("/etc/"));
524	/// assert!(path.starts_with("/etc/passwd"));
525	/// assert!(path.starts_with("/etc/passwd/")); // extra slash is okay
526	/// assert!(path.starts_with("/etc/passwd///")); // multiple extra slashes are okay
527	///
528	/// assert!(!path.starts_with("/e"));
529	/// assert!(!path.starts_with("/etc/passwd.txt"));
530	///
531	/// assert!(!Utf8Path::new("/etc/foo.rs").starts_with("/etc/foo"));
532	/// ```
533	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
542/// Extensions
543impl SPath {
544	/// Consumes the SPath and returns one with the given extension ensured:
545	/// - Sets the extension if not already equal.
546	/// - Returns self if the extension is already present.
547	///
548	/// ## Params
549	/// - `ext` e.g. `html` (not . prefixed)
550	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	/// Returns a new SPath with the given extension ensured.
558	///
559	/// - Since this takes a reference, it will return a Clone no matter what.
560	/// - Use [`into_ensure_extension`] to consume and create a new SPath only if needed.
561	///
562	/// Delegates to `into_ensure_extension`.
563	///
564	/// ## Params
565	/// - `ext` e.g. `html` (not . prefixed)
566	pub fn ensure_extension(&self, ext: &str) -> Self {
567		self.clone().into_ensure_extension(ext)
568	}
569
570	/// Appends the extension, even if one already exists or is the same.
571	///
572	/// ## Params
573	/// - `ext` e.g. `html` (not . prefixed)
574	pub fn append_extension(&self, ext: &str) -> Self {
575		SPath::new(format!("{self}.{ext}"))
576	}
577}
578
579/// Other
580impl SPath {
581	/// Returns a new SPath for the eventual directory before the first glob expression.
582	///
583	/// If not a glob, will return none
584	///
585	/// ## Examples
586	/// - `/some/path/**/src/*.rs` → `/some/path`
587	/// - `**/src/*.rs` → `""`
588	/// - `/some/{src,doc}/**/*` → `/some`
589	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
605// region:    --- Std Traits Impls
606
607impl fmt::Display for SPath {
608	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
609		write!(f, "{}", self.as_str())
610	}
611}
612
613// endregion: --- Std Traits Impls
614
615// region:    --- AsRefs
616
617impl 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
641// endregion: --- AsRefs
642
643// region:    --- Froms (into other types)
644
645impl 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
675// endregion: --- Froms (into other types)
676
677// region:    --- Froms
678
679impl 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
715// endregion: --- Froms
716
717// region:    --- TryFrom
718
719impl 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
740// endregion: --- TryFrom
741
742// region:    --- Path Validation
743
744pub(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
751/// Validate but without generating an error (good for the _ok constructors)
752pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
753	Utf8PathBuf::from_path_buf(path.into()).ok()
754}
755
756// endregion: --- Path Validation
757
758// region:    --- Tests
759
760#[cfg(test)]
761mod tests {
762	use super::*;
763
764	#[test]
765	fn test_spath_is_likely_text() {
766		// -- Setup & Fixtures
767		let cases: &[(&str, bool)] = &[
768			// known text extensions
769			("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			// binary / non-text extensions
823			("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		// -- Exec & Check
842		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// endregion: --- Tests