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						| "dart" | "py" | "rb"
217						| "go" | "java" | "c"
218						| "cpp" | "h" | "hpp"
219						| "sh" | "bash" | "zsh"
220						| "fish" | "php" | "lua"
221						| "ini" | "cfg" | "conf"
222						| "sql" | "graphql"
223						| "gql" | "svg" | "log"
224						| "env" | "tex"
225				);
226			if known_text_ext {
227				return true;
228			}
229		}
230
231		// -- Get the mime type and return if found
232		let mimes = mime_guess::from_path(self.path());
233		if mimes.is_empty() {
234			return true;
235		}
236
237		// -- Fall back to mime type detection
238		mimes.into_iter().any(|mime| {
239			let mime = mime.essence_str();
240			mime.starts_with("text/")
241				|| mime == "application/json"
242				|| mime == "application/javascript"
243				|| mime == "application/x-javascript"
244				|| mime == "application/ecmascript"
245				|| mime == "application/x-python"
246				|| mime == "application/xml"
247				|| mime == "application/toml"
248				|| mime == "application/x-toml"
249				|| mime == "application/x-yaml"
250				|| mime == "application/yaml"
251				|| mime == "application/sql"
252				|| mime == "application/graphql"
253				|| mime == "application/xml-dtd"
254				|| mime == "application/x-qml"
255				|| mime == "application/ini"
256				|| mime == "application/x-ini"
257				|| mime == "application/x-sh"
258				|| mime == "application/x-httpd-php"
259				|| mime == "application/x-lua"
260				|| mime == "application/vnd.dart"
261				|| mime.ends_with("+json")
262				|| mime.ends_with("+xml")
263				|| mime.ends_with("+yaml")
264		})
265	}
266}
267
268/// Meta
269impl SPath {
270	/// Get a Simple Metadata structure `SMeta` with
271	/// created_epoch_us, modified_epoch_us, and size (all i64)
272	/// (size will be '0' for any none file)
273	#[allow(clippy::fn_to_numeric_cast)]
274	pub fn meta(&self) -> Result<SMeta> {
275		let path = self;
276
277		let metadata = self.metadata()?;
278
279		// -- Get modified (failed if it cannot)
280		let modified = metadata.modified().map_err(|ex| Error::CantGetMetadata((path, ex).into()))?;
281		let modified_epoch_us: i64 = modified
282			.duration_since(UNIX_EPOCH)
283			.map_err(|ex| Error::CantGetMetadata((path, ex).into()))?
284			.as_micros()
285			.min(i64::MAX as u128) as i64;
286
287		// -- Get created (If not found, will get modified)
288		let created_epoch_us = metadata
289			.modified()
290			.ok()
291			.and_then(|c| c.duration_since(UNIX_EPOCH).ok())
292			.map(|c| c.as_micros().min(i64::MAX as u128) as i64);
293		let created_epoch_us = created_epoch_us.unwrap_or(modified_epoch_us);
294
295		// -- Get size
296		let size = if metadata.is_file() { metadata.len() } else { 0 };
297
298		Ok(SMeta {
299			created_epoch_us,
300			modified_epoch_us,
301			size,
302			is_file: metadata.is_file(),
303			is_dir: metadata.is_dir(),
304		})
305	}
306
307	/// Returns the std metadata
308	pub fn metadata(&self) -> Result<Metadata> {
309		fs::metadata(self).map_err(|ex| Error::CantGetMetadata((self, ex).into()))
310	}
311}
312
313/// Transformers
314impl SPath {
315	/// This perform a OS Canonicalization.
316	pub fn canonicalize(&self) -> Result<SPath> {
317		let path = self
318			.path_buf
319			.canonicalize_utf8()
320			.map_err(|err| Error::CannotCanonicalize((self.std_path(), err).into()))?;
321		Ok(SPath::new(path))
322	}
323
324	// region:    --- Collapse
325
326	/// Collapse a path without performing I/O.
327	///
328	/// All redundant separator and up-level references are collapsed.
329	///
330	/// However, this does not resolve links.
331	pub fn collapse(&self) -> SPath {
332		let path_buf = crate::into_collapsed(self.path_buf.clone());
333		SPath::new(path_buf)
334	}
335
336	/// Same as [`collapse`] but consume and create a new SPath only if needed
337	pub fn into_collapsed(self) -> SPath {
338		if self.is_collapsed() { self } else { self.collapse() }
339	}
340
341	/// Return `true` if the path is collapsed.
342	///
343	/// # Quirk
344	///
345	/// If the path does not start with `./` but contains `./` in the middle,
346	/// then this function might returns `true`.
347	pub fn is_collapsed(&self) -> bool {
348		crate::is_collapsed(self)
349	}
350
351	// endregion: --- Collapse
352
353	// region:    --- Parent & Join
354
355	/// Returns the parent directory as an Option<SPath>.
356	pub fn parent(&self) -> Option<SPath> {
357		self.path_buf.parent().map(SPath::from)
358	}
359
360	/// Returns a new SPath with the given suffix appended to the filename (after the eventual extension)
361	///
362	/// Use [`join`] to join path segments.
363	///
364	/// Example:
365	/// - `foo.rs` + `_backup` → `foo.rs_backup`
366	pub fn append_suffix(&self, suffix: &str) -> SPath {
367		SPath::new(format!("{self}{suffix}"))
368	}
369
370	/// Joins the provided path with the current path and returns an SPath.
371	pub fn join(&self, leaf_path: impl Into<Utf8PathBuf>) -> SPath {
372		let path_buf = self.path_buf.join(leaf_path.into());
373		SPath::from(path_buf)
374	}
375
376	/// Joins a standard Path to the path of this SPath.
377	pub fn join_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
378		let leaf_path = leaf_path.as_ref();
379		let joined = self.std_path().join(leaf_path);
380		let path_buf = validate_spath_for_result(joined)?;
381		Ok(SPath::from(path_buf))
382	}
383
384	/// Creates a new sibling SPath with the given leaf_path.
385	pub fn new_sibling(&self, leaf_path: impl AsRef<str>) -> SPath {
386		let leaf_path = leaf_path.as_ref();
387		match self.path_buf.parent() {
388			Some(parent_dir) => SPath::new(parent_dir.join(leaf_path)),
389			None => SPath::new(leaf_path),
390		}
391	}
392
393	/// Creates a new sibling SPath with the given standard path.
394	pub fn new_sibling_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
395		let leaf_path = leaf_path.as_ref();
396
397		match self.std_path().parent() {
398			Some(parent_dir) => SPath::from_std_path(parent_dir.join(leaf_path)),
399			None => SPath::from_std_path(leaf_path),
400		}
401	}
402
403	// endregion: --- Parent & Join
404
405	// region:    --- Diff
406
407	/// Returns the relative difference from `base` to this path as an [`SPath`].
408	///
409	/// This delegates to [`pathdiff::diff_utf8_paths`], so it never touches the file system and
410	/// simply subtracts `base` from `self` when `base` is a prefix.
411	/// The returned value preserves the crate-level normalization guarantees and can safely be
412	/// joined back onto `base`.
413	///
414	/// Returns `None` when the inputs cannot be related through a relative path (for example,
415	/// when they reside on different volumes or when normalization prevents a clean prefix match).
416	///
417	/// # Examples
418	/// ```
419	/// # use simple_fs::SPath;
420	/// let base = SPath::new("/workspace/project");
421	/// let file = SPath::new("/workspace/project/src/main.rs");
422	/// assert_eq!(file.diff(&base).map(|p| p.to_string()), Some("src/main.rs".into()));
423	/// ```
424	pub fn diff(&self, base: impl AsRef<Utf8Path>) -> Option<SPath> {
425		let base = base.as_ref();
426
427		let diff_path = diff_utf8_paths(self, base);
428
429		diff_path.map(SPath::from)
430	}
431
432	/// Returns the relative path from `base` to this path or an [`Error::CannotDiff`].
433	///
434	/// This is a fallible counterpart to [`SPath::diff`]. When the paths share a common prefix it
435	/// returns the diff, otherwise it raises [`Error::CannotDiff`] containing the original inputs,
436	/// making failures descriptive.
437	///
438	/// The computation still delegates to [`pathdiff::diff_utf8_paths`], so no filesystem access
439	/// occurs and the resulting [`SPath`] keeps its normalization guarantees.
440	///
441	/// # Errors
442	/// Returns [`Error::CannotDiff`] when `base` is not a prefix of `self` (for example, when the
443	/// inputs live on different volumes).
444	pub fn try_diff(&self, base: impl AsRef<Utf8Path>) -> Result<SPath> {
445		self.diff(&base).ok_or_else(|| Error::CannotDiff {
446			path: self.to_string(),
447			base: base.as_ref().to_string(),
448		})
449	}
450
451	// endregion: --- Diff
452
453	// region:    --- Replace
454
455	pub fn replace_prefix(&self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
456		let base = base.as_ref();
457		let with = with.as_ref();
458		let s = self.as_str();
459		if let Some(stripped) = s.strip_prefix(base) {
460			// Avoid introducing double slashes (is with.is_empty() because do not want to add a / if empty)
461			let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
462				format!("{with}{stripped}")
463			} else {
464				format!("{with}/{stripped}")
465			};
466			SPath::new(joined)
467		} else {
468			self.clone()
469		}
470	}
471
472	pub fn into_replace_prefix(self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
473		let base = base.as_ref();
474		let with = with.as_ref();
475		let s = self.as_str();
476		if let Some(stripped) = s.strip_prefix(base) {
477			let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
478				format!("{with}{stripped}")
479			} else {
480				format!("{with}/{stripped}")
481			};
482			SPath::new(joined)
483		} else {
484			self
485		}
486	}
487
488	// endregion: --- Replace
489}
490
491/// Path/UTF8Path/Camino passthrough
492impl SPath {
493	pub fn as_std_path(&self) -> &Path {
494		self.std_path()
495	}
496
497	/// Returns a path that, when joined onto `base`, yields `self`.
498	///
499	/// # Errors
500	///
501	/// If `base` is not a prefix of `self`
502	pub fn strip_prefix(&self, prefix: impl AsRef<str>) -> Result<SPath> {
503		let prefix = prefix.as_ref();
504		let new_path = self.path_buf.strip_prefix(prefix).map_err(|_| Error::StripPrefix {
505			prefix: prefix.to_string(),
506			path: self.to_string(),
507		})?;
508
509		Ok(new_path.into())
510	}
511
512	/// Determines whether `base` is a prefix of `self`.
513	///
514	/// Only considers whole path components to match.
515	///
516	/// # Examples
517	///
518	/// ```
519	/// use camino::Utf8Path;
520	///
521	/// let path = Utf8Path::new("/etc/passwd");
522	///
523	/// assert!(path.starts_with("/etc"));
524	/// assert!(path.starts_with("/etc/"));
525	/// assert!(path.starts_with("/etc/passwd"));
526	/// assert!(path.starts_with("/etc/passwd/")); // extra slash is okay
527	/// assert!(path.starts_with("/etc/passwd///")); // multiple extra slashes are okay
528	///
529	/// assert!(!path.starts_with("/e"));
530	/// assert!(!path.starts_with("/etc/passwd.txt"));
531	///
532	/// assert!(!Utf8Path::new("/etc/foo.rs").starts_with("/etc/foo"));
533	/// ```
534	pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
535		self.path_buf.starts_with(base)
536	}
537
538	pub fn starts_with_prefix(&self, base: impl AsRef<str>) -> bool {
539		self.path_buf.starts_with(base.as_ref())
540	}
541}
542
543/// Extensions
544impl SPath {
545	/// Consumes the SPath and returns one with the given extension ensured:
546	/// - Sets the extension if not already equal.
547	/// - Returns self if the extension is already present.
548	///
549	/// ## Params
550	/// - `ext` e.g. `html` (not . prefixed)
551	pub fn into_ensure_extension(mut self, ext: &str) -> Self {
552		if self.extension() != Some(ext) {
553			self.path_buf.set_extension(ext);
554		}
555		self
556	}
557
558	/// Returns a new SPath with the given extension ensured.
559	///
560	/// - Since this takes a reference, it will return a Clone no matter what.
561	/// - Use [`into_ensure_extension`] to consume and create a new SPath only if needed.
562	///
563	/// Delegates to `into_ensure_extension`.
564	///
565	/// ## Params
566	/// - `ext` e.g. `html` (not . prefixed)
567	pub fn ensure_extension(&self, ext: &str) -> Self {
568		self.clone().into_ensure_extension(ext)
569	}
570
571	/// Appends the extension, even if one already exists or is the same.
572	///
573	/// ## Params
574	/// - `ext` e.g. `html` (not . prefixed)
575	pub fn append_extension(&self, ext: &str) -> Self {
576		SPath::new(format!("{self}.{ext}"))
577	}
578}
579
580/// Other
581impl SPath {
582	/// Returns a new SPath for the eventual directory before the first glob expression.
583	///
584	/// If not a glob, will return none
585	///
586	/// ## Examples
587	/// - `/some/path/**/src/*.rs` → `/some/path`
588	/// - `**/src/*.rs` → `""`
589	/// - `/some/{src,doc}/**/*` → `/some`
590	pub fn dir_before_glob(&self) -> Option<SPath> {
591		let path_str = self.as_str();
592		let mut last_slash_idx = None;
593
594		for (i, c) in path_str.char_indices() {
595			if c == '/' {
596				last_slash_idx = Some(i);
597			} else if matches!(c, '*' | '?' | '[' | '{') {
598				return Some(SPath::from(&path_str[..last_slash_idx.unwrap_or(0)]));
599			}
600		}
601
602		None
603	}
604}
605
606// region:    --- Std Traits Impls
607
608impl fmt::Display for SPath {
609	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
610		write!(f, "{}", self.as_str())
611	}
612}
613
614// endregion: --- Std Traits Impls
615
616// region:    --- AsRefs
617
618impl AsRef<SPath> for SPath {
619	fn as_ref(&self) -> &SPath {
620		self
621	}
622}
623
624impl AsRef<Path> for SPath {
625	fn as_ref(&self) -> &Path {
626		self.path_buf.as_ref()
627	}
628}
629
630impl AsRef<Utf8Path> for SPath {
631	fn as_ref(&self) -> &Utf8Path {
632		self.path_buf.as_ref()
633	}
634}
635
636impl AsRef<str> for SPath {
637	fn as_ref(&self) -> &str {
638		self.as_str()
639	}
640}
641
642// endregion: --- AsRefs
643
644// region:    --- Froms (into other types)
645
646impl From<SPath> for String {
647	fn from(val: SPath) -> Self {
648		val.as_str().to_string()
649	}
650}
651
652impl From<&SPath> for String {
653	fn from(val: &SPath) -> Self {
654		val.as_str().to_string()
655	}
656}
657
658impl From<SPath> for PathBuf {
659	fn from(val: SPath) -> Self {
660		val.into_std_path_buf()
661	}
662}
663
664impl From<&SPath> for PathBuf {
665	fn from(val: &SPath) -> Self {
666		val.path_buf.clone().into()
667	}
668}
669
670impl From<SPath> for Utf8PathBuf {
671	fn from(val: SPath) -> Self {
672		val.path_buf
673	}
674}
675
676// endregion: --- Froms (into other types)
677
678// region:    --- Froms
679
680impl From<&SPath> for SPath {
681	fn from(path: &SPath) -> Self {
682		path.clone()
683	}
684}
685
686impl From<Utf8PathBuf> for SPath {
687	fn from(path_buf: Utf8PathBuf) -> Self {
688		SPath::new(path_buf)
689	}
690}
691
692impl From<&Utf8Path> for SPath {
693	fn from(path: &Utf8Path) -> Self {
694		SPath::new(path)
695	}
696}
697
698impl From<String> for SPath {
699	fn from(path: String) -> Self {
700		SPath::new(path)
701	}
702}
703
704impl From<&String> for SPath {
705	fn from(path: &String) -> Self {
706		SPath::new(path)
707	}
708}
709
710impl From<&str> for SPath {
711	fn from(path: &str) -> Self {
712		SPath::new(path)
713	}
714}
715
716// endregion: --- Froms
717
718// region:    --- TryFrom
719
720impl TryFrom<PathBuf> for SPath {
721	type Error = Error;
722	fn try_from(path_buf: PathBuf) -> Result<SPath> {
723		SPath::from_std_path_buf(path_buf)
724	}
725}
726
727impl TryFrom<fs::DirEntry> for SPath {
728	type Error = Error;
729	fn try_from(fs_entry: fs::DirEntry) -> Result<SPath> {
730		SPath::from_std_path_buf(fs_entry.path())
731	}
732}
733
734impl TryFrom<walkdir::DirEntry> for SPath {
735	type Error = Error;
736	fn try_from(wd_entry: walkdir::DirEntry) -> Result<SPath> {
737		SPath::from_std_path(wd_entry.path())
738	}
739}
740
741// endregion: --- TryFrom
742
743// region:    --- Path Validation
744
745pub(crate) fn validate_spath_for_result(path: impl Into<PathBuf>) -> Result<Utf8PathBuf> {
746	let path = path.into();
747	let path_buf =
748		Utf8PathBuf::from_path_buf(path).map_err(|err| Error::PathNotUtf8(err.to_string_lossy().to_string()))?;
749	Ok(path_buf)
750}
751
752/// Validate but without generating an error (good for the _ok constructors)
753pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
754	Utf8PathBuf::from_path_buf(path.into()).ok()
755}
756
757// endregion: --- Path Validation
758
759// region:    --- Tests
760
761#[cfg(test)]
762mod tests {
763	use super::*;
764
765	#[test]
766	fn test_spath_is_likely_text() {
767		// -- Setup & Fixtures
768		let cases: &[(&str, bool)] = &[
769			// known text extensions
770			("readme.md", true),
771			("readme.markdown", true),
772			("data.csv", true),
773			("config.toml", true),
774			("config.yaml", true),
775			("config.yml", true),
776			("data.json", true),
777			("data.jsonc", true),
778			("data.jsonl", true),
779			("data.ndjson", true),
780			("data.ldjson", true),
781			("doc.xml", true),
782			("page.html", true),
783			("page.htm", true),
784			("styles.css", true),
785			("styles.scss", true),
786			("styles.sass", true),
787			("styles.less", true),
788			("script.js", true),
789			("script.mjs", true),
790			("script.cjs", true),
791			("types.ts", true),
792			("component.tsx", true),
793			("component.jsx", true),
794			("main.rs", true),
795			("main.py", true),
796			("main.rb", true),
797			("main.go", true),
798			("Main.java", true),
799			("main.c", true),
800			("main.cpp", true),
801			("main.h", true),
802			("main.hpp", true),
803			("script.sh", true),
804			("script.bash", true),
805			("script.zsh", true),
806			("script.fish", true),
807			("index.php", true),
808			("script.lua", true),
809			("config.ini", true),
810			("config.cfg", true),
811			("config.conf", true),
812			("query.sql", true),
813			("schema.graphql", true),
814			("schema.gql", true),
815			("icon.svg", true),
816			("app.log", true),
817			(".env", true),
818			("Dockerfile", true),
819			("Makefile", true),
820			("LICENSE", true),
821			(".gitignore", true),
822			("notes.txt", true),
823			("main.dart", true),
824			("main.tsv", true),
825			("main.tex", true),
826			("main.scala", true),
827			("main.vue", true),
828			("main.svelte", true),
829			("main.hbs", true),
830			("main.astro", true),
831			("main.cs", true),
832			("main.kt", true),
833			("main.kotlin", true),
834			// binary / non-text extensions
835			("image.png", false),
836			("image.jpg", false),
837			("image.jpeg", false),
838			("image.gif", false),
839			("image.webp", false),
840			("archive.zip", false),
841			("archive.tar", false),
842			("archive.gz", false),
843			("binary.exe", false),
844			("library.so", false),
845			("library.dll", false),
846			("document.pdf", false),
847			("audio.mp3", false),
848			("video.mp4", false),
849			("font.ttf", false),
850			("font.woff", false),
851		];
852
853		// -- Exec & Check
854		for (filename, expected) in cases {
855			let spath = SPath::new(*filename);
856			let result = spath.is_likely_text();
857			assert_eq!(
858				result, *expected,
859				"is_likely_text({filename:?}) expected {expected} but got {result}"
860			);
861		}
862	}
863}
864
865// endregion: --- Tests