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::{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/// 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	/// Returns the path.metadata modified SystemTime
311	///
312	#[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	/// Returns the epoch duration in microseconds.
323	/// Note: The maximum UTC date would be approximately `2262-04-11`.
324	///       Thus, for all intents and purposes, it is far enough to not worry.
325	#[deprecated = "use spath.meta()"]
326	pub fn modified_us(&self) -> Result<i64> {
327		Ok(self.meta()?.modified_epoch_us)
328	}
329}
330
331/// Transformers
332impl SPath {
333	/// This perform a OS Canonicalization.
334	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	// region:    --- Collapse
343
344	/// Collapse a path without performing I/O.
345	///
346	/// All redundant separator and up-level references are collapsed.
347	///
348	/// However, this does not resolve links.
349	pub fn collapse(&self) -> SPath {
350		let path_buf = crate::into_collapsed(self.path_buf.clone());
351		SPath::new(path_buf)
352	}
353
354	/// Same as [`collapse`] but consume and create a new SPath only if needed
355	pub fn into_collapsed(self) -> SPath {
356		if self.is_collapsed() { self } else { self.collapse() }
357	}
358
359	/// Return `true` if the path is collapsed.
360	///
361	/// # Quirk
362	///
363	/// If the path does not start with `./` but contains `./` in the middle,
364	/// then this function might returns `true`.
365	pub fn is_collapsed(&self) -> bool {
366		crate::is_collapsed(self)
367	}
368
369	// endregion: --- Collapse
370
371	// region:    --- Parent & Join
372
373	/// Returns the parent directory as an Option<SPath>.
374	pub fn parent(&self) -> Option<SPath> {
375		self.path_buf.parent().map(SPath::from)
376	}
377
378	/// Returns a new SPath with the given suffix appended to the filename (after the eventual extension)
379	///
380	/// Use [`join`] to join path segments.
381	///
382	/// Example:
383	/// - `foo.rs` + `_backup` → `foo.rs_backup`
384	pub fn append_suffix(&self, suffix: &str) -> SPath {
385		SPath::new(format!("{self}{suffix}"))
386	}
387
388	/// Joins the provided path with the current path and returns an SPath.
389	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	/// Joins a standard Path to the path of this SPath.
395	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	/// Creates a new sibling SPath with the given leaf_path.
403	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	/// Creates a new sibling SPath with the given standard path.
412	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	// endregion: --- Parent & Join
422
423	// region:    --- Diff
424
425	/// Returns the relative difference from `base` to this path as an [`SPath`].
426	///
427	/// This delegates to [`pathdiff::diff_utf8_paths`], so it never touches the file system and
428	/// simply subtracts `base` from `self` when `base` is a prefix.
429	/// The returned value preserves the crate-level normalization guarantees and can safely be
430	/// joined back onto `base`.
431	///
432	/// Returns `None` when the inputs cannot be related through a relative path (for example,
433	/// when they reside on different volumes or when normalization prevents a clean prefix match).
434	///
435	/// # Examples
436	/// ```
437	/// # use simple_fs::SPath;
438	/// let base = SPath::new("/workspace/project");
439	/// let file = SPath::new("/workspace/project/src/main.rs");
440	/// assert_eq!(file.diff(&base).map(|p| p.to_string()), Some("src/main.rs".into()));
441	/// ```
442	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	/// Returns the relative path from `base` to this path or an [`Error::CannotDiff`].
451	///
452	/// This is a fallible counterpart to [`SPath::diff`]. When the paths share a common prefix it
453	/// returns the diff, otherwise it raises [`Error::CannotDiff`] containing the original inputs,
454	/// making failures descriptive.
455	///
456	/// The computation still delegates to [`pathdiff::diff_utf8_paths`], so no filesystem access
457	/// occurs and the resulting [`SPath`] keeps its normalization guarantees.
458	///
459	/// # Errors
460	/// Returns [`Error::CannotDiff`] when `base` is not a prefix of `self` (for example, when the
461	/// inputs live on different volumes).
462	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	// endregion: --- Diff
470
471	// region:    --- Replace
472
473	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			// Avoid introducing double slashes (is with.is_empty() because do not want to add a / if empty)
479			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	// endregion: --- Replace
507}
508
509/// Path/UTF8Path/Camino passthrough
510impl SPath {
511	pub fn as_std_path(&self) -> &Path {
512		self.std_path()
513	}
514
515	/// Returns a path that, when joined onto `base`, yields `self`.
516	///
517	/// # Errors
518	///
519	/// If `base` is not a prefix of `self`
520	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	/// Determines whether `base` is a prefix of `self`.
531	///
532	/// Only considers whole path components to match.
533	///
534	/// # Examples
535	///
536	/// ```
537	/// use camino::Utf8Path;
538	///
539	/// let path = Utf8Path::new("/etc/passwd");
540	///
541	/// assert!(path.starts_with("/etc"));
542	/// assert!(path.starts_with("/etc/"));
543	/// assert!(path.starts_with("/etc/passwd"));
544	/// assert!(path.starts_with("/etc/passwd/")); // extra slash is okay
545	/// assert!(path.starts_with("/etc/passwd///")); // multiple extra slashes are okay
546	///
547	/// assert!(!path.starts_with("/e"));
548	/// assert!(!path.starts_with("/etc/passwd.txt"));
549	///
550	/// assert!(!Utf8Path::new("/etc/foo.rs").starts_with("/etc/foo"));
551	/// ```
552	pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
553		self.path_buf.starts_with(base)
554	}
555}
556
557/// Extensions
558impl SPath {
559	/// Consumes the SPath and returns one with the given extension ensured:
560	/// - Sets the extension if not already equal.
561	/// - Returns self if the extension is already present.
562	///
563	/// ## Params
564	/// - `ext` e.g. `html` (not . prefixed)
565	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	/// Returns a new SPath with the given extension ensured.
573	///
574	/// - Since this takes a reference, it will return a Clone no matter what.
575	/// - Use [`into_ensure_extension`] to consume and create a new SPath only if needed.
576	///
577	/// Delegates to `into_ensure_extension`.
578	///
579	/// ## Params
580	/// - `ext` e.g. `html` (not . prefixed)
581	pub fn ensure_extension(&self, ext: &str) -> Self {
582		self.clone().into_ensure_extension(ext)
583	}
584
585	/// Appends the extension, even if one already exists or is the same.
586	///
587	/// ## Params
588	/// - `ext` e.g. `html` (not . prefixed)
589	pub fn append_extension(&self, ext: &str) -> Self {
590		SPath::new(format!("{self}.{ext}"))
591	}
592}
593
594/// Other
595impl SPath {
596	/// Returns a new SPath for the eventual directory before the first glob expression.
597	///
598	/// If not a glob, will return none
599	///
600	/// ## Examples
601	/// - `/some/path/**/src/*.rs` → `/some/path`
602	/// - `**/src/*.rs` → `""`
603	/// - `/some/{src,doc}/**/*` → `/some`
604	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
620// region:    --- Std Traits Impls
621
622impl fmt::Display for SPath {
623	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
624		write!(f, "{}", self.as_str())
625	}
626}
627
628// endregion: --- Std Traits Impls
629
630// region:    --- AsRefs
631
632impl 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
656// endregion: --- AsRefs
657
658// region:    --- Froms (into other types)
659
660impl 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
690// endregion: --- Froms (into other types)
691
692// region:    --- Froms
693
694impl 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
730// endregion: --- Froms
731
732// region:    --- TryFrom
733
734impl 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
755// endregion: --- TryFrom
756
757// region:    --- Path Validation
758
759pub(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
766/// Validate but without generating an error (good for the _ok constructors)
767pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
768	Utf8PathBuf::from_path_buf(path.into()).ok()
769}
770
771// endregion: --- Path Validation
772
773// region:    --- Tests
774
775#[cfg(test)]
776mod tests {
777	use super::*;
778
779	#[test]
780	fn test_spath_is_likely_text() {
781		// -- Setup & Fixtures
782		let cases: &[(&str, bool)] = &[
783			// known text extensions
784			("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			// binary / non-text extensions
838			("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		// -- Exec & Check
857		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// endregion: --- Tests