├── tests-data ├── file2.txt ├── file1.md ├── another-dir │ ├── notes.md │ ├── sub-dir │ │ ├── data.txt │ │ ├── example.md │ │ └── deep-folder │ │ │ ├── final.txt │ │ │ └── final.md │ └── document.txt ├── dir1 │ ├── file4.txt │ ├── file3.md │ └── dir2 │ │ ├── dir3 │ │ ├── file7.md │ │ └── file8.txt │ │ ├── file5.md │ │ └── file6.txt └── example.csv ├── src ├── common │ ├── mod.rs │ ├── smeta.rs │ └── pretty.rs ├── reshape │ ├── mod.rs │ ├── normalizer.rs │ └── collapser.rs ├── featured │ ├── with_json │ │ ├── mod.rs │ │ ├── load.rs │ │ ├── ndjson.rs │ │ └── save.rs │ ├── mod.rs │ ├── with_toml.rs │ └── bin_nums.rs ├── safer_remove │ ├── mod.rs │ ├── safer_remove_options.rs │ └── safer_remove_impl.rs ├── span │ ├── mod.rs │ ├── read_span.rs │ ├── line_spans.rs │ └── csv_spans.rs ├── list │ ├── mod.rs │ ├── iter_files.rs │ ├── iter_dirs.rs │ ├── list_options.rs │ ├── sort.rs │ ├── glob.rs │ ├── globs_dir_iter.rs │ └── globs_file_iter.rs ├── lib.rs ├── dir.rs ├── file.rs ├── watch.rs ├── error.rs ├── sfile.rs └── spath.rs ├── rustfmt.toml ├── LICENSE-MIT ├── .gitignore ├── Cargo.toml ├── README.md ├── dev └── spec │ └── internal-api-reference.md ├── tests ├── tests_list_dirs.rs ├── tests_spath.rs └── tests_list_files.rs ├── LICENSE-APACHE └── doc └── for-llm └── api-reference-for-llm.md /tests-data/file2.txt: -------------------------------------------------------------------------------- 1 | This is a root level text file. 2 | -------------------------------------------------------------------------------- /tests-data/file1.md: -------------------------------------------------------------------------------- 1 | This is a root level markdown file. 2 | -------------------------------------------------------------------------------- /tests-data/another-dir/notes.md: -------------------------------------------------------------------------------- 1 | Some notes in markdown format. 2 | -------------------------------------------------------------------------------- /tests-data/dir1/file4.txt: -------------------------------------------------------------------------------- 1 | A text file in the first subdirectory. 2 | -------------------------------------------------------------------------------- /tests-data/another-dir/sub-dir/data.txt: -------------------------------------------------------------------------------- 1 | Some data in a text file. 2 | -------------------------------------------------------------------------------- /tests-data/dir1/file3.md: -------------------------------------------------------------------------------- 1 | A markdown file in the first subdirectory. 2 | -------------------------------------------------------------------------------- /tests-data/dir1/dir2/dir3/file7.md: -------------------------------------------------------------------------------- 1 | A markdown file in the deepest subdirectory. 2 | -------------------------------------------------------------------------------- /tests-data/dir1/dir2/dir3/file8.txt: -------------------------------------------------------------------------------- 1 | A text file in the deepest subdirectory. 2 | -------------------------------------------------------------------------------- /tests-data/dir1/dir2/file5.md: -------------------------------------------------------------------------------- 1 | A markdown file in the second level subdirectory. 2 | -------------------------------------------------------------------------------- /tests-data/dir1/dir2/file6.txt: -------------------------------------------------------------------------------- 1 | A text file in the second level subdirectory. 2 | -------------------------------------------------------------------------------- /tests-data/another-dir/document.txt: -------------------------------------------------------------------------------- 1 | A text document in a different first-level directory. 2 | -------------------------------------------------------------------------------- /tests-data/another-dir/sub-dir/example.md: -------------------------------------------------------------------------------- 1 | An example markdown file in a subdirectory. 2 | -------------------------------------------------------------------------------- /tests-data/another-dir/sub-dir/deep-folder/final.txt: -------------------------------------------------------------------------------- 1 | The final text file in the deepest level. 2 | -------------------------------------------------------------------------------- /tests-data/another-dir/sub-dir/deep-folder/final.md: -------------------------------------------------------------------------------- 1 | The final markdown file in the deepest level. 2 | -------------------------------------------------------------------------------- /tests-data/example.csv: -------------------------------------------------------------------------------- 1 | name,age,comment 2 | Alice,30,"hello, world" 3 | Bob,25,"Line with ""quote""" 4 | Carol,28,"multi 5 | line with ""quotes"" inside" 6 | -------------------------------------------------------------------------------- /src/common/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod pretty; 4 | mod smeta; 5 | 6 | pub use pretty::*; 7 | pub use smeta::*; 8 | 9 | // endregion: --- Modules 10 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # rustfmt doc - https://rust-lang.github.io/rustfmt/ 2 | 3 | edition = "2021" 4 | hard_tabs = true # no comment 5 | max_width = 120 6 | chain_width = 80 7 | array_width = 80 8 | 9 | -------------------------------------------------------------------------------- /src/reshape/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- modules 2 | 3 | mod collapser; 4 | mod normalizer; 5 | 6 | pub use collapser::*; 7 | pub use normalizer::*; 8 | 9 | // endregion: --- modules 10 | -------------------------------------------------------------------------------- /src/featured/with_json/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod load; 4 | mod ndjson; 5 | mod save; 6 | 7 | pub use load::*; 8 | pub use ndjson::*; 9 | pub use save::*; 10 | 11 | // endregion: --- Modules 12 | -------------------------------------------------------------------------------- /src/safer_remove/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod safer_remove_impl; 4 | mod safer_remove_options; 5 | 6 | pub use safer_remove_impl::*; 7 | pub use safer_remove_options::*; 8 | 9 | // endregion: --- Modules 10 | -------------------------------------------------------------------------------- /src/span/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod csv_spans; 4 | mod line_spans; 5 | mod read_span; 6 | 7 | pub use csv_spans::*; 8 | pub use line_spans::*; 9 | pub use read_span::*; 10 | 11 | // endregion: --- Modules 12 | -------------------------------------------------------------------------------- /src/list/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod globs_dir_iter; 4 | mod globs_file_iter; 5 | 6 | mod glob; 7 | mod iter_dirs; 8 | mod iter_files; 9 | mod list_options; 10 | mod sort; 11 | 12 | pub use glob::*; 13 | pub use iter_dirs::*; 14 | pub use iter_files::*; 15 | pub use list_options::*; 16 | pub use sort::*; 17 | 18 | // endregion: --- Modules 19 | -------------------------------------------------------------------------------- /src/featured/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | #[cfg(feature = "bin-nums")] 4 | mod bin_nums; 5 | #[cfg(feature = "with-json")] 6 | mod with_json; 7 | #[cfg(feature = "with-toml")] 8 | mod with_toml; 9 | 10 | #[cfg(feature = "with-json")] 11 | pub use with_json::*; 12 | 13 | #[cfg(feature = "with-toml")] 14 | pub use with_toml::*; 15 | 16 | #[cfg(feature = "bin-nums")] 17 | pub use bin_nums::*; 18 | 19 | // endregion: --- Modules 20 | -------------------------------------------------------------------------------- /src/list/iter_files.rs: -------------------------------------------------------------------------------- 1 | use crate::{ListOptions, Result, SFile}; 2 | use std::path::Path; 3 | 4 | pub fn iter_files( 5 | dir: impl AsRef, 6 | include_globs: Option<&[&str]>, 7 | list_options: Option>, 8 | ) -> Result { 9 | super::globs_file_iter::GlobsFileIter::new(dir, include_globs, list_options) 10 | } 11 | 12 | pub fn list_files( 13 | dir: impl AsRef, 14 | include_globs: Option<&[&str]>, 15 | list_options: Option>, 16 | ) -> Result> { 17 | let sfiles_iter = iter_files(dir, include_globs, list_options)?; 18 | Ok(sfiles_iter.collect()) 19 | } 20 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod common; 4 | mod dir; 5 | mod error; 6 | mod featured; 7 | mod file; 8 | mod list; 9 | mod reshape; 10 | mod safer_remove; 11 | mod sfile; 12 | mod span; 13 | mod spath; 14 | mod watch; 15 | 16 | pub use self::error::{Error, Result}; 17 | 18 | // -- Re-export everything for the root crate 19 | 20 | pub use common::*; 21 | pub use dir::*; 22 | pub use file::*; 23 | pub use list::*; 24 | pub use reshape::*; 25 | pub use safer_remove::*; 26 | pub use sfile::*; 27 | pub use span::*; 28 | pub use spath::*; 29 | pub use watch::*; 30 | 31 | #[allow(unused)] 32 | pub use featured::*; 33 | 34 | // endregion: --- Modules 35 | 36 | const TOP_MAX_DEPTH: usize = 100; 37 | -------------------------------------------------------------------------------- /src/common/smeta.rs: -------------------------------------------------------------------------------- 1 | /// A simplified file metadata structure with common, normalized fields. 2 | /// All fields are guaranteed to be present. 3 | #[derive(Debug, Clone)] 4 | pub struct SMeta { 5 | /// Creation time since the Unix epoch in microseconds. 6 | /// If unavailable, this may fall back to the modification time. 7 | pub created_epoch_us: i64, 8 | 9 | /// Last modification time since the Unix epoch in microseconds. 10 | pub modified_epoch_us: i64, 11 | 12 | /// File size in bytes. Will be 0 for directories or when unavailable. 13 | pub size: u64, 14 | 15 | /// Whether the path is a regular file. 16 | pub is_file: bool, 17 | 18 | /// Whether the path is a directory. 19 | pub is_dir: bool, 20 | } 21 | -------------------------------------------------------------------------------- /src/dir.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result}; 2 | use std::fs; 3 | use std::path::Path; 4 | 5 | pub fn ensure_dir(dir: impl AsRef) -> Result { 6 | let dir = dir.as_ref(); 7 | if dir.is_dir() { 8 | Ok(false) 9 | } else { 10 | fs::create_dir_all(dir).map_err(|e| Error::DirCantCreateAll((dir, e).into()))?; 11 | Ok(true) 12 | } 13 | } 14 | 15 | pub fn ensure_file_dir(file_path: impl AsRef) -> Result { 16 | let file_path = file_path.as_ref(); 17 | let dir = file_path 18 | .parent() 19 | .ok_or_else(|| Error::FileHasNoParent(file_path.to_string_lossy().to_string()))?; 20 | 21 | if dir.is_dir() { 22 | Ok(false) 23 | } else { 24 | fs::create_dir_all(dir).map_err(|e| Error::DirCantCreateAll((dir, e).into()))?; 25 | Ok(true) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/list/iter_dirs.rs: -------------------------------------------------------------------------------- 1 | use crate::{ListOptions, Result, SPath}; 2 | 3 | use std::path::Path; 4 | 5 | /// Returns an iterator over directories in the specified `dir` filtered optionally by `include_globs` 6 | /// and `list_options`. This implementation uses the internal GlobsDirIter. 7 | pub fn iter_dirs( 8 | dir: impl AsRef, 9 | include_globs: Option<&[&str]>, 10 | list_options: Option>, 11 | ) -> Result> { 12 | let iter = super::globs_dir_iter::GlobsDirIter::new(dir, include_globs, list_options)?; 13 | Ok(iter) 14 | } 15 | 16 | /// Collects directories from `iter_dirs` into a Vec 17 | pub fn list_dirs( 18 | dir: impl AsRef, 19 | include_globs: Option<&[&str]>, 20 | list_options: Option>, 21 | ) -> Result> { 22 | let iter = iter_dirs(dir, include_globs, list_options)?; 23 | Ok(iter.collect()) 24 | } 25 | -------------------------------------------------------------------------------- /src/featured/with_toml.rs: -------------------------------------------------------------------------------- 1 | use crate::file::create_file; 2 | use crate::{Error, Result, read_to_string}; 3 | use std::fs; 4 | use std::path::Path; 5 | 6 | pub fn load_toml(file_path: impl AsRef) -> Result 7 | where 8 | T: serde::de::DeserializeOwned, 9 | { 10 | let file_path = file_path.as_ref(); 11 | let content = read_to_string(file_path)?; 12 | 13 | let res = toml::from_str(&content).map_err(|e| Error::TomlCantRead((file_path, e).into()))?; 14 | 15 | Ok(res) 16 | } 17 | 18 | pub fn save_toml(file_path: impl AsRef, data: &T) -> Result<()> 19 | where 20 | T: serde::Serialize, 21 | { 22 | let file_path = file_path.as_ref(); 23 | create_file(file_path)?; 24 | 25 | let toml_string = toml::to_string(data).map_err(|e| Error::TomlCantWrite((file_path, e).into()))?; 26 | fs::write(file_path, toml_string).map_err(|e| Error::TomlCantWrite((file_path, e).into()))?; 27 | 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /src/featured/with_json/load.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result, get_buf_reader}; 2 | use serde_json::Value; 3 | use std::path::Path; 4 | 5 | pub fn load_json(file: impl AsRef) -> Result 6 | where 7 | T: serde::de::DeserializeOwned, 8 | { 9 | let file = file.as_ref(); 10 | 11 | let buf_reader = get_buf_reader(file)?; 12 | let val = serde_json::from_reader(buf_reader).map_err(|ex| Error::JsonCantRead((file, ex).into()))?; 13 | 14 | Ok(val) 15 | } 16 | 17 | /// Loads a ndjson (newline delimited json) file returning `Result>`. 18 | /// Empty lines will be skipped. 19 | pub fn load_ndjson(file: impl AsRef) -> Result> { 20 | let file = file.as_ref(); 21 | let buf_reader = get_buf_reader(file)?; 22 | super::parse_ndjson_from_reader(buf_reader) 23 | } 24 | 25 | /// Returns an iterator over each line parsed as json `Result`. 26 | /// Empty lines will be skipped. 27 | pub fn stream_ndjson(file: impl AsRef) -> Result>> { 28 | let file = file.as_ref(); 29 | let buf_reader = get_buf_reader(file)?; 30 | Ok(super::parse_ndjson_iter_from_reader(buf_reader)) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Jeremy Chone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -- Base 2 | .* 3 | !.gitignore 4 | 5 | _* 6 | # '_' in src dir, ok. 7 | !**/src/**/_* 8 | 9 | *.lock 10 | *.lockb 11 | *.log 12 | 13 | # -- Rust 14 | target/ 15 | # !Cargo.lock # commented by default 16 | !.cargo/ 17 | 18 | # -- Devai 19 | # Only allow .devai/custom and .devai/Config.toml 20 | # Note: Here the starting `/` will just include the top .devai. 21 | # Remove the starting `/` to include all .devai/custom even if their in a sub dir 22 | !/.devai/ 23 | .devai/* 24 | !.devai/custom/ 25 | !.devai/custom/** 26 | !.devai/Config.toml 27 | # Ignore the .devai (typically temporary solos) 28 | src/**/*.devai 29 | 30 | # -- Safety net 31 | 32 | dist/ 33 | out/ 34 | 35 | # Data Files 36 | *.db3 37 | *.parquet 38 | *.map 39 | *.zip 40 | *.gz 41 | *.tar 42 | *.tgz 43 | *.vsix 44 | 45 | # Videos 46 | *.mov 47 | *.mp4 48 | *.webm 49 | *.ogg 50 | *.avi 51 | 52 | # Images 53 | *.icns 54 | *.ico 55 | *.jpeg 56 | *.jpg 57 | *.png 58 | *.bmp 59 | 60 | # -- Nodejs 61 | node_modules/ 62 | !.mocharc.yaml 63 | report.*.json 64 | 65 | # -- Python 66 | __pycache__/ 67 | 68 | 69 | # -- others 70 | # Allows .env (make sure only dev info) 71 | # !.env # Commented by default 72 | 73 | # Allow vscode 74 | # !.vscode # Commented by default 75 | -------------------------------------------------------------------------------- /src/featured/with_json/ndjson.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result}; 2 | use serde_json::Value; 3 | use std::io::{BufRead, Cursor}; 4 | 5 | // From &str using Cursor (reuses above) 6 | pub fn parse_ndjson_iter(input: &str) -> impl Iterator> { 7 | let reader = Cursor::new(input); 8 | parse_ndjson_iter_from_reader(reader) 9 | } 10 | 11 | pub fn parse_ndjson(input: &str) -> Result> { 12 | let reader = Cursor::new(input); 13 | parse_ndjson_from_reader(reader) 14 | } 15 | 16 | // Full collector from BufRead 17 | pub fn parse_ndjson_from_reader(reader: R) -> Result> { 18 | parse_ndjson_iter_from_reader(reader).collect() 19 | } 20 | 21 | // Core streaming parser 22 | pub fn parse_ndjson_iter_from_reader(reader: R) -> impl Iterator> { 23 | reader.lines().enumerate().filter_map(|(index, line_result)| { 24 | match line_result { 25 | Ok(line) if line.trim().is_empty() => None, // skip empty 26 | Ok(line) => Some(serde_json::from_str::(&line).map_err(|e| { 27 | Error::NdJson(format!( 28 | "aip.file.load_ndjson - Failed to parse JSON on line {}. Cause: {}", 29 | index + 1, 30 | e 31 | )) 32 | })), 33 | Err(e) => Some(Err(Error::NdJson(format!( 34 | "aip.file.load_ndjson - Failed to read line {}. Cause: {}", 35 | index + 1, 36 | e 37 | )))), 38 | } 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/file.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result}; 2 | use std::fs::{self, File}; 3 | use std::io::{BufReader, BufWriter}; 4 | use std::path::Path; 5 | 6 | pub fn create_file(file_path: impl AsRef) -> Result { 7 | let file_path = file_path.as_ref(); 8 | File::create(file_path).map_err(|e| Error::FileCantCreate((file_path, e).into())) 9 | } 10 | 11 | pub fn read_to_string(file_path: impl AsRef) -> Result { 12 | let file_path = file_path.as_ref(); 13 | 14 | if !file_path.is_file() { 15 | return Err(Error::FileNotFound(file_path.to_string_lossy().to_string())); 16 | } 17 | 18 | let content = fs::read_to_string(file_path).map_err(|e| Error::FileCantRead((file_path, e).into()))?; 19 | 20 | Ok(content) 21 | } 22 | 23 | pub fn open_file(path: impl AsRef) -> Result { 24 | let path = path.as_ref(); 25 | let f = File::open(path).map_err(|e| Error::FileCantOpen((path, e).into()))?; 26 | Ok(f) 27 | } 28 | 29 | pub fn get_buf_reader(file: impl AsRef) -> Result> { 30 | let file = file.as_ref(); 31 | 32 | let file = File::open(file).map_err(|e| Error::FileCantOpen((file, e).into()))?; 33 | 34 | Ok(BufReader::new(file)) 35 | } 36 | 37 | pub fn get_buf_writer(file_path: impl AsRef) -> Result> { 38 | let file_path = file_path.as_ref(); 39 | 40 | let file = create_file(file_path)?; 41 | 42 | Ok(BufWriter::new(file)) 43 | } 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "simple-fs" 3 | version = "0.9.3-WIP" 4 | edition = "2024" 5 | authors = ["Jeremy Chone "] 6 | license = "MIT OR Apache-2.0" 7 | description = "Simple and convenient API for File System access" 8 | categories = ["filesystem"] 9 | keywords = [ 10 | "file-system", 11 | "json", 12 | "toml", 13 | "io" 14 | ] 15 | homepage = "https://github.com/jeremychone/rust-simple-fs" 16 | repository = "https://github.com/jeremychone/rust-simple-fs" 17 | 18 | [lints.rust] 19 | unsafe_code = "forbid" 20 | # unused = { level = "allow", priority = -1 } # For exploratory dev. 21 | 22 | [features] 23 | "full" = ["with-json", "with-toml", "bin-nums"] 24 | "with-json" = ["serde", "serde_json"] 25 | "with-toml" = ["serde", "toml"] 26 | "bin-nums" = ["byteorder"] 27 | 28 | [dependencies] 29 | # -- Files 30 | camino = "1" # trying this lib out 31 | walkdir = "2" 32 | globset = "0.4" 33 | notify = "8" 34 | notify-debouncer-full = "0.6" 35 | # -- Feature: json, toml 36 | serde = { version = "1", features = ["derive"], optional = true} 37 | # -- Feature: json 38 | serde_json = { version = "1", optional = true} 39 | # -- Feature: toml 40 | toml = { version = "0.9", optional = true} 41 | # -- Features: bin-nums 42 | byteorder = { version = "1.5", optional = true} 43 | pathdiff = { version = "0.2.2", features = ["camino"]} 44 | path-clean = "1.0.1" 45 | # -- Other 46 | derive_more = {version = "2.0", features = ["from", "display"] } 47 | flume = "0.11.1" 48 | memchr = "2" 49 | -------------------------------------------------------------------------------- /src/safer_remove/safer_remove_options.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone)] 2 | pub struct SaferRemoveOptions<'a> { 3 | pub must_contain_any: Option<&'a [&'a str]>, 4 | pub must_contain_all: Option<&'a [&'a str]>, 5 | pub restrict_to_current_dir: bool, 6 | } 7 | 8 | // region: --- Default 9 | 10 | impl Default for SaferRemoveOptions<'_> { 11 | fn default() -> Self { 12 | Self { 13 | must_contain_any: None, 14 | must_contain_all: None, 15 | restrict_to_current_dir: true, 16 | } 17 | } 18 | } 19 | 20 | // endregion: --- Default 21 | 22 | // region: --- Froms 23 | 24 | impl From<()> for SaferRemoveOptions<'_> { 25 | fn from(_: ()) -> Self { 26 | Self::default() 27 | } 28 | } 29 | 30 | impl<'a> From<&'a [&'a str]> for SaferRemoveOptions<'a> { 31 | fn from(patterns: &'a [&'a str]) -> Self { 32 | Self { 33 | must_contain_any: Some(patterns), 34 | ..Default::default() 35 | } 36 | } 37 | } 38 | 39 | // endregion: --- Froms 40 | 41 | // region: --- Fluent API 42 | 43 | impl<'a> SaferRemoveOptions<'a> { 44 | pub fn with_must_contain_any(mut self, patterns: &'a [&'a str]) -> Self { 45 | self.must_contain_any = Some(patterns); 46 | self 47 | } 48 | 49 | pub fn with_must_contain_all(mut self, patterns: &'a [&'a str]) -> Self { 50 | self.must_contain_all = Some(patterns); 51 | self 52 | } 53 | 54 | pub fn with_restrict_to_current_dir(mut self, val: bool) -> Self { 55 | self.restrict_to_current_dir = val; 56 | self 57 | } 58 | } 59 | 60 | // endregion: --- Fluent API 61 | -------------------------------------------------------------------------------- /src/span/read_span.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result, SPath, open_file}; 2 | use std::fs::File; 3 | use std::io::{self, ErrorKind}; 4 | 5 | #[cfg(unix)] 6 | use std::os::unix::fs::FileExt as _; 7 | #[cfg(windows)] 8 | use std::os::windows::fs::FileExt as _; 9 | 10 | /// Read a (start,end) half-open span and return a string. 11 | pub fn read_span(path: impl AsRef, start: usize, end: usize) -> Result { 12 | let len = end.checked_sub(start).ok_or(Error::SpanInvalidStartAfterEnd)?; 13 | 14 | let path = path.as_ref(); 15 | let file = open_file(path)?; 16 | 17 | let res = read_exact_at(&file, start as u64, len).map_err(|err| Error::FileCantRead((path, err).into()))?; 18 | 19 | let txt = String::from_utf8(res).map_err(|_| Error::SpanInvalidUtf8)?; 20 | 21 | Ok(txt) 22 | } 23 | 24 | // region: --- Support 25 | 26 | /// Read exactly `len` bytes starting at absolute file offset `offset` into a Vec. 27 | fn read_exact_at(file: &File, offset: u64, len: usize) -> io::Result> { 28 | let mut buf = vec![0u8; len]; 29 | let mut filled = 0usize; 30 | 31 | while filled < len { 32 | #[cfg(unix)] 33 | let n = file.read_at(&mut buf[filled..], offset + filled as u64)?; 34 | #[cfg(windows)] 35 | let n = file.seek_read(&mut buf[filled..], offset + filled as u64)?; 36 | 37 | if n == 0 { 38 | return Err(io::Error::new( 39 | ErrorKind::UnexpectedEof, 40 | "span exceeds file size (hit EOF)", 41 | )); 42 | } 43 | filled += n; 44 | } 45 | Ok(buf) 46 | } 47 | 48 | // endregion: --- Support 49 | -------------------------------------------------------------------------------- /src/list/list_options.rs: -------------------------------------------------------------------------------- 1 | /// Note: In the future, the lifetime might be removed, and iter_files will take Option<&ListOptions>. 2 | #[derive(Default)] 3 | pub struct ListOptions<'a> { 4 | pub exclude_globs: Option>, 5 | 6 | /// When this is true, 7 | /// - the glob will be relative to the directory of the list, rather than including it. 8 | /// 9 | /// By default, it is false. 10 | pub relative_glob: bool, 11 | 12 | /// For now, only used in dir list 13 | pub depth: Option, 14 | } 15 | 16 | /// Constructors 17 | impl<'a> ListOptions<'a> { 18 | pub fn new(globs: Option<&'a [&'a str]>) -> Self { 19 | ListOptions { 20 | exclude_globs: globs.map(|v| v.to_vec()), 21 | relative_glob: false, 22 | depth: None, 23 | } 24 | } 25 | 26 | pub fn from_relative_glob(val: bool) -> Self { 27 | ListOptions { 28 | exclude_globs: None, 29 | relative_glob: val, 30 | depth: None, 31 | } 32 | } 33 | } 34 | 35 | /// Setters 36 | impl<'a> ListOptions<'a> { 37 | pub fn with_exclude_globs(mut self, globs: &'a [&'a str]) -> Self { 38 | self.exclude_globs = Some(globs.to_vec()); 39 | self 40 | } 41 | 42 | pub fn with_relative_glob(mut self) -> Self { 43 | self.relative_glob = true; 44 | self 45 | } 46 | } 47 | 48 | /// Getters 49 | impl<'a> ListOptions<'a> { 50 | pub fn exclude_globs(&'a self) -> Option<&'a [&'a str]> { 51 | self.exclude_globs.as_deref() 52 | } 53 | } 54 | 55 | // region: --- Froms 56 | 57 | impl<'a> From<&'a [&'a str]> for ListOptions<'a> { 58 | fn from(globs: &'a [&'a str]) -> Self { 59 | ListOptions { 60 | exclude_globs: Some(globs.to_vec()), 61 | relative_glob: false, 62 | depth: None, 63 | } 64 | } 65 | } 66 | 67 | impl<'a> From> for ListOptions<'a> { 68 | fn from(globs: Option<&'a [&'a str]>) -> Self { 69 | ListOptions { 70 | exclude_globs: globs.map(|v| v.to_vec()), 71 | relative_glob: false, 72 | depth: None, 73 | } 74 | } 75 | } 76 | 77 | impl<'a> From> for ListOptions<'a> { 78 | fn from(globs: Vec<&'a str>) -> Self { 79 | let globs_ref: Vec<&'a str> = globs.to_vec(); 80 | ListOptions { 81 | exclude_globs: Some(globs_ref), 82 | relative_glob: false, 83 | depth: None, 84 | } 85 | } 86 | } 87 | 88 | // endregion: --- Froms 89 | -------------------------------------------------------------------------------- /src/span/line_spans.rs: -------------------------------------------------------------------------------- 1 | use crate::spath::SPath; 2 | use crate::{Error, Result, open_file}; 3 | use memchr::memchr_iter; 4 | use std::io::{self, Read}; 5 | 6 | /// Return byte ranges [start, end) for each line in the file at `path`, 7 | /// splitting on '\n' and trimming a preceding '\r' (CRLF) even across chunk boundaries. 8 | /// Runs in O(n) time, streaming; does not allocate the whole file. 9 | pub fn line_spans(path: impl AsRef) -> Result> { 10 | let path = path.as_ref(); 11 | let mut f = open_file(path)?; 12 | let res = line_spans_from_reader(&mut f).map_err(|err| Error::FileCantRead((path, err).into()))?; 13 | Ok(res) 14 | } 15 | 16 | // region: --- Support 17 | 18 | /// Same logic over any `Read` (useful for pipes). 19 | fn line_spans_from_reader(r: &mut R) -> io::Result> { 20 | let mut spans: Vec<(usize, usize)> = Vec::new(); 21 | 22 | // 64 KiB chunks are a good balance for cache and syscalls. 23 | let mut buf = [0u8; 64 * 1024]; 24 | 25 | let mut file_pos: usize = 0; // absolute offset of start of `buf` 26 | let mut line_start: usize = 0; // absolute start of current line 27 | let mut prev_byte_is_cr = false; // was the byte immediately before this chunk a '\r'? 28 | 29 | loop { 30 | let n = r.read(&mut buf)?; 31 | if n == 0 { 32 | break; 33 | } 34 | let chunk = &buf[..n]; 35 | 36 | // Find all '\n' quickly. 37 | for nl_idx in memchr_iter(b'\n', chunk) { 38 | let abs_nl = file_pos + nl_idx; 39 | 40 | // If the byte just before '\n' is '\r', trim it. Handle chunk boundary. 41 | let end = if nl_idx > 0 { 42 | if chunk[nl_idx - 1] == b'\r' { abs_nl - 1 } else { abs_nl } 43 | } else if prev_byte_is_cr { 44 | abs_nl - 1 45 | } else { 46 | abs_nl 47 | }; 48 | 49 | spans.push((line_start, end)); 50 | line_start = abs_nl + 1; // next line starts after '\n' 51 | } 52 | 53 | // Prepare for next chunk. 54 | prev_byte_is_cr = chunk[n - 1] == b'\r'; 55 | file_pos += n; 56 | } 57 | 58 | // Final line if file doesn't end with '\n' 59 | if line_start < file_pos { 60 | spans.push((line_start, file_pos)); 61 | } 62 | 63 | Ok(spans) 64 | } 65 | 66 | // endregion: --- Support 67 | 68 | // region: --- Tests 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | type Result = core::result::Result>; // For tests. 73 | 74 | use super::*; 75 | 76 | #[test] 77 | fn test_span_line_span_line_spans_simple() -> Result<()> { 78 | // -- Setup & Fixtures 79 | let path = SPath::from("tests-data/example.csv"); 80 | 81 | // -- Exec 82 | let spans = line_spans(&path)?; 83 | 84 | // -- Check 85 | assert_eq!(spans.len(), 5, "should find 5 physical lines"); 86 | 87 | let expected = [ 88 | "name,age,comment", 89 | "Alice,30,\"hello, world\"", 90 | "Bob,25,\"Line with \"\"quote\"\"\"", 91 | "Carol,28,\"multi", 92 | "line with \"\"quotes\"\" inside\"", 93 | ]; 94 | 95 | for (i, exp) in expected.iter().enumerate() { 96 | let (s, e) = spans.get(i).copied().ok_or("missing expected line span")?; 97 | let got = crate::read_span(&path, s, e)?; 98 | assert_eq!(&got, exp); 99 | } 100 | 101 | Ok(()) 102 | } 103 | } 104 | 105 | // endregion: --- Tests 106 | -------------------------------------------------------------------------------- /src/featured/with_json/save.rs: -------------------------------------------------------------------------------- 1 | use crate::file::create_file; 2 | use crate::{Error, Result}; 3 | use serde::Serialize; 4 | use std::fs::OpenOptions; 5 | use std::io::{BufWriter, Write}; 6 | use std::path::Path; 7 | 8 | const JSON_LINES_BUFFER_SIZE: usize = 100; 9 | 10 | pub fn save_json(file: impl AsRef, data: &T) -> Result<()> 11 | where 12 | T: serde::Serialize, 13 | { 14 | save_json_impl(file.as_ref(), data, false) 15 | } 16 | 17 | pub fn save_json_pretty(file: impl AsRef, data: &T) -> Result<()> 18 | where 19 | T: serde::Serialize, 20 | { 21 | save_json_impl(file.as_ref(), data, true) 22 | } 23 | 24 | fn save_json_impl(file_path: &Path, data: &T, pretty: bool) -> Result<()> 25 | where 26 | T: serde::Serialize, 27 | { 28 | let file = create_file(file_path)?; 29 | 30 | let res = if pretty { 31 | serde_json::to_writer_pretty(file, data) 32 | } else { 33 | serde_json::to_writer(file, data) 34 | }; 35 | 36 | res.map_err(|e| Error::JsonCantWrite((file_path, e).into()))?; 37 | 38 | Ok(()) 39 | } 40 | 41 | /// Appends a `serde_json::Value` as a JSON line to the specified file. 42 | /// Creates the file if it doesn't exist. 43 | pub fn append_json_line(file: impl AsRef, value: &T) -> Result<()> { 44 | let file_path = file.as_ref(); 45 | 46 | // Serialize the value to a JSON string first. 47 | let json_string = serde_json::to_string(value).map_err(|e| Error::JsonCantWrite((file_path, e).into()))?; 48 | 49 | // Open the file in append mode, creating it if necessary. 50 | let mut file = OpenOptions::new() 51 | .create(true) // Create the file if it doesn't exist 52 | .append(true) // Set append mode 53 | .open(file_path) 54 | .map_err(|e| Error::FileCantOpen((file_path, e).into()))?; 55 | 56 | // Write the JSON string followed by a newline character. 57 | writeln!(file, "{}", json_string).map_err(|e| Error::FileCantWrite((file_path, e).into()))?; 58 | 59 | Ok(()) 60 | } 61 | 62 | /// Appends multiple `serde_json::Value` items as JSON lines to the specified file. 63 | /// Creates the file if it doesn't exist. Writes in batches for efficiency. 64 | pub fn append_json_lines<'a, T, I>(file: impl AsRef, values: I) -> Result<()> 65 | where 66 | T: Serialize + 'a, 67 | I: IntoIterator, 68 | { 69 | let file_path = file.as_ref(); 70 | 71 | // Open the file in append mode, creating it if necessary. 72 | let file = OpenOptions::new() 73 | .create(true) // Create the file if it doesn't exist 74 | .append(true) // Set append mode 75 | .open(file_path) 76 | .map_err(|e| Error::FileCantOpen((file_path, e).into()))?; 77 | 78 | let mut writer = BufWriter::new(file); 79 | let mut count = 0; 80 | 81 | for value in values { 82 | // Serialize the value to a JSON string. 83 | let json_string = serde_json::to_string(value).map_err(|e| Error::JsonCantWrite((file_path, e).into()))?; 84 | 85 | // Write the JSON string followed by a newline character to the buffer. 86 | writeln!(writer, "{}", json_string).map_err(|e| Error::FileCantWrite((file_path, e).into()))?; 87 | 88 | count += 1; 89 | 90 | // Flush the buffer periodically. 91 | if count % JSON_LINES_BUFFER_SIZE == 0 { 92 | writer.flush().map_err(|e| Error::FileCantWrite((file_path, e).into()))?; 93 | } 94 | } 95 | 96 | // Ensure any remaining lines in the buffer are written. 97 | writer.flush().map_err(|e| Error::FileCantWrite((file_path, e).into()))?; 98 | 99 | Ok(()) 100 | } 101 | -------------------------------------------------------------------------------- /src/featured/bin_nums.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result}; 2 | use crate::{get_buf_reader, get_buf_writer}; 3 | use byteorder::{BigEndian, ByteOrder, LittleEndian}; 4 | use std::io::{Read, Write}; 5 | use std::path::Path; 6 | 7 | // region: --- Loaders 8 | 9 | macro_rules! generate_load_functions { 10 | ( $( $type:ty, $size:expr, $load_be_fn_name:ident, $load_le_fn_name:ident, $load_fn:ident, $byteorder_read_fn:ident );* $(;)? ) => { 11 | $( 12 | pub fn $load_be_fn_name(file_path: impl AsRef) -> Result> { 13 | $load_fn(file_path.as_ref(), BigEndian::$byteorder_read_fn) 14 | } 15 | 16 | pub fn $load_le_fn_name(file_path: impl AsRef) -> Result> { 17 | $load_fn(file_path.as_ref(), LittleEndian::$byteorder_read_fn) 18 | } 19 | 20 | fn $load_fn(file_path: &Path, read_fn: fn(buf: &[u8]) -> $type) -> Result> { 21 | let mut reader = get_buf_reader(file_path)?; 22 | 23 | let mut data = Vec::new(); 24 | let mut buf = [0u8; $size]; 25 | while let Ok(()) = reader.read_exact(&mut buf) { 26 | let val = read_fn(&buf); 27 | data.push(val); 28 | } 29 | 30 | Ok(data) 31 | } 32 | )* 33 | }; 34 | } 35 | 36 | generate_load_functions!( 37 | f64, 8, load_be_f64, load_le_f64, load_f64, read_f64; 38 | f32, 4, load_be_f32, load_le_f32, load_f32, read_f32; 39 | u64, 8, load_be_u64, load_le_u64, load_u64, read_u64; 40 | u32, 4, load_be_u32, load_le_u32, load_u32, read_u32; 41 | u16, 2, load_be_u16, load_le_u16, load_u16, read_u16; 42 | i64, 8, load_be_i64, load_le_i64, load_i64, read_i64; 43 | i32, 4, load_be_i32, load_le_i32, load_i32, read_i32; 44 | i16, 2, load_be_i16, load_le_i16, load_i16, read_i16; 45 | ); 46 | 47 | // endregion: --- Loaders 48 | 49 | // region: --- Savers 50 | 51 | macro_rules! generate_save_functions { 52 | ( $( $type:ty, $size:expr, $save_be_fn_name:ident, $save_le_fn_name:ident, $save_fn:ident, $byteorder_write_fn:ident );* $(;)? ) => { 53 | $( 54 | pub fn $save_be_fn_name(file_path: impl AsRef, data: &[$type]) -> Result<()> { 55 | $save_fn(file_path.as_ref(), data, BigEndian::$byteorder_write_fn) 56 | } 57 | 58 | pub fn $save_le_fn_name(file_path: impl AsRef, data: &[$type]) -> Result<()> { 59 | $save_fn(file_path.as_ref(), data, LittleEndian::$byteorder_write_fn) 60 | } 61 | 62 | fn $save_fn(file_path: &Path, data: &[$type], write_fn: fn(buf: &mut [u8], n: $type)) -> Result<()> { 63 | let mut writer = get_buf_writer(file_path)?; 64 | 65 | let mut buf = [0; $size]; 66 | for value in data { 67 | write_fn(&mut buf, *value); 68 | writer 69 | .write_all(&buf) 70 | .map_err(|e| Error::FileCantWrite((file_path, e).into()))?; 71 | } 72 | 73 | writer.flush().map_err(|e| Error::FileCantWrite((file_path, e).into()))?; 74 | Ok(()) 75 | } 76 | )* 77 | }; 78 | } 79 | 80 | generate_save_functions!( 81 | f64, 8, save_be_f64, save_le_f64, save_f64, write_f64; 82 | f32, 4, save_be_f32, save_le_f32, save_f32, write_f32; 83 | u64, 8, save_be_u64, save_le_u64, save_u64, write_u64; 84 | u32, 4, save_be_u32, save_le_u32, save_u32, write_u32; 85 | u16, 2, save_be_u16, save_le_u16, save_u16, write_u16; 86 | i64, 8, save_be_i64, save_le_i64, save_i64, write_i64; 87 | i32, 4, save_be_i32, save_le_i32, save_i32, write_i32; 88 | i16, 2, save_be_i16, save_le_i16, save_i16, write_i16; 89 | ); 90 | 91 | // endregion: --- Savers 92 | -------------------------------------------------------------------------------- /src/list/sort.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use globset::{Glob, GlobMatcher}; 4 | 5 | use crate::{Error, Result, SPath}; 6 | 7 | /// Sort files by glob priority, then by full path. 8 | /// 9 | /// - Builds a Vec of Glob (no GlobSet). 10 | /// - The "glob index" used for ordering is chosen as: 11 | /// - end_weighted = false: first matching glob index (from the beginning). 12 | /// - end_weighted = true: last matching glob index (from the end). 13 | /// - Files are ordered by (glob_index, full_path). Non-matches get `usize::MAX`. 14 | pub fn sort_by_globs(mut items: Vec, globs: &[&str], end_weighted: bool) -> Result> 15 | where 16 | T: AsRef, 17 | { 18 | // Build individual Glob matchers in order. 19 | let mut matchers: Vec<(usize, GlobMatcher)> = Vec::with_capacity(globs.len()); 20 | for (idx, pat) in globs.iter().enumerate() { 21 | let gm = Glob::new(pat).map_err(Error::sort_by_globs)?.compile_matcher(); 22 | matchers.push((idx, gm)); 23 | } 24 | 25 | items.sort_by(|a, b| { 26 | // Get paths from either SFile or SPath via AsRef. 27 | let ap: &SPath = a.as_ref(); 28 | let bp: &SPath = b.as_ref(); 29 | 30 | let ai = match_index_for_path(ap, &matchers, end_weighted); 31 | let bi = match_index_for_path(bp, &matchers, end_weighted); 32 | 33 | match ai.cmp(&bi) { 34 | Ordering::Equal => { 35 | // Tiebreaker: by full path from SPath. 36 | let an = ap.as_str(); 37 | let bn = bp.as_str(); 38 | an.cmp(bn) 39 | } 40 | other => other, 41 | } 42 | }); 43 | 44 | Ok(items) 45 | } 46 | 47 | // region: --- Support 48 | 49 | #[inline] 50 | fn match_index_for_path(path: &SPath, matchers: &[(usize, GlobMatcher)], end_weighted: bool) -> usize { 51 | if matchers.is_empty() { 52 | return usize::MAX; 53 | } 54 | 55 | // Normalize the input used for matching: many callers produce paths that start with "./". 56 | // Glob patterns typically don't include that leading "./", so strip it for matching purposes. 57 | let s = path.as_str(); 58 | let match_input = s.strip_prefix("./").unwrap_or(s); 59 | 60 | if end_weighted { 61 | // Use the last matching glob index (from the end). 62 | let mut found: Option = None; 63 | for (idx, gm) in matchers.iter().map(|(i, m)| (*i, m)) { 64 | if gm.is_match(match_input) { 65 | found = Some(idx); 66 | } 67 | } 68 | found.unwrap_or(usize::MAX) 69 | } else { 70 | // Use the first matching glob index (from the beginning). 71 | for (idx, gm) in matchers.iter().map(|(i, m)| (*i, m)) { 72 | if gm.is_match(match_input) { 73 | return idx; 74 | } 75 | } 76 | usize::MAX 77 | } 78 | } 79 | 80 | // endregion: --- Support 81 | 82 | // region: --- Tests 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | type Result = core::result::Result>; // For tests. 87 | 88 | use super::*; 89 | use crate::list_files; 90 | 91 | #[test] 92 | fn test_list_sort_sort_files_by_globs_end_weighted_true() -> Result<()> { 93 | // -- Setup & Fixtures 94 | let globs = ["src/**/*", "src/common/**/*.*", "src/list/sort.rs"]; 95 | let files = list_files("./", Some(&globs), None)?; 96 | 97 | // -- Exec 98 | let files = sort_by_globs(files, &globs, true)?; 99 | 100 | // -- Check 101 | let file_names = files.into_iter().map(|v| v.to_string()).collect::>(); 102 | let last_file = file_names.last().ok_or("Should have a least one")?; 103 | 104 | assert_eq!(last_file, "./src/list/sort.rs"); 105 | 106 | Ok(()) 107 | } 108 | } 109 | 110 | // endregion: --- Tests 111 | -------------------------------------------------------------------------------- /src/list/glob.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result, SPath, TOP_MAX_DEPTH}; 2 | use camino::Utf8PathBuf; 3 | use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; 4 | 5 | pub const DEFAULT_EXCLUDE_GLOBS: &[&str] = &["**/.git", "**/.DS_Store"]; 6 | 7 | pub fn get_glob_set(globs: &[&str]) -> Result { 8 | let mut builder = GlobSetBuilder::new(); 9 | 10 | for &glob_str in globs { 11 | let glob = GlobBuilder::new(glob_str) 12 | // NOTE: Important to set to true, otherwise single "*" will pass through "/". 13 | .literal_separator(true) 14 | .build() 15 | .map_err(|e| Error::GlobCantNew { 16 | glob: glob_str.to_string(), 17 | cause: e, 18 | })?; 19 | builder.add(glob); 20 | } 21 | 22 | let glob_set = builder.build().map_err(|e| Error::GlobSetCantBuild { 23 | globs: globs.iter().map(|&v| v.to_string()).collect(), 24 | cause: e, 25 | })?; 26 | 27 | Ok(glob_set) 28 | } 29 | 30 | pub fn longest_base_path_wild_free(pattern: &SPath) -> SPath { 31 | let path = Utf8PathBuf::from(pattern); 32 | let mut base_path = Utf8PathBuf::new(); 33 | 34 | for component in path.components() { 35 | let component_str = component.as_os_str().to_string_lossy(); 36 | if component_str.contains('*') || component_str.contains('?') { 37 | break; 38 | } 39 | base_path.push(component); 40 | } 41 | 42 | SPath::new(base_path) 43 | } 44 | 45 | /// Computes the maximum depth required for a set of glob patterns. 46 | /// 47 | /// Logic: 48 | /// 1) If a depth is provided via the argument, it is returned directly. 49 | /// 2) Otherwise, if any pattern contains "**", returns TOP_MAX_DEPTH. 50 | /// 3) Else, calculates the maximum folder level from patterns (using the folder count), 51 | /// regardless if they contain a single "*" or only "/". 52 | /// 53 | /// Returns at least 1. 54 | pub fn get_depth(patterns: &[&str], depth: Option) -> usize { 55 | if let Some(user_depth) = depth { 56 | return user_depth; 57 | } 58 | for &g in patterns { 59 | if g.contains("**") { 60 | return TOP_MAX_DEPTH; 61 | } 62 | } 63 | let mut max_depth = 0; 64 | for &g in patterns { 65 | let depth_count = g.matches(['\\', '/']).count() + 1; 66 | if depth_count > max_depth { 67 | max_depth = depth_count; 68 | } 69 | } 70 | max_depth.max(1) 71 | } 72 | 73 | // region: --- Tests 74 | 75 | #[cfg(test)] 76 | mod tests { 77 | use super::*; 78 | type Result = core::result::Result>; 79 | 80 | #[test] 81 | fn test_glob_get_depth_no_depth_simple() -> Result<()> { 82 | // -- Setup & Fixtures 83 | let test_cases: &[(&[&str], usize)] = &[ 84 | (&["*/*"], 2), 85 | (&["some/path/**/and*/"], TOP_MAX_DEPTH), 86 | (&["*"], 1), 87 | (&["a/b", "c/d/e/f"], 4), 88 | (&[], 1), 89 | ]; 90 | 91 | // -- Exec & Check 92 | for &(patterns, expected) in test_cases { 93 | // -- Exec: Call get_depth without a provided depth 94 | let depth = get_depth(patterns, None); 95 | // -- Check: Verify returned depth matches expected value 96 | assert_eq!( 97 | depth, expected, 98 | "For patterns {patterns:?}, expected depth {expected}, got {depth}", 99 | ); 100 | } 101 | Ok(()) 102 | } 103 | 104 | #[test] 105 | fn test_glob_get_depth_with_depth_custom() -> Result<()> { 106 | // -- Setup & Fixtures 107 | let test_cases: &[(&[&str], usize, usize)] = &[ 108 | (&["*/*"], 5, 5), 109 | (&["some/path/**/and*/"], 10, 10), 110 | (&["*"], 3, 3), 111 | (&["a/b", "c/d/e/f"], 7, 7), 112 | (&[], 4, 4), 113 | ]; 114 | 115 | // -- Exec & Check 116 | for &(patterns, provided_depth, expected) in test_cases { 117 | // -- Exec: Call get_depth with the provided depth value 118 | let depth = get_depth(patterns, Some(provided_depth)); 119 | // -- Check: Verify returned depth equals expected value 120 | assert_eq!( 121 | depth, expected, 122 | "For patterns {patterns:?} with provided depth {provided_depth}, expected depth {expected}, got {depth}", 123 | ); 124 | } 125 | Ok(()) 126 | } 127 | } 128 | 129 | // endregion: --- Tests 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simple-fs 2 | 3 | [simple-fs](https://github.com/jeremychone/rust-simple-fs) is a crate that provides a set of convenient and common file APIs built on `std::fs`, [walkdir](https://crates.io/crates/walkdir), and [globset](https://crates.io/crates/globset). 4 | 5 | ## Concept 6 | 7 | `simple-fs` operates under the assumption that paths that are not `utf8` are not visible to the API, simplifying many of the path-related APIs. 8 | 9 | The main construct of `simple-fs` is the `SPath` structure, which contains a `Utf8PathBuf` and ensures the following: 10 | 11 | - It is UTF-8 by contract. 12 | - Posix normalizes the path, meaning only `/` (no `\`), and redundant `//` or `/./` are collapsed to a single `./`. 13 | - It does not have any `\\?\` on Windows. 14 | 15 | The `SFile` is a File struct that contains an `SPath`. 16 | 17 | By applying the above rules, path/file APIs can be drastically simplified, and both structs offer many Path functions, with `&str` as the return type. 18 | 19 | This crate also offers a simple and scalable way to list or iterate on files, given a glob: 20 | 21 | - `iter_files(dir, include_globs: Option<&[&str]>, list_options: Option) -> Result` 22 | - `list_files(dir, include_globs: Option<&[&str]>, list_options: Option) -> Result>` 23 | - `ensure_dir(dir_path)` makes sure all the directory paths are created. 24 | - `ensure_file_dir(file_path)` makes sure the file directory exists. 25 | 26 | The crate also includes other convenient, common APIs: 27 | 28 | - `read_to_string`, which reports the file path if not found. 29 | - `get_buf_reader`, which also reports the file path if not found or in case of an error. 30 | 31 | For more control, it is recommended to use `std::fs`, `walkdir`, `globset`, and other crates directly. 32 | 33 | This is a very early implementation, with more to come. 34 | 35 | Happy coding! 36 | 37 | ## Cargo Features 38 | 39 | | Feature | Functions Included | 40 | |-------------|--------------------------------------------------| 41 | | `with-json` | `load_json`, `save_json`, `save_json_pretty` | 42 | | `with-toml` | `load_toml`, `save_toml` | 43 | | `bin-nums` | `save_be_f64`, `load_be_f64`, `save_le_f64`, ... | 44 | | `full` | All the above. | 45 | | default | None of the above. See below. | 46 | 47 | ## Notable Changes 48 | 49 | - `0.9.0-alpha.x` Same API (for now), but new optimized `list_files(...)` (hence the large version jump) 50 | - `0.8.x` Removed 'target/' and 'node_modules/' from the default excludes (too presumptive) 51 | - `0.7.x` `SMeta.size` is now `u64` (changed from `i64`), new APIs 52 | - `0.6.x` 53 | - Now uses Utf8 by default; std path moved to `..std_path..` naming. 54 | - Now normalizes all SPath to be Posix based (i.e., `/` and removes redundant `//` and `/./`). 55 | + `SPath/SFile`. 56 | - `!` Deprecated '.to_str()', now '.as_str()' 57 | - `!` .diff(..) - Now takes AsRef Utf8Path, returns Option (use try_diff(..) for Result) 58 | - `+` Add `collapse`, `into_collapsed`, `is_collapsed`, `try_collapse` 59 | - `!` `.clean(..)` is replaced by `.collapse()` 60 | - `+` list/iter files/dirs - Added support for negative glob patterns in include_globs (convenience). 61 | - `!` API CHANGE - Now all default to Utf8Path (from camino crate). Use `std_path...()` for the standard path. 62 | - `^` sfile/spath - Added is_absolute/is_relative passthrough. 63 | - `0.5.0` 64 | - Internally uses camino, utf8path. 65 | - Reimplementation of the iter_files iterator, supporting absolute path globs out of the base directory. 66 | - `0.4.0` 67 | - Update to `notify 8` (should not have any API changes) 68 | - API CHANGE - SPath - Now `SPath::from(&str/&String,String)` (no need for `try_from`) 69 | - `0.3.1` from `0.3.0` 70 | - This is a fix; however, it can change behavior on `list/iter` files. 71 | - Previously, the glob `*` was traversing into subfolders `/`, which was not the intended behavior. 72 | - Now, in `0.3.1`, it uses the glob `literal_separator = true`, so it won't descend further. 73 | - You can now use `*.rs` to list direct descendants and `**/*.rs` for nested files. 74 | - The glob also needs to include the `dir` given in the list; otherwise, set `ListOptions.relative_glob = true` to make it relative. 75 | - `0.3.x` from `0.2.x` 76 | - API CHANGE - watch - Changed the rx to be [flume](https://crates.io/crates/flume) based (works on both sync/async). 77 | - `0.2.0` from `0.1.x` 78 | - API CHANGE - Now .file_name() and .file_stem() return Option<&str>; use .name() or .stem() to get &str. -------------------------------------------------------------------------------- /src/watch.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result, SPath}; 2 | use notify::{self, RecommendedWatcher, RecursiveMode}; 3 | use notify_debouncer_full::{DebounceEventHandler, DebounceEventResult, Debouncer, RecommendedCache, new_debouncer}; 4 | use std::path::Path; 5 | // use std::sync::mpsc::{channel, Receiver, Sender}; 6 | use std::time::Duration; 7 | 8 | // -- Re-export some DebouncedEvent 9 | use flume::{Receiver, Sender}; 10 | pub use notify_debouncer_full::DebouncedEvent; 11 | use std::collections::HashSet; 12 | 13 | const WATCH_DEBOUNCE_MS: u64 = 200; 14 | 15 | // region: --- SimpleEvent 16 | 17 | /// A greatly simplified file event struct, containing only one path and one simplified event kind. 18 | /// Additionally, these will be debounced on top of the debouncer to ensure only one path/kind per debounced event list. 19 | #[derive(Debug)] 20 | pub struct SEvent { 21 | pub spath: SPath, 22 | pub skind: SEventKind, 23 | } 24 | 25 | /// Simplified event kind. 26 | #[derive(Debug, Clone, Eq, Hash, PartialEq)] 27 | pub enum SEventKind { 28 | Create, 29 | Modify, 30 | Remove, 31 | Other, 32 | } 33 | 34 | impl From for SEventKind { 35 | fn from(val: notify::EventKind) -> Self { 36 | match val { 37 | notify::EventKind::Any => SEventKind::Other, 38 | notify::EventKind::Access(_) => SEventKind::Other, 39 | notify::EventKind::Create(_) => SEventKind::Create, 40 | notify::EventKind::Modify(_) => SEventKind::Modify, 41 | notify::EventKind::Remove(_) => SEventKind::Remove, 42 | notify::EventKind::Other => SEventKind::Other, 43 | } 44 | } 45 | } 46 | 47 | /// A simplified watcher struct containing a receiver for file system events and an internal debouncer. 48 | #[allow(unused)] 49 | pub struct SWatcher { 50 | pub rx: Receiver>, 51 | // Note: Here we keep the debouncer so that it does not get dropped and continues to run. 52 | notify_full_debouncer: Debouncer, 53 | } 54 | 55 | // endregion: --- SimpleEvent 56 | 57 | /// A simplified watcher that monitors a path (file or directory) and returns an `SWatcher` object with a 58 | /// standard mpsc Receiver for a `Vec`. 59 | /// Each `SEvent` contains one `spath` and one simplified event kind (`SEventKind`). 60 | /// This will ignore any path that cannot be converted to a string (i.e., it will only trigger events if the path is valid UTF-8) 61 | pub fn watch(path: impl AsRef) -> Result { 62 | let (tx, rx) = flume::unbounded(); 63 | 64 | let path = path.as_ref(); 65 | let handler = EventHandler { tx }; 66 | let mut debouncer = 67 | new_debouncer(Duration::from_millis(WATCH_DEBOUNCE_MS), None, handler).map_err(|err| Error::FailToWatch { 68 | path: path.to_string_lossy().to_string(), 69 | cause: err.to_string(), 70 | })?; 71 | 72 | if !path.exists() { 73 | return Err(Error::CantWatchPathNotFound(path.to_string_lossy().to_string())); 74 | } 75 | 76 | debouncer 77 | .watch(path, RecursiveMode::Recursive) 78 | .map_err(|err| Error::FailToWatch { 79 | path: path.to_string_lossy().to_string(), 80 | cause: err.to_string(), 81 | })?; 82 | 83 | let swatcher = SWatcher { 84 | rx, 85 | notify_full_debouncer: debouncer, 86 | }; 87 | 88 | Ok(swatcher) 89 | } 90 | 91 | /// Event Handler that propagates a simplified Vec 92 | struct EventHandler { 93 | tx: Sender>, 94 | } 95 | 96 | impl DebounceEventHandler for EventHandler { 97 | fn handle_event(&mut self, result: DebounceEventResult) { 98 | match result { 99 | Ok(events) => { 100 | let sevents = build_sevents(events); 101 | if !sevents.is_empty() { 102 | let _ = self.tx.send(sevents); 103 | } 104 | } 105 | Err(err) => println!("simple-fs - handle_event error {err:?}"), // may want to trace 106 | } 107 | } 108 | } 109 | 110 | #[derive(Hash, Eq, PartialEq)] 111 | struct SEventKey { 112 | spath_string: String, 113 | skind: SEventKind, 114 | } 115 | 116 | fn build_sevents(events: Vec) -> Vec { 117 | let mut sevents_set: HashSet = HashSet::new(); 118 | 119 | let mut sevents = Vec::new(); 120 | 121 | for devent in events { 122 | let event = devent.event; 123 | let skind = SEventKind::from(event.kind); 124 | 125 | for path in event.paths { 126 | if let Some(spath) = SPath::from_std_path_buf_ok(path) { 127 | let key = SEventKey { 128 | spath_string: spath.to_string(), 129 | skind: skind.clone(), 130 | }; 131 | 132 | // If this spath/skind is not in the set, then add it to the sevents list 133 | if !sevents_set.contains(&key) { 134 | sevents.push(SEvent { 135 | spath, 136 | skind: skind.clone(), 137 | }); 138 | 139 | sevents_set.insert(key); 140 | } 141 | } 142 | } 143 | } 144 | 145 | sevents 146 | } 147 | -------------------------------------------------------------------------------- /src/span/csv_spans.rs: -------------------------------------------------------------------------------- 1 | use crate::spath::SPath; 2 | use crate::{Error, Result, open_file}; 3 | use std::io::{self, Read}; 4 | 5 | /// CSV-aware record spans: returns byte ranges [start, end) for each *row*. 6 | /// - Treats '\n' as a record separator only when **not** inside quotes. 7 | /// - For CRLF, the '\r' is excluded from the end bound. 8 | /// - Supports `""` as an escaped quote inside quoted fields. 9 | /// - Streams in chunks; does *not* read the whole file into memory. 10 | pub fn csv_row_spans(path: impl AsRef) -> Result> { 11 | let path = path.as_ref(); 12 | let mut f = open_file(path)?; 13 | csv_row_spans_from_reader(&mut f).map_err(|err| Error::FileCantRead((path, err).into())) 14 | } 15 | 16 | // region: --- Support 17 | 18 | fn csv_row_spans_from_reader(r: &mut R) -> io::Result> { 19 | let mut spans: Vec<(usize, usize)> = Vec::new(); 20 | 21 | // 64 KiB chunks: good balance of cacheability vs syscalls. 22 | let mut buf = [0u8; 64 * 1024]; 23 | 24 | // Absolute position of start of `buf` in file. 25 | let mut file_pos: usize = 0; 26 | // Absolute start offset of the current record. 27 | let mut rec_start: usize = 0; 28 | 29 | // CSV quote state across chunk boundaries. 30 | let mut in_quotes: bool = false; 31 | // We saw a '"' at the end of the previous byte; need to decide if it’s 32 | // a closing quote or the first of a `""` escape when we see the next byte. 33 | let mut quote_pending: bool = false; 34 | 35 | // Track CR immediately before '\n' across chunk boundary. 36 | let mut prev_byte_is_cr: bool = false; 37 | 38 | loop { 39 | let n = r.read(&mut buf)?; 40 | if n == 0 { 41 | break; 42 | } 43 | let chunk = &buf[..n]; 44 | 45 | let mut i = 0usize; 46 | while i < n { 47 | let b = chunk[i]; 48 | 49 | // Resolve a pending quote (from previous byte/chunk) if any. 50 | if quote_pending { 51 | if b == b'"' { 52 | // Escaped quote "" inside a quoted field. 53 | // Consume this byte as the second quote of the escape. 54 | quote_pending = false; 55 | // Stay in_quotes; the pair represents a literal '"'. 56 | i += 1; 57 | prev_byte_is_cr = false; 58 | continue; 59 | } else { 60 | // Previous '"' was a closing quote. 61 | in_quotes = false; 62 | quote_pending = false; 63 | // Fall through to process current byte normally. 64 | } 65 | } 66 | 67 | match b { 68 | b'"' => { 69 | if in_quotes { 70 | // Might be closing quote, but need lookahead to disambiguate "". 71 | quote_pending = true; 72 | } else { 73 | // Enter quoted field. 74 | in_quotes = true; 75 | // No pending: we only set pending when *inside* quotes. 76 | } 77 | } 78 | b'\n' => { 79 | if !in_quotes && !quote_pending { 80 | // This is a record delimiter. Compute end (exclude preceding \r). 81 | let abs_nl = file_pos + i; 82 | let end = if i > 0 { 83 | if chunk[i - 1] == b'\r' { abs_nl - 1 } else { abs_nl } 84 | } else if prev_byte_is_cr { 85 | abs_nl - 1 86 | } else { 87 | abs_nl 88 | }; 89 | spans.push((rec_start, end)); 90 | rec_start = abs_nl + 1; 91 | } 92 | } 93 | _ => { /* regular byte */ } 94 | } 95 | 96 | prev_byte_is_cr = b == b'\r'; 97 | i += 1; 98 | } 99 | 100 | // If chunk ended with a '"' inside quotes, we have to defer the decision. 101 | // `quote_pending` already encodes that state correctly. 102 | // If chunk ended with '\r', remember it for CRLF spanning chunks: 103 | // handled via `prev_byte_is_cr` above. 104 | 105 | file_pos += n; 106 | } 107 | 108 | // End-of-file: close any pending quote decision (treat as closing if still pending). 109 | #[allow(unused)] 110 | if quote_pending { 111 | in_quotes = false; 112 | quote_pending = false; 113 | } 114 | 115 | // Final record if file doesn’t end with '\n' 116 | if rec_start < file_pos { 117 | spans.push((rec_start, file_pos)); 118 | } 119 | 120 | Ok(spans) 121 | } 122 | 123 | // endregion: --- Support 124 | 125 | // region: --- Tests 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | type Result = core::result::Result>; // For tests. 130 | 131 | use super::*; 132 | 133 | #[test] 134 | fn test_span_csv_row_spans_simple() -> Result<()> { 135 | // -- Setup & Fixtures 136 | let path = SPath::from("tests-data/example.csv"); 137 | 138 | // -- Exec 139 | let spans = csv_row_spans(&path)?; 140 | 141 | // -- Check 142 | assert_eq!(spans.len(), 4, "should find 4 CSV records (including header)"); 143 | 144 | let expected = [ 145 | "name,age,comment", 146 | "Alice,30,\"hello, world\"", 147 | "Bob,25,\"Line with \"\"quote\"\"\"", 148 | "Carol,28,\"multi\nline with \"\"quotes\"\" inside\"", 149 | ]; 150 | 151 | for (i, exp) in expected.iter().enumerate() { 152 | let (s, e) = spans.get(i).copied().ok_or("missing expected span")?; 153 | let got = crate::read_span(&path, s, e)?; 154 | assert_eq!(&got, exp); 155 | } 156 | 157 | Ok(()) 158 | } 159 | } 160 | 161 | // endregion: --- Tests 162 | -------------------------------------------------------------------------------- /dev/spec/internal-api-reference.md: -------------------------------------------------------------------------------- 1 | # simple-fs – Internal APIs 2 | 3 | This document describes internal types, structs, functions, and constants primarily used for internal logic, implementation details, and module coordination. 4 | 5 | ## Core / Constants 6 | 7 | ### `src/lib.rs` 8 | 9 | ```rust 10 | const TOP_MAX_DEPTH: usize = 100; 11 | ``` 12 | 13 | ## `src/error.rs` 14 | 15 | ### Types 16 | 17 | ```rust 18 | pub enum Cause { 19 | Custom(String), 20 | Io(Box), 21 | #[cfg(feature = "with-json")] 22 | SerdeJson(Box), 23 | #[cfg(feature = "with-toml")] 24 | TomlDe(Box), 25 | #[cfg(feature = "with-toml")] 26 | TomlSer(Box), 27 | } 28 | ``` 29 | 30 | ```rust 31 | pub struct PathAndCause { 32 | pub path: String, 33 | pub cause: Cause, 34 | } 35 | ``` 36 | 37 | ### PathAndCause Conversions 38 | 39 | ```rust 40 | impl From<(&Path, io::Error)> for PathAndCause 41 | impl From<(&SPath, io::Error)> for PathAndCause 42 | impl From<(&SPath, std::time::SystemTimeError)> for PathAndCause 43 | 44 | // Requires feature = "with-json" 45 | impl From<(&Path, serde_json::Error)> for PathAndCause 46 | 47 | // Requires feature = "with-toml" 48 | impl From<(&Path, toml::de::Error)> for PathAndCause 49 | impl From<(&Path, toml::ser::Error)> for PathAndCause 50 | ``` 51 | 52 | ## `src/spath.rs` / `src/sfile.rs` (Validation) 53 | 54 | ### `src/spath.rs` 55 | 56 | ```rust 57 | pub(crate) fn validate_spath_for_result(path: impl Into) -> Result 58 | pub(crate) fn validate_spath_for_option(path: impl Into) -> Option 59 | ``` 60 | 61 | ### `src/sfile.rs` 62 | 63 | ```rust 64 | fn validate_sfile_for_result(path: &SPath) -> Result<()> 65 | fn validate_sfile_for_option(path: &SPath) -> Option<()> 66 | ``` 67 | 68 | ## `src/common/pretty.rs` 69 | 70 | ### Functions 71 | 72 | ```rust 73 | pub fn pretty_size_with_options(size_in_bytes: u64, options: impl Into) -> String 74 | ``` 75 | 76 | ### Constants 77 | 78 | ```rust 79 | const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; // Internal to pretty_size_with_options impl 80 | ``` 81 | 82 | ## `src/featured` (Feature Gated) 83 | 84 | ### `src/featured/bin_nums.rs` (Requires `bin-nums`) 85 | 86 | Internal implementation functions for loading binary numbers. 87 | 88 | ```rust 89 | // Generic loader function templates 90 | fn load_f64(file_path: &Path, read_fn: fn(buf: &[u8]) -> f64) -> Result> 91 | fn save_f64(file_path: &Path, data: &[f64], write_fn: fn(buf: &mut [u8], n: f64)) -> Result<()> 92 | // ... Similar functions exist for f32, u64, u32, u16, i64, i32, i16 types. 93 | ``` 94 | 95 | ### `src/featured/with_json/save.rs` (Requires `with-json`) 96 | 97 | ```rust 98 | const JSON_LINES_BUFFER_SIZE: usize = 100; 99 | fn save_json_impl(file_path: &Path, data: &T, pretty: bool) -> Result<()> 100 | where 101 | T: serde::Serialize, 102 | ``` 103 | 104 | ## `src/list` (Iteration & Globbing) 105 | 106 | ### Iterators 107 | 108 | ```rust 109 | pub struct GlobsDirIter { 110 | inner: Box>, 111 | } 112 | ``` 113 | 114 | ```rust 115 | pub struct GlobsFileIter { 116 | inner: Box>, 117 | } 118 | ``` 119 | 120 | ```rust 121 | struct GlobGroup { 122 | base: SPath, 123 | patterns: Vec, 124 | prefixes: Vec, 125 | } 126 | ``` 127 | 128 | ### Support Functions (`src/list/globs_file_iter.rs`) 129 | 130 | ```rust 131 | fn process_globs(main_base: &SPath, globs: &[&str]) -> Result> 132 | fn relative_from_absolute(glob: &SPath, group_base: &SPath) -> String 133 | fn directory_matches_allowed_prefixes(path: &SPath, base: &SPath, prefixes: &[String]) -> bool 134 | fn glob_literal_prefixes(pattern: &str) -> Vec 135 | fn expand_brace_segment(segment: &str) -> Option> 136 | fn segment_contains_wildcard(segment: &str) -> bool 137 | fn append_adjusted(target: &mut Vec, values: &[String]) 138 | fn normalize_prefixes(prefixes: &mut Vec) 139 | ``` 140 | 141 | ### Support Functions (`src/list/sort.rs`) 142 | 143 | ```rust 144 | fn match_index_for_path(path: &SPath, matchers: &[(usize, GlobMatcher)], end_weighted: bool) -> usize 145 | ``` 146 | 147 | ## `src/safer_remove/safer_remove_impl.rs` (Safety Checks) 148 | 149 | ```rust 150 | fn check_path_for_deletion_safety(path: &SPath, options: &SaferRemoveOptions<'_>) -> Result<()> 151 | ``` 152 | 153 | ## `src/span` (IO Implementation) 154 | 155 | ### `src/span/csv_spans.rs` 156 | 157 | ```rust 158 | fn csv_row_spans_from_reader(r: &mut R) -> io::Result> 159 | ``` 160 | 161 | ### `src/span/line_spans.rs` 162 | 163 | ```rust 164 | fn line_spans_from_reader(r: &mut R) -> io::Result> 165 | ``` 166 | 167 | ### `src/span/read_span.rs` 168 | 169 | ```rust 170 | fn read_exact_at(file: &File, offset: u64, len: usize) -> io::Result> 171 | ``` 172 | 173 | ## `src/watch.rs` (File Watching) 174 | 175 | ### Types 176 | 177 | ```rust 178 | struct EventHandler { 179 | tx: Sender>, 180 | } 181 | ``` 182 | 183 | ```rust 184 | #[derive(Hash, Eq, PartialEq)] 185 | struct SEventKey { 186 | spath_string: String, 187 | skind: SEventKind, 188 | } 189 | ``` 190 | 191 | ### Functions 192 | 193 | ```rust 194 | fn build_sevents(events: Vec) -> Vec 195 | ``` 196 | 197 | ### Constants 198 | 199 | ```rust 200 | const WATCH_DEBOUNCE_MS: u64 = 200; 201 | ``` 202 | -------------------------------------------------------------------------------- /src/safer_remove/safer_remove_impl.rs: -------------------------------------------------------------------------------- 1 | use crate::SPath; 2 | use crate::error::{Cause, PathAndCause}; 3 | use crate::safer_remove::SaferRemoveOptions; 4 | use crate::{Error, Result}; 5 | use std::fs; 6 | 7 | /// Safely deletes a directory if it passes safety checks. 8 | /// 9 | /// Safety checks (based on options): 10 | /// - If `restrict_to_current_dir` is true, the directory path must be below the current directory 11 | /// - If `must_contain_any` is set, the path must contain at least one of the specified patterns 12 | /// - If `must_contain_all` is set, the path must contain all of the specified patterns 13 | /// 14 | /// Returns Ok(true) if the directory was deleted, Ok(false) if it didn't exist. 15 | /// Returns an error if safety checks fail or deletion fails. 16 | pub fn safer_remove_dir<'a>(dir_path: &SPath, options: impl Into>) -> Result { 17 | let options = options.into(); 18 | 19 | // If path doesn't exist, just return false 20 | if !dir_path.exists() { 21 | return Ok(false); 22 | } 23 | 24 | check_path_for_deletion_safety::(dir_path, &options)?; 25 | 26 | // Perform the deletion 27 | fs::remove_dir_all(dir_path.as_std_path()).map_err(|e| { 28 | Error::DirNotSafeToRemove(PathAndCause { 29 | path: dir_path.to_string(), 30 | cause: Cause::Io(Box::new(e)), 31 | }) 32 | })?; 33 | 34 | Ok(true) 35 | } 36 | 37 | /// Safely deletes a file if it passes safety checks. 38 | /// 39 | /// Safety checks (based on options): 40 | /// - If `restrict_to_current_dir` is true, the file path must be below the current directory 41 | /// - If `must_contain_any` is set, the path must contain at least one of the specified patterns 42 | /// - If `must_contain_all` is set, the path must contain all of the specified patterns 43 | /// 44 | /// Returns Ok(true) if the file was deleted, Ok(false) if it didn't exist. 45 | /// Returns an error if safety checks fail or deletion fails. 46 | pub fn safer_remove_file<'a>(file_path: &SPath, options: impl Into>) -> Result { 47 | let options = options.into(); 48 | 49 | // If path doesn't exist, just return false 50 | if !file_path.exists() { 51 | return Ok(false); 52 | } 53 | 54 | check_path_for_deletion_safety::(file_path, &options)?; 55 | 56 | // Perform the deletion 57 | fs::remove_file(file_path.as_std_path()).map_err(|e| { 58 | Error::FileNotSafeToRemove(PathAndCause { 59 | path: file_path.to_string(), 60 | cause: Cause::Io(Box::new(e)), 61 | }) 62 | })?; 63 | 64 | Ok(true) 65 | } 66 | 67 | // region: --- Support 68 | 69 | /// Performs safety checks before deletion based on the provided options: 70 | /// 1. If `restrict_to_current_dir` is true, path must be below the current working directory. 71 | /// 2. If `must_contain_any` is set, path must contain at least one of those patterns. 72 | /// 3. If `must_contain_all` is set, path must contain all of those patterns. 73 | /// 74 | /// The const generic IS_DIR determines whether this is checking a directory (true) or file (false). 75 | fn check_path_for_deletion_safety(path: &SPath, options: &SaferRemoveOptions<'_>) -> Result<()> { 76 | // Resolve the path to absolute 77 | let resolved = path.canonicalize()?; 78 | let resolved_str = resolved.as_str(); 79 | let path_str = path.as_str(); 80 | 81 | // -- Safety checks 82 | let mut error_causes = Vec::new(); 83 | 84 | // Check that the path is below current directory (if enabled) 85 | if options.restrict_to_current_dir { 86 | let current_dir = std::env::current_dir().map_err(|e| { 87 | let pac = PathAndCause { 88 | path: path.to_string(), 89 | cause: Cause::Io(Box::new(e)), 90 | }; 91 | if IS_DIR { 92 | Error::DirNotSafeToRemove(pac) 93 | } else { 94 | Error::FileNotSafeToRemove(pac) 95 | } 96 | })?; 97 | let current_dir_path = SPath::from_std_path_buf(current_dir)?; 98 | let current_resolved = current_dir_path.canonicalize()?; 99 | let current_str = current_resolved.as_str(); 100 | 101 | if !resolved_str.starts_with(current_str) { 102 | error_causes.push(format!("is not below current directory '{current_resolved}'")); 103 | } 104 | } 105 | 106 | // Check must_contain_any 107 | if let Some(patterns) = options.must_contain_any { 108 | if patterns.is_empty() { 109 | error_causes.push("must_contain_any cannot be an empty list (use None to disable)".to_string()); 110 | } else { 111 | let has_any = patterns.iter().any(|s| path_str.contains(s)); 112 | if !has_any { 113 | error_causes.push(format!("does not contain any of the required patterns: {patterns:?}")); 114 | } 115 | } 116 | } 117 | 118 | // Check must_contain_all 119 | if let Some(patterns) = options.must_contain_all { 120 | if patterns.is_empty() { 121 | error_causes.push("must_contain_all cannot be an empty list (use None to disable)".to_string()); 122 | } else { 123 | let missing: Vec<_> = patterns.iter().filter(|s| !path_str.contains(*s)).collect(); 124 | if !missing.is_empty() { 125 | error_causes.push(format!("does not contain all required patterns, missing: {missing:?}")); 126 | } 127 | } 128 | } 129 | 130 | if !error_causes.is_empty() { 131 | let cause_msg = format!("Safety check failed: {}", error_causes.join("; ")); 132 | let path_and_cause = PathAndCause { 133 | path: path.to_string(), 134 | cause: Cause::Custom(cause_msg), 135 | }; 136 | 137 | if IS_DIR { 138 | return Err(Error::DirNotSafeToRemove(path_and_cause)); 139 | } else { 140 | return Err(Error::FileNotSafeToRemove(path_and_cause)); 141 | } 142 | } 143 | 144 | Ok(()) 145 | } 146 | 147 | // endregion: --- Support 148 | -------------------------------------------------------------------------------- /src/list/globs_dir_iter.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, ListOptions, Result, SPath}; 2 | use globset::{Glob, GlobSetBuilder}; 3 | use std::path::Path; 4 | use walkdir::WalkDir; 5 | 6 | pub struct GlobsDirIter { 7 | inner: Box>, 8 | } 9 | 10 | impl GlobsDirIter { 11 | /// Create a new GlobsDirIter for directories. 12 | /// 13 | /// - `dir`: the starting directory. 14 | /// - `include_globs`: optional slice of glob patterns. If provided, only directories whose 15 | /// full path matches at least one pattern will be returned. Patterns starting with `!` 16 | /// are treated as exclusion patterns. 17 | /// - `list_options`: optional list options, e.g., limiting recursion depth. 18 | /// 19 | /// Returns a Result with GlobsDirIter or an appropriate Error. 20 | pub fn new( 21 | dir: impl AsRef, 22 | include_globs: Option<&[&str]>, 23 | list_options: Option>, 24 | ) -> Result { 25 | let base_dir = SPath::from_std_path(dir.as_ref())?; 26 | 27 | // Process include_globs to separate includes and negated excludes (starting with !) 28 | let (include_patterns, negated_excludes) = if let Some(globs) = include_globs { 29 | let mut includes = Vec::new(); 30 | let mut excludes = Vec::new(); 31 | 32 | for &pattern in globs { 33 | if let Some(negative_pattern) = pattern.strip_prefix("!") { 34 | excludes.push(negative_pattern); 35 | } else { 36 | includes.push(pattern); 37 | } 38 | } 39 | 40 | // If all patterns were negated, use a default include pattern 41 | if includes.is_empty() && !excludes.is_empty() { 42 | (vec!["**"], excludes) 43 | } else { 44 | (includes, excludes) 45 | } 46 | } else { 47 | (vec![], Vec::new()) 48 | }; 49 | 50 | // Create or extend the ListOptions with negated_excludes 51 | let list_options = if !negated_excludes.is_empty() { 52 | match list_options { 53 | Some(opts) => { 54 | let mut new_opts = ListOptions { 55 | exclude_globs: opts.exclude_globs.clone(), 56 | relative_glob: opts.relative_glob, 57 | depth: opts.depth, 58 | }; 59 | 60 | if let Some(existing_excludes) = &mut new_opts.exclude_globs { 61 | // Append negated excludes to existing excludes 62 | let mut combined = existing_excludes.clone(); 63 | combined.extend(negated_excludes); 64 | new_opts.exclude_globs = Some(combined); 65 | } else { 66 | // Create new excludes from negated patterns 67 | new_opts.exclude_globs = Some(negated_excludes); 68 | } 69 | 70 | Some(new_opts) 71 | } 72 | None => { 73 | // Create a new ListOptions with just the negated excludes 74 | Some(ListOptions { 75 | exclude_globs: Some(negated_excludes), 76 | relative_glob: false, 77 | depth: None, 78 | }) 79 | } 80 | } 81 | } else { 82 | list_options 83 | }; 84 | 85 | // Build the include GlobSet from provided patterns if any 86 | let include_globset = if !include_patterns.is_empty() { 87 | let mut builder = GlobSetBuilder::new(); 88 | for pattern in include_patterns.iter() { 89 | builder.add(Glob::new(pattern).map_err(|e| Error::GlobCantNew { 90 | glob: pattern.to_string(), 91 | cause: e, 92 | })?); 93 | } 94 | Some(builder.build().map_err(|e| Error::GlobSetCantBuild { 95 | globs: include_patterns.iter().map(|&s| s.to_string()).collect(), 96 | cause: e, 97 | })?) 98 | } else { 99 | None 100 | }; 101 | 102 | // Extract exclude patterns from list_options if present 103 | let exclude_globset = if let Some(opts) = &list_options { 104 | if let Some(exclude_globs) = opts.exclude_globs() { 105 | let mut builder = GlobSetBuilder::new(); 106 | for pattern in exclude_globs { 107 | builder.add(Glob::new(pattern).map_err(|e| Error::GlobCantNew { 108 | glob: pattern.to_string(), 109 | cause: e, 110 | })?); 111 | } 112 | Some(builder.build().map_err(|e| Error::GlobSetCantBuild { 113 | globs: exclude_globs.iter().map(|s| s.to_string()).collect(), 114 | cause: e, 115 | })?) 116 | } else { 117 | None 118 | } 119 | } else { 120 | None 121 | }; 122 | 123 | // Determine whether to use relative globs 124 | let use_relative_glob = list_options.as_ref().is_some_and(|o| o.relative_glob); 125 | 126 | // Determine the maximum depth 127 | let depth = list_options.as_ref().and_then(|o| o.depth); 128 | 129 | // Create the walkdir iterator 130 | let walker = WalkDir::new(base_dir.path()); 131 | let walker = if let Some(depth) = depth { 132 | walker.max_depth(depth) 133 | } else { 134 | walker 135 | }; 136 | 137 | // Build the final iterator 138 | let iter = walker 139 | .into_iter() 140 | .filter_map(|entry_result| entry_result.ok()) 141 | .filter(|entry| entry.file_type().is_dir()) 142 | .filter_map(|entry| SPath::from_std_path_ok(entry.path())) 143 | .filter(move |path| { 144 | // Skip paths that match exclude patterns 145 | if let Some(ref exclude_set) = exclude_globset { 146 | // Handle relative or absolute paths for exclude patterns 147 | if use_relative_glob { 148 | if let Some(rel_path) = path.diff(&base_dir) 149 | && exclude_set.is_match(rel_path) 150 | { 151 | return false; 152 | } 153 | } else if exclude_set.is_match(path) { 154 | return false; 155 | } 156 | } 157 | 158 | // Only include paths that match include patterns (if specified) 159 | if let Some(ref include_set) = include_globset { 160 | // Handle relative or absolute paths for include patterns 161 | if use_relative_glob { 162 | if let Some(rel_path) = path.diff(&base_dir) { 163 | include_set.is_match(rel_path) 164 | } else { 165 | false 166 | } 167 | } else { 168 | include_set.is_match(path) 169 | } 170 | } else { 171 | true // No include patterns specified, include all paths 172 | } 173 | }); 174 | 175 | Ok(Self { inner: Box::new(iter) }) 176 | } 177 | } 178 | 179 | impl Iterator for GlobsDirIter { 180 | type Item = SPath; 181 | 182 | fn next(&mut self) -> Option { 183 | self.inner.next() 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/common/pretty.rs: -------------------------------------------------------------------------------- 1 | // region: --- Pretty Size 2 | 3 | use derive_more::From; 4 | 5 | #[derive(Debug, Default, Clone, From)] 6 | pub struct PrettySizeOptions { 7 | #[from] 8 | lowest_unit: SizeUnit, 9 | } 10 | 11 | impl From<&str> for PrettySizeOptions { 12 | fn from(val: &str) -> Self { 13 | SizeUnit::new(val).into() 14 | } 15 | } 16 | 17 | impl From<&String> for PrettySizeOptions { 18 | fn from(val: &String) -> Self { 19 | SizeUnit::new(val).into() 20 | } 21 | } 22 | 23 | impl From for PrettySizeOptions { 24 | fn from(val: String) -> Self { 25 | SizeUnit::new(&val).into() 26 | } 27 | } 28 | 29 | #[derive(Debug, Clone, Default)] 30 | pub enum SizeUnit { 31 | #[default] 32 | B, 33 | KB, 34 | MB, 35 | GB, 36 | TB, 37 | } 38 | 39 | impl SizeUnit { 40 | /// Will return 41 | pub fn new(val: &str) -> Self { 42 | match val.to_uppercase().as_str() { 43 | "B" => Self::B, 44 | "KB" => Self::KB, 45 | "MB" => Self::MB, 46 | "GB" => Self::GB, 47 | "TB" => Self::TB, 48 | _ => Self::B, 49 | } 50 | } 51 | 52 | /// Index of the unit in the `UNITS` array used by [`pretty_size_with_options`]. 53 | #[inline] 54 | pub fn idx(&self) -> usize { 55 | match self { 56 | Self::B => 0, 57 | Self::KB => 1, 58 | Self::MB => 2, 59 | Self::GB => 3, 60 | Self::TB => 4, 61 | } 62 | } 63 | } 64 | 65 | impl From<&str> for SizeUnit { 66 | fn from(val: &str) -> Self { 67 | Self::new(val) 68 | } 69 | } 70 | 71 | impl From<&String> for SizeUnit { 72 | fn from(val: &String) -> Self { 73 | Self::new(val) 74 | } 75 | } 76 | 77 | impl From for SizeUnit { 78 | fn from(val: String) -> Self { 79 | Self::new(&val) 80 | } 81 | } 82 | 83 | /// Formats a byte size as a pretty, fixed-width (9 char) string with unit alignment. 84 | /// The output format is tailored to align nicely in monospaced tables. 85 | /// 86 | /// - Number is always 6 character, always right aligned. 87 | /// - Empty char 88 | /// - Unit is always 2 chars, left aligned. So, for Byte, "B", it will be "B " 89 | /// - When below 1K Byte, do not have any digits 90 | /// - Otherwise, always 2 digit, rounded 91 | /// 92 | /// ### Examples 93 | /// 94 | /// `777` -> `" 777 B "` 95 | /// `8777` -> `" 8.78 KB"` 96 | /// `88777` -> `" 88.78 KB"` 97 | /// `888777` -> `"888.78 KB"` 98 | /// `2_345_678_900` -> `" 2.35 GB"` 99 | /// 100 | /// NOTE: if in simple-fs, migh call it pretty_size() 101 | pub fn pretty_size(size_in_bytes: u64) -> String { 102 | pretty_size_with_options(size_in_bytes, PrettySizeOptions::default()) 103 | } 104 | 105 | /// Formats a byte size as a pretty, fixed-width (9 char) string with unit alignment. 106 | /// The output format is tailored to align nicely in monospaced tables. 107 | /// 108 | /// - Number is always 6 character, always right aligned. 109 | /// - Empty char 110 | /// - Unit is always 2 chars, left aligned. So, for Byte, "B", it will be "B " 111 | /// - When below 1K Byte, do not have any digits 112 | /// - Otherwise, always 2 digit, rounded 113 | /// 114 | /// ### PrettySizeOptions 115 | /// 116 | /// - `lowest_unit` 117 | /// Define the lowest unit to consider, 118 | /// For example, if `MB`, then, B and KB will be expressed in decimal 119 | /// following the formatting rules. 120 | /// 121 | /// NOTE: From String, &str, .. are implemented, so `PrettySizeOptions::from("MB")` will default to 122 | /// `PrettySizeOptions { lowest_unit: SizeUnit::MB }` (if string not match, will default to `SizeUnit::MB`) 123 | /// 124 | /// ### Examples 125 | /// 126 | /// `777` -> `" 777 B "` 127 | /// `8777` -> `" 8.78 KB"` 128 | /// `88777` -> `" 88.78 KB"` 129 | /// `888777` -> `"888.78 KB"` 130 | /// `2_345_678_900` -> `" 2.35 GB"` 131 | /// 132 | /// NOTE: if in simple-fs, migh call it pretty_size() 133 | pub fn pretty_size_with_options(size_in_bytes: u64, options: impl Into) -> String { 134 | let options = options.into(); 135 | 136 | const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; 137 | 138 | // -- Step 1: shift the value so that we start at the minimum unit requested. 139 | let min_unit_idx = options.lowest_unit.idx(); 140 | let mut size = size_in_bytes as f64; 141 | for _ in 0..min_unit_idx { 142 | size /= 1000.0; 143 | } 144 | let mut unit_idx = min_unit_idx; 145 | 146 | // -- Step 2: continue bubbling up if the number is >= 1000. 147 | while size >= 1000.0 && unit_idx < UNITS.len() - 1 { 148 | size /= 1000.0; 149 | unit_idx += 1; 150 | } 151 | 152 | let unit_str = UNITS[unit_idx]; 153 | 154 | // -- Step 3: formatting 155 | if unit_idx == 0 { 156 | // Bytes: integer, pad to 6, then add " B " 157 | let number_str = format!("{size_in_bytes:>6}"); 158 | format!("{number_str} {unit_str} ") 159 | } else { 160 | // Units KB or above: 2 decimals, pad to width, then add " unit" 161 | let number_str = format!("{size:>6.2}"); 162 | format!("{number_str} {unit_str}") 163 | } 164 | } 165 | 166 | // endregion: --- Pretty Size 167 | 168 | // region: --- Tests 169 | 170 | #[cfg(test)] 171 | mod tests { 172 | type Result = core::result::Result>; // For tests. 173 | 174 | use super::*; 175 | 176 | #[test] 177 | fn test_pretty_size() -> Result<()> { 178 | // -- Setup & Fixtures 179 | let cases = [ 180 | (777, " 777 B "), 181 | (8777, " 8.78 KB"), 182 | (88777, " 88.78 KB"), 183 | (888777, "888.78 KB"), 184 | (888700, "888.70 KB"), 185 | (200000, "200.00 KB"), 186 | (2_000_000, " 2.00 MB"), 187 | (900_000_000, "900.00 MB"), 188 | (2_345_678_900, " 2.35 GB"), 189 | (1_234_567_890_123, " 1.23 TB"), 190 | (2_345_678_900_123_456, " 2.35 PB"), 191 | (0, " 0 B "), 192 | ]; 193 | 194 | // -- Exec 195 | for &(input, expected) in &cases { 196 | let actual = pretty_size(input); 197 | assert_eq!(actual, expected, "input: {input}"); 198 | } 199 | 200 | Ok(()) 201 | } 202 | 203 | #[test] 204 | fn test_pretty_size_with_lowest_unit() -> Result<()> { 205 | // -- Setup 206 | let options = PrettySizeOptions::from("MB"); 207 | let cases = [ 208 | // 209 | (88777, " 0.09 MB"), 210 | (888777, " 0.89 MB"), 211 | (1_234_567, " 1.23 MB"), 212 | ]; 213 | 214 | // -- Exec / Check 215 | for &(input, expected) in &cases { 216 | let actual = pretty_size_with_options(input, options.clone()); 217 | assert_eq!(actual, expected, "input: {input}"); 218 | } 219 | 220 | Ok(()) 221 | } 222 | } 223 | 224 | // endregion: --- Tests 225 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::SPath; 2 | use derive_more::{Display, From}; 3 | use std::io; 4 | use std::path::Path; 5 | use std::time::SystemTimeError; 6 | 7 | pub type Result = core::result::Result; 8 | 9 | #[derive(Debug, Display)] 10 | pub enum Error { 11 | // -- Path 12 | #[display("Path is not valid UTF-8: '{_0}'")] 13 | PathNotUtf8(String), 14 | #[display("Path has no file name: '{_0}'")] 15 | PathHasNoFileName(String), 16 | #[display("Strip Prefix fail. Path '{path}' does not a base of '{prefix}'")] 17 | StripPrefix { 18 | prefix: String, 19 | path: String, 20 | }, 21 | 22 | // -- File 23 | #[display("File not found at path: '{_0}'")] 24 | FileNotFound(String), 25 | #[display("Cannot open file '{}'\nCause: {}", _0.path, _0.cause)] 26 | FileCantOpen(PathAndCause), 27 | #[display("Cannot read path '{}'\nCause: {}", _0.path, _0.cause)] 28 | FileCantRead(PathAndCause), 29 | #[display("Cannot write file '{}'\nCause: {}", _0.path, _0.cause)] 30 | FileCantWrite(PathAndCause), 31 | #[display("Cannot create file '{}'\nCause: {}", _0.path, _0.cause)] 32 | FileCantCreate(PathAndCause), 33 | #[display("File path has no parent directory: '{_0}'")] 34 | FileHasNoParent(String), 35 | 36 | // -- Remove 37 | #[display("File not safe to remove.\nPath: '{}'\nCause: {}", _0.path, _0.cause)] 38 | FileNotSafeToRemove(PathAndCause), 39 | #[display("Directory not safe to remove.\nPath: '{}'\nCause: {}", _0.path, _0.cause)] 40 | DirNotSafeToRemove(PathAndCause), 41 | 42 | // -- Sort 43 | #[display("Cannot sort by globs.\nCause: {cause}")] 44 | SortByGlobs { 45 | cause: String, 46 | }, 47 | 48 | // -- Metadata 49 | #[display("Cannot get metadata for path '{}'\nCause: {}", _0.path, _0.cause)] 50 | CantGetMetadata(PathAndCause), 51 | #[display("Cannot get 'modified' metadata for path '{}'\nCause: {}", _0.path, _0.cause)] 52 | CantGetMetadataModified(PathAndCause), 53 | 54 | // -- Time 55 | #[display("Cannot get duration from system time. Cause: {_0}")] 56 | CantGetDurationSystemTimeError(SystemTimeError), 57 | 58 | // -- Directory 59 | #[display("Cannot create directory (and parents) '{}'\nCause: {}", _0.path, _0.cause)] 60 | DirCantCreateAll(PathAndCause), 61 | 62 | // -- Path Validations 63 | #[display("Path is invalid: '{}'\nCause: {}",_0.path, _0.cause)] 64 | PathNotValidForPath(PathAndCause), 65 | 66 | // -- Glob 67 | #[display("Cannot create glob pattern '{glob}'.\nCause: {cause}")] 68 | GlobCantNew { 69 | glob: String, 70 | cause: globset::Error, 71 | }, 72 | #[display("Cannot build glob set from '{globs:?}'.\nCause: {cause}")] 73 | GlobSetCantBuild { 74 | globs: Vec, 75 | cause: globset::Error, 76 | }, 77 | 78 | // -- Watch 79 | #[display("Failed to watch path '{path}'.\nCause: {cause}")] 80 | FailToWatch { 81 | path: String, 82 | cause: String, 83 | }, 84 | #[display("Cannot watch path because it was not found: '{_0}'")] 85 | CantWatchPathNotFound(String), 86 | 87 | // -- Span 88 | SpanInvalidStartAfterEnd, 89 | SpanOutOfBounds, 90 | SpanInvalidUtf8, 91 | 92 | // -- Other 93 | #[display("Cannot compute relative path from '{base}' to '{path}'")] 94 | CannotDiff { 95 | path: String, 96 | base: String, 97 | }, 98 | #[display("Cannot Canonicalize path '{}'\nCause: {}", _0.path, _0.cause)] 99 | CannotCanonicalize(PathAndCause), 100 | 101 | // -- with-json 102 | #[cfg(feature = "with-json")] 103 | #[display("Cannot read json path '{}'\nCause: {}", _0.path, _0.cause)] 104 | JsonCantRead(PathAndCause), 105 | #[cfg(feature = "with-json")] 106 | #[display("Cannot write JSON to path '{}'\nCause: {}", _0.path, _0.cause)] 107 | JsonCantWrite(PathAndCause), 108 | #[cfg(feature = "with-json")] 109 | #[display("Error processing NDJSON: {_0}")] 110 | NdJson(String), 111 | 112 | // -- with-toml 113 | #[cfg(feature = "with-toml")] 114 | #[display("Cannot read TOML from path '{}'\nCause: {}", _0.path, _0.cause)] 115 | TomlCantRead(PathAndCause), 116 | #[cfg(feature = "with-toml")] 117 | #[display("Cannot write TOML to path '{}'\nCause: {}", _0.path, _0.cause)] 118 | TomlCantWrite(PathAndCause), 119 | } 120 | 121 | impl Error { 122 | pub fn sort_by_globs(cause: impl std::fmt::Display) -> Error { 123 | Error::SortByGlobs { 124 | cause: cause.to_string(), 125 | } 126 | } 127 | } 128 | 129 | // region: --- Cause Types 130 | 131 | #[derive(Debug, Display, From)] 132 | pub enum Cause { 133 | #[from] 134 | Custom(String), 135 | 136 | #[from] 137 | Io(Box), 138 | 139 | #[cfg(feature = "with-json")] 140 | SerdeJson(Box), 141 | 142 | #[cfg(feature = "with-toml")] 143 | TomlDe(Box), 144 | 145 | #[cfg(feature = "with-toml")] 146 | TomlSer(Box), 147 | } 148 | 149 | #[derive(Debug)] 150 | pub struct PathAndCause { 151 | pub path: String, 152 | pub cause: Cause, 153 | } 154 | 155 | // endregion: --- Cause Types 156 | 157 | // region: --- IO 158 | 159 | impl From<(&Path, io::Error)> for PathAndCause { 160 | fn from(val: (&Path, io::Error)) -> Self { 161 | PathAndCause { 162 | path: val.0.to_string_lossy().to_string(), 163 | cause: Cause::Io(Box::new(val.1)), 164 | } 165 | } 166 | } 167 | 168 | impl From<(&SPath, io::Error)> for PathAndCause { 169 | fn from(val: (&SPath, io::Error)) -> Self { 170 | PathAndCause { 171 | path: val.0.to_string(), 172 | cause: Cause::Io(Box::new(val.1)), 173 | } 174 | } 175 | } 176 | 177 | //std::time::SystemTimeError 178 | impl From<(&SPath, std::time::SystemTimeError)> for PathAndCause { 179 | fn from(val: (&SPath, std::time::SystemTimeError)) -> Self { 180 | PathAndCause { 181 | path: val.0.to_string(), 182 | cause: Cause::Custom(val.1.to_string()), 183 | } 184 | } 185 | } 186 | 187 | // endregion: --- IO 188 | 189 | // region: --- JSON 190 | 191 | #[cfg(feature = "with-json")] 192 | impl From<(&Path, serde_json::Error)> for PathAndCause { 193 | fn from(val: (&Path, serde_json::Error)) -> Self { 194 | PathAndCause { 195 | path: val.0.to_string_lossy().to_string(), 196 | cause: Cause::SerdeJson(Box::new(val.1)), 197 | } 198 | } 199 | } 200 | 201 | // endregion: --- JSON 202 | 203 | // region: --- TOML 204 | 205 | #[cfg(feature = "with-toml")] 206 | impl From<(&Path, toml::de::Error)> for PathAndCause { 207 | fn from(val: (&Path, toml::de::Error)) -> Self { 208 | PathAndCause { 209 | path: val.0.to_string_lossy().to_string(), 210 | cause: Cause::TomlDe(Box::new(val.1)), 211 | } 212 | } 213 | } 214 | 215 | #[cfg(feature = "with-toml")] 216 | impl From<(&Path, toml::ser::Error)> for PathAndCause { 217 | fn from(val: (&Path, toml::ser::Error)) -> Self { 218 | PathAndCause { 219 | path: val.0.to_string_lossy().to_string(), 220 | cause: Cause::TomlSer(Box::new(val.1)), 221 | } 222 | } 223 | } 224 | 225 | // endregion: --- TOML 226 | 227 | // region: --- Error Boilerplate 228 | 229 | impl std::error::Error for Error {} 230 | 231 | // endregion: --- Error Boilerplate 232 | -------------------------------------------------------------------------------- /tests/tests_list_dirs.rs: -------------------------------------------------------------------------------- 1 | use simple_fs::{ListOptions, iter_dirs, list_dirs}; 2 | 3 | type Result = core::result::Result>; 4 | 5 | #[test] 6 | fn test_iter_dirs_list_dirs() -> Result<()> { 7 | // -- Exec: List directories in the current working directory, no filtering. 8 | let dirs = list_dirs("./", None, None)?; 9 | 10 | // -- Check: Ensure that at least one directory is found. 11 | assert!( 12 | !dirs.is_empty(), 13 | "Expected to find at least one directory, but found none." 14 | ); 15 | 16 | // -- Check: Each returned entry must be a directory. 17 | for dir in dirs { 18 | assert!(dir.is_dir(), "Expected {} to be a directory", dir.as_str()); 19 | } 20 | 21 | Ok(()) 22 | } 23 | 24 | #[test] 25 | fn test_list_dirs_one_level_dotted() -> Result<()> { 26 | // -- Exec: List directories in the tests-data directory, no filtering. 27 | let dirs = list_dirs("./tests-data/", None, None)?; 28 | 29 | // -- Check: Ensure we find the expected directories. 30 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 31 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 32 | assert!( 33 | dir_paths.contains(&"./tests-data/another-dir"), 34 | "Should contain another-dir" 35 | ); 36 | 37 | Ok(()) 38 | } 39 | 40 | #[test] 41 | fn test_list_dirs_with_glob_pattern() -> Result<()> { 42 | // -- Exec: List directories in tests-data directory matching a glob pattern. 43 | let dirs = list_dirs("./tests-data/", Some(&["./tests-data/dir1"]), None)?; 44 | 45 | // -- Check: Ensure we only find directories matching the pattern. 46 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 47 | assert_eq!(dirs.len(), 1, "Should have 1 directory matching 'dir1'"); 48 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 49 | assert!( 50 | !dir_paths.contains(&"./tests-data/another-dir"), 51 | "Should not contain another-dir" 52 | ); 53 | 54 | Ok(()) 55 | } 56 | 57 | #[test] 58 | fn test_list_dirs_with_relative_glob() -> Result<()> { 59 | // -- Exec: List directories using relative glob pattern. 60 | let dirs = list_dirs( 61 | "./tests-data/", 62 | Some(&["another-dir"]), 63 | Some(ListOptions::default().with_relative_glob()), 64 | )?; 65 | 66 | // -- Check: Ensure we only find directories matching the pattern. 67 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 68 | assert_eq!(dirs.len(), 1, "Should have 1 directory matching 'another-dir'"); 69 | assert!( 70 | dir_paths.contains(&"./tests-data/another-dir"), 71 | "Should contain another-dir" 72 | ); 73 | assert!(!dir_paths.contains(&"./tests-data/dir1"), "Should not contain dir1"); 74 | 75 | Ok(()) 76 | } 77 | 78 | #[test] 79 | fn test_list_dirs_recursive() -> Result<()> { 80 | // -- Exec: List all directories recursively in tests-data. 81 | let dirs = list_dirs("./tests-data/", Some(&["./tests-data/**"]), None)?; 82 | 83 | // -- Check: Ensure we find all the expected directories. 84 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 85 | 86 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 87 | assert!( 88 | dir_paths.contains(&"./tests-data/dir1/dir2"), 89 | "Should contain dir1/dir2" 90 | ); 91 | assert!( 92 | dir_paths.contains(&"./tests-data/dir1/dir2/dir3"), 93 | "Should contain dir1/dir2/dir3" 94 | ); 95 | assert!( 96 | dir_paths.contains(&"./tests-data/another-dir"), 97 | "Should contain another-dir" 98 | ); 99 | assert!( 100 | dir_paths.contains(&"./tests-data/another-dir/sub-dir"), 101 | "Should contain another-dir/sub-dir" 102 | ); 103 | assert!( 104 | dir_paths.contains(&"./tests-data/another-dir/sub-dir/deep-folder"), 105 | "Should contain another-dir/sub-dir/deep-folder" 106 | ); 107 | 108 | Ok(()) 109 | } 110 | 111 | #[test] 112 | fn test_list_dirs_with_exclude_option() -> Result<()> { 113 | // -- Exec: List directories with exclusion pattern. 114 | let list_options = ListOptions::default() 115 | .with_exclude_globs(&["**/dir2", "**/dir2/**"]) 116 | .with_relative_glob(); // Add relative_glob for proper pattern matching 117 | 118 | let dirs = list_dirs("./tests-data/", Some(&["**"]), Some(list_options))?; 119 | 120 | // -- Check: Ensure excluded directories are not in the results. 121 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 122 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 123 | assert!( 124 | !dir_paths.contains(&"./tests-data/dir1/dir2"), 125 | "Should not contain dir1/dir2" 126 | ); 127 | assert!( 128 | !dir_paths.contains(&"./tests-data/dir1/dir2/dir3"), 129 | "Should not contain dir1/dir2/dir3" 130 | ); 131 | assert!( 132 | dir_paths.contains(&"./tests-data/another-dir"), 133 | "Should contain another-dir" 134 | ); 135 | 136 | Ok(()) 137 | } 138 | 139 | #[test] 140 | fn test_iter_dirs_functionality() -> Result<()> { 141 | // -- Exec: Use iter_dirs to create an iterator over directories. 142 | let dir_iter = iter_dirs("./tests-data/", None, None)?; 143 | 144 | // -- Check: Convert iterator to vector and verify results. 145 | let dirs: Vec<_> = dir_iter.collect(); 146 | assert!(dirs.len() >= 2, "Should have at least 2 directories"); 147 | 148 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 149 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 150 | assert!( 151 | dir_paths.contains(&"./tests-data/another-dir"), 152 | "Should contain another-dir" 153 | ); 154 | 155 | Ok(()) 156 | } 157 | 158 | #[test] 159 | fn test_list_dirs_absolute_path() -> Result<()> { 160 | // -- Exec: Get absolute path and use it for listing directories. 161 | let test_data_abs = std::fs::canonicalize("./tests-data/")?; 162 | let dirs = list_dirs(&test_data_abs, None, None)?; 163 | 164 | // -- Check: Verify that we can find directories using absolute path. 165 | assert!( 166 | dirs.len() >= 2, 167 | "Should have at least 2 directories using absolute path" 168 | ); 169 | 170 | // Get the directory names for easier comparison 171 | let dir_names: Vec<_> = dirs.iter().map(|p| p.name()).collect(); 172 | assert!(dir_names.contains(&"dir1"), "Should contain dir1"); 173 | assert!(dir_names.contains(&"another-dir"), "Should contain another-dir"); 174 | 175 | Ok(()) 176 | } 177 | 178 | #[test] 179 | fn test_list_dirs_with_negative_glob() -> Result<()> { 180 | // -- Exec: List directories with a negative (exclusion) pattern in include_globs. 181 | let dirs = list_dirs( 182 | "./tests-data/", 183 | Some(&["**", "!**/dir2"]), // Include all directories but exclude dir2 184 | None, 185 | )?; 186 | 187 | // -- Check: Ensure excluded directories are not in the results. 188 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 189 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 190 | assert!( 191 | !dir_paths.contains(&"./tests-data/dir1/dir2"), 192 | "Should not contain dir1/dir2" 193 | ); 194 | assert!( 195 | dir_paths.contains(&"./tests-data/another-dir"), 196 | "Should contain another-dir" 197 | ); 198 | assert!( 199 | dir_paths.contains(&"./tests-data/another-dir/sub-dir"), 200 | "Should contain another-dir/sub-dir" 201 | ); 202 | 203 | Ok(()) 204 | } 205 | 206 | #[test] 207 | fn test_list_dirs_with_multiple_negative_globs() -> Result<()> { 208 | // -- Exec: List directories with multiple negative patterns in include_globs. 209 | let dirs = list_dirs( 210 | "./tests-data/", 211 | Some(&[ 212 | "**", // Include all directories 213 | "!**/dir2", // Exclude dir2 214 | "!**/deep-folder", // Exclude deep-folder 215 | ]), 216 | None, 217 | )?; 218 | 219 | // -- Check: Ensure all excluded directories are not in the results. 220 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 221 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 222 | assert!( 223 | !dir_paths.contains(&"./tests-data/dir1/dir2"), 224 | "Should not contain dir1/dir2" 225 | ); 226 | assert!( 227 | dir_paths.contains(&"./tests-data/another-dir"), 228 | "Should contain another-dir" 229 | ); 230 | assert!( 231 | dir_paths.contains(&"./tests-data/another-dir/sub-dir"), 232 | "Should contain another-dir/sub-dir" 233 | ); 234 | assert!( 235 | !dir_paths.contains(&"./tests-data/another-dir/sub-dir/deep-folder"), 236 | "Should not contain another-dir/sub-dir/deep-folder" 237 | ); 238 | 239 | Ok(()) 240 | } 241 | 242 | #[test] 243 | fn test_list_dirs_with_only_negative_globs() -> Result<()> { 244 | // -- Exec: List directories with only negative patterns (should default to "**" for includes). 245 | let dirs = list_dirs( 246 | "./tests-data/", 247 | Some(&["!**/dir2", "!**/deep-folder"]), // Only exclusion patterns 248 | None, 249 | )?; 250 | 251 | // -- Check: Verify filtering works with only negative patterns. 252 | let dir_paths = dirs.iter().map(|p| p.as_str()).collect::>(); 253 | assert!(dir_paths.contains(&"./tests-data/dir1"), "Should contain dir1"); 254 | assert!( 255 | !dir_paths.contains(&"./tests-data/dir1/dir2"), 256 | "Should not contain dir1/dir2" 257 | ); 258 | assert!( 259 | !dir_paths.contains(&"./tests-data/another-dir/sub-dir/deep-folder"), 260 | "Should not contain another-dir/sub-dir/deep-folder" 261 | ); 262 | 263 | Ok(()) 264 | } 265 | -------------------------------------------------------------------------------- /tests/tests_spath.rs: -------------------------------------------------------------------------------- 1 | use simple_fs::SPath; 2 | 3 | pub type Result = core::result::Result>; 4 | 5 | #[test] 6 | fn test_spath_starts_with_simple() -> Result<()> { 7 | // -- Setup & Fixtures 8 | let fx_data = &[ 9 | // (path_str, prefix_str, expected_bool) 10 | // Exact matches 11 | ("~/passwd", "~/", true), 12 | ("~/passwd", "~", true), 13 | ("~passwd", "~", false), // because `~` is not a path component 14 | ("/etc/passwd", "/etc/passwd", true), 15 | ("src/main.rs", "src/main.rs", true), 16 | // Prefix matches 17 | ("/etc/passwd", "/etc", true), 18 | ("/etc/passwd", "/etc/", true), 19 | ("some/path/to/file", "some/path", true), 20 | ("some/path/to/file", "some/path/", true), 21 | // Prefix matches with extra slashes in prefix 22 | ("/etc/passwd", "/etc/passwd/", true), // extra slash is okay 23 | ("/etc/passwd", "/etc/passwd///", true), // multiple extra slashes are okay 24 | // Non-matches 25 | ("/etc/passwd", "/e", false), // partial component 26 | ("/etc/passwd", "/etc/passwd.txt", false), // different file 27 | ("src/main.rs", "src/main", false), // partial component 28 | ("file.txt", "another-file.txt", false), 29 | ("data/project/file.txt", "data/project/files", false), // prefix is longer in component name 30 | // Relative paths 31 | ("relative/path", "relative", true), 32 | ("relative/path", "relative/", true), 33 | ("./config/settings.toml", "./config", true), 34 | ("./config/settings.toml", "./config/", true), 35 | // Edge cases 36 | ("file", "file", true), 37 | ("file", "f", false), 38 | ("/", "/", true), 39 | ("/a/b", "/", true), 40 | ("a/b", "a", true), 41 | // Non-match when base is longer 42 | ("path/to/file", "path/to/file/extra", false), 43 | ]; 44 | 45 | // -- Exec & Check 46 | for &(path_str, prefix_str, expected_bool) in fx_data.iter() { 47 | let spath = SPath::new(path_str); 48 | let prefix_path = SPath::new(prefix_str); // SPath can take Path as AsRef 49 | let actual_bool = spath.starts_with(prefix_path.std_path()); // SPath.starts_with takes AsRef 50 | 51 | assert_eq!( 52 | actual_bool, expected_bool, 53 | "Path: '{path_str}', Prefix: '{prefix_str}'. Expected: {expected_bool}, Got: {actual_bool}" 54 | ); 55 | 56 | // Test with &str directly 57 | let actual_bool_str_prefix = spath.starts_with(prefix_str); 58 | assert_eq!( 59 | actual_bool_str_prefix, expected_bool, 60 | "Path: '{path_str}', Prefix (str): '{prefix_str}'. Expected: {expected_bool}, Got: {actual_bool_str_prefix}" 61 | ); 62 | } 63 | 64 | Ok(()) 65 | } 66 | 67 | #[test] 68 | fn test_spath_spath_new_sibling() -> Result<()> { 69 | // -- Setup & Fixtures 70 | let fx_data = &[ 71 | // (original_path, sibling_leaf_path, expected_path) 72 | ("/some/path/to/file.txt", "new_file.md", "/some/path/to/new_file.md"), 73 | ("some/path/to/file.txt", "new_file.md", "some/path/to/new_file.md"), 74 | ("/some/path/to/file.txt", "file.txt", "/some/path/to/file.txt"), 75 | ("./file.txt", "new_file.md", "./new_file.md"), 76 | ("file.txt", "new_file.md", "new_file.md"), 77 | ]; 78 | 79 | // -- Exec & Check 80 | for data in fx_data.iter() { 81 | let original_path = SPath::new(data.0); 82 | let sibling_leaf_path = SPath::new(data.1); 83 | let expected_path = SPath::new(data.2); 84 | 85 | let actual_path = original_path.new_sibling(sibling_leaf_path); 86 | 87 | assert_eq!(actual_path.as_str(), expected_path.as_str()); 88 | } 89 | 90 | Ok(()) 91 | } 92 | 93 | #[test] 94 | fn test_spath_replace_prefix_simple() -> Result<()> { 95 | // -- Setup & Fixtures 96 | let fx_data = &[ 97 | // (original_path, base_prefix, replacement, expected_path) 98 | // Basic replacement 99 | ( 100 | "/data/proj/src/main.rs", 101 | "/data/proj/", 102 | "/archive/v1/", 103 | "/archive/v1/src/main.rs", 104 | ), 105 | // Base without trailing slash, replacement without trailing slash 106 | ( 107 | "/data/proj/src/main.rs", 108 | "/data/proj", 109 | "/archive/v1", 110 | "/archive/v1/src/main.rs", 111 | ), 112 | // Relative paths 113 | ("src/main.rs", "src/", "lib/", "lib/main.rs"), 114 | // Replacement with trailing slash, base without 115 | ("src/main.rs", "src", "lib/", "lib/main.rs"), 116 | // Prefix not found 117 | ( 118 | "/data/proj/src/main.rs", 119 | "/nonexistent/", 120 | "/foo/", 121 | "/data/proj/src/main.rs", 122 | ), 123 | // Empty base prefix (should prepend replacement and a slash) 124 | ("file.txt", "", "prefix", "prefix/file.txt"), 125 | // Root base prefix 126 | ("/file.txt", "/", "/new_root", "/new_root/file.txt"), 127 | // Full path replacement 128 | ( 129 | "/data/project/file.txt", 130 | "/data/project/file.txt", 131 | "/archive/doc.md", 132 | "/archive/doc.md/", 133 | ), 134 | // Base longer than path (no replacement) 135 | ("path/to/file", "path/to/file/extra", "new", "path/to/file"), 136 | // Base is identical to path, replacement is empty 137 | // ("config.toml", "config.toml", "", ""), // "" + "/" + "" 138 | // Base is part of path, replacement is empty 139 | ("project/config.toml", "project/", "", "config.toml"), 140 | ]; 141 | 142 | // -- Exec & Check 143 | for &(original_str, base_str, with_str, expected_str) in fx_data.iter() { 144 | let original_path = SPath::new(original_str); 145 | let expected_path = SPath::new(expected_str); 146 | 147 | let actual_path = original_path.replace_prefix(base_str, with_str); 148 | 149 | assert_eq!( 150 | actual_path.as_str(), 151 | expected_path.as_str(), 152 | "Failed for: original='{original_str}', base='{base_str}', with='{with_str}'" 153 | ); 154 | } 155 | 156 | Ok(()) 157 | } 158 | 159 | #[test] 160 | fn test_spath_into_replace_prefix_simple() -> Result<()> { 161 | // -- Setup & Fixtures 162 | let fx_data = &[ 163 | // (original_path, base_prefix, replacement, expected_path) 164 | // Basic replacement 165 | ( 166 | "/data/proj/src/main.rs", 167 | "/data/proj/", 168 | "/archive/v1/", 169 | "/archive/v1/src/main.rs", 170 | ), 171 | // Base without trailing slash, replacement without trailing slash 172 | ( 173 | "/data/proj/src/main.rs", 174 | "/data/proj", 175 | "/archive/v1", 176 | "/archive/v1/src/main.rs", 177 | ), 178 | // Relative paths 179 | ("src/main.rs", "src/", "lib/", "lib/main.rs"), 180 | // Replacement with trailing slash, base without 181 | ("src/main.rs", "src", "lib/", "lib/main.rs"), 182 | // Prefix not found (original path should be returned) 183 | ( 184 | "/data/proj/src/main.rs", 185 | "/nonexistent/", 186 | "/foo/", 187 | "/data/proj/src/main.rs", 188 | ), 189 | // Empty base prefix (should prepend replacement and a slash) 190 | ("file.txt", "", "prefix", "prefix/file.txt"), 191 | // Root base prefix 192 | ("/file.txt", "/", "/new_root", "/new_root/file.txt"), 193 | // Full path replacement 194 | ( 195 | "/data/project/file.txt", 196 | "/data/project/file.txt", 197 | "/archive/doc.md", 198 | "/archive/doc.md/", 199 | ), 200 | // Base longer than path (no replacement) 201 | ("path/to/file", "path/to/file/extra", "new", "path/to/file"), 202 | // Base is identical to path, replacement is empty 203 | ("config.toml", "config.toml", "", ""), 204 | // Base is part of path, replacement is empty 205 | ("project/config.toml", "project/", "", "config.toml"), 206 | ]; 207 | 208 | // -- Exec & Check 209 | for &(original_str, base_str, with_str, expected_str) in fx_data.iter() { 210 | let original_path = SPath::new(original_str); 211 | let original_path_for_check = SPath::new(original_str); // Clone for comparison if no change 212 | let expected_path = SPath::new(expected_str); 213 | 214 | let actual_path = original_path.into_replace_prefix(base_str, with_str); 215 | 216 | assert_eq!( 217 | actual_path.as_str(), 218 | expected_path.as_str(), 219 | "Failed for: original='{original_str}', base='{base_str}', with='{with_str}'" 220 | ); 221 | 222 | // Check if the original path was indeed consumed or returned if unchanged 223 | if original_str == expected_str { 224 | // This check is a bit indirect for "consumed". 225 | // If no change, the `into_` version might return `self`. 226 | // We are primarily testing the transformation logic here. 227 | assert_eq!(actual_path.as_str(), original_path_for_check.as_str()); 228 | } 229 | } 230 | 231 | Ok(()) 232 | } 233 | 234 | #[test] 235 | fn test_spath_spath_diff() -> Result<()> { 236 | // -- Setup & Fixtures 237 | let fx_data = &[ 238 | // (base_path, target_path, expected_path) 239 | ( 240 | "/some/base/path", 241 | "/some/base/path/sub_dir/some_file.md", 242 | "sub_dir/some_file.md", 243 | ), 244 | ( 245 | "/some/base/path/sub_dir/some_file.md", 246 | "/some/base/path/some/other-file.md", 247 | "../../some/other-file.md", 248 | ), 249 | ]; 250 | 251 | // -- Exec & Check 252 | for data in fx_data.iter() { 253 | let base_path = SPath::new(data.0); 254 | let target_path = SPath::new(data.1); 255 | let expected_path = SPath::new(data.2); 256 | 257 | let diff_path = target_path.diff(&base_path).ok_or("Should have diff")?; 258 | let rejoined_path = base_path.join(&diff_path).collapse(); 259 | 260 | assert_eq!(diff_path.as_str(), expected_path.as_str()); 261 | assert_eq!(rejoined_path.as_str(), target_path.as_str()); 262 | } 263 | 264 | Ok(()) 265 | } 266 | -------------------------------------------------------------------------------- /src/reshape/normalizer.rs: -------------------------------------------------------------------------------- 1 | //! Path normalization functions 2 | //! 3 | //! Normalize path strings by collapsing redundant separators and handling platform-specific quirks. 4 | 5 | use camino::{Utf8Path, Utf8PathBuf}; 6 | 7 | /// Checks if a path needs normalization. 8 | /// - If it contains a `\` 9 | /// - If it has two or more consecutive `//` 10 | /// - If it contains one or more `/./` 11 | /// 12 | /// Note: This performs a single pass and returns as early as possible. 13 | pub fn needs_normalize(path: &Utf8Path) -> bool { 14 | let path_str = path.as_str(); 15 | let mut chars = path_str.chars().peekable(); 16 | 17 | // Check for \\?\ prefix 18 | if path_str.starts_with(r"\\?\") { 19 | return true; 20 | } 21 | 22 | while let Some(c) = chars.next() { 23 | match c { 24 | '\\' => return true, 25 | '/' => match chars.peek() { 26 | Some('/') => return true, 27 | Some('.') => { 28 | let mut lookahead = chars.clone(); 29 | lookahead.next(); // consume '.' 30 | match lookahead.peek() { 31 | Some('/') | None => return true, 32 | _ => {} 33 | } 34 | } 35 | _ => {} 36 | }, 37 | _ => {} 38 | } 39 | } 40 | 41 | false 42 | } 43 | /// Normalizes a path by: 44 | /// - Converting backslashes to forward slashes 45 | /// - Collapsing multiple consecutive slashes to single slashes 46 | /// - Removing single dots except at the start 47 | /// - Removing Windows-specific `\\?\` prefix 48 | /// 49 | /// The function performs a quick check to determine if normalization is actually needed. 50 | /// If no normalization is required, it returns the original path to avoid unnecessary allocations. 51 | pub fn into_normalized(path: Utf8PathBuf) -> Utf8PathBuf { 52 | // Quick check to see if any normalization is needed 53 | let path_str = path.as_str(); 54 | 55 | // Check for conditions that require normalization 56 | let needs_normalization = needs_normalize(&path); 57 | 58 | if !needs_normalization { 59 | return path; 60 | } 61 | 62 | // Perform normalization 63 | let mut result = String::with_capacity(path_str.len()); 64 | let mut chars = path_str.chars().peekable(); 65 | let mut last_was_slash = false; 66 | 67 | // Handle Windows UNC path prefix (\\?\) 68 | if path_str.starts_with(r"\\?\") { 69 | for _ in 0..4 { 70 | chars.next(); // Skip the first 4 chars 71 | } 72 | } 73 | 74 | while let Some(c) = chars.next() { 75 | match c { 76 | '\\' | '/' => { 77 | // Convert backslash to forward slash and collapse consecutive slashes 78 | if !last_was_slash { 79 | result.push('/'); 80 | last_was_slash = true; 81 | } 82 | } 83 | '.' => { 84 | // Special handling for dots 85 | if last_was_slash { 86 | // Look ahead to check if this is a "/./" pattern 87 | match chars.peek() { 88 | Some(&'/') | Some(&'\\') => { 89 | // Skip single dot if it's not at the start 90 | if !result.is_empty() { 91 | chars.next(); // Skip the next slash 92 | continue; 93 | } 94 | } 95 | // Check if it's a "../" pattern (which we want to keep) 96 | Some(&'.') => { 97 | result.push('.'); 98 | last_was_slash = false; 99 | } 100 | // Something else 101 | _ => { 102 | result.push('.'); 103 | last_was_slash = false; 104 | } 105 | } 106 | } else { 107 | result.push('.'); 108 | last_was_slash = false; 109 | } 110 | } 111 | _ => { 112 | result.push(c); 113 | last_was_slash = false; 114 | } 115 | } 116 | } 117 | 118 | // If the original path ended with a slash, ensure the normalized path does too 119 | if (path_str.ends_with('/') || path_str.ends_with('\\')) && !result.ends_with('/') { 120 | result.push('/'); 121 | } 122 | 123 | Utf8PathBuf::from(result) 124 | } 125 | 126 | // region: --- Tests 127 | 128 | #[cfg(test)] 129 | mod tests { 130 | type Result = core::result::Result>; // For tests. 131 | 132 | use super::*; 133 | 134 | #[test] 135 | fn test_normalizer_into_normalize_backslashes() -> Result<()> { 136 | // -- Setup & Fixtures 137 | let paths = [ 138 | (r"C:\Users\name\file.txt", "C:/Users/name/file.txt"), 139 | (r"path\to\file.txt", "path/to/file.txt"), 140 | (r"mixed/path\style", "mixed/path/style"), 141 | ]; 142 | 143 | // -- Exec & Check 144 | for (input, expected) in paths { 145 | let path = Utf8PathBuf::from(input); 146 | let normalized = into_normalized(path); 147 | assert_eq!( 148 | normalized.as_str(), 149 | expected, 150 | "Failed to normalize backslashes in '{input}'" 151 | ); 152 | } 153 | 154 | Ok(()) 155 | } 156 | 157 | #[test] 158 | fn test_normalizer_into_normalize_multiple_slashes() -> Result<()> { 159 | // -- Setup & Fixtures 160 | let paths = [ 161 | ("//path//to///file.txt", "/path/to/file.txt"), 162 | ("path////file.txt", "path/file.txt"), 163 | (r"\\server\\share\\file.txt", "/server/share/file.txt"), 164 | ]; 165 | 166 | // -- Exec & Check 167 | for (input, expected) in paths { 168 | let path = Utf8PathBuf::from(input); 169 | let normalized = into_normalized(path); 170 | assert_eq!( 171 | normalized.as_str(), 172 | expected, 173 | "Failed to collapse multiple slashes in '{input}'" 174 | ); 175 | } 176 | 177 | Ok(()) 178 | } 179 | 180 | #[test] 181 | fn test_normalizer_into_normalize_single_dots() -> Result<()> { 182 | // -- Setup & Fixtures 183 | let paths = [ 184 | ("path/./file.txt", "path/file.txt"), 185 | ("./path/./to/./file.txt", "./path/to/file.txt"), 186 | ("path/to/./././file.txt", "path/to/file.txt"), 187 | ]; 188 | 189 | // -- Exec & Check 190 | for (input, expected) in paths { 191 | let path = Utf8PathBuf::from(input); 192 | let normalized = into_normalized(path); 193 | assert_eq!( 194 | normalized.as_str(), 195 | expected, 196 | "Failed to handle single dots correctly in '{input}'" 197 | ); 198 | } 199 | 200 | Ok(()) 201 | } 202 | 203 | #[test] 204 | fn test_normalizer_into_normalize_preserve_parent_dirs() -> Result<()> { 205 | // -- Setup & Fixtures 206 | let paths = [ 207 | ("path/../file.txt", "path/../file.txt"), 208 | ("../path/file.txt", "../path/file.txt"), 209 | ("path/../../file.txt", "path/../../file.txt"), 210 | ]; 211 | 212 | // -- Exec & Check 213 | for (input, expected) in paths { 214 | let path = Utf8PathBuf::from(input); 215 | let normalized = into_normalized(path); 216 | assert_eq!( 217 | normalized.as_str(), 218 | expected, 219 | "Should preserve parent directory references in '{input}'" 220 | ); 221 | } 222 | 223 | Ok(()) 224 | } 225 | 226 | #[test] 227 | fn test_normalizer_into_normalize_windows_prefix() -> Result<()> { 228 | // -- Setup & Fixtures 229 | let paths = [ 230 | (r"\\?\C:\Users\name\file.txt", "C:/Users/name/file.txt"), 231 | (r"\\?\UNC\server\share", "UNC/server/share"), 232 | ]; 233 | 234 | // -- Exec & Check 235 | for (input, expected) in paths { 236 | let path = Utf8PathBuf::from(input); 237 | let normalized = into_normalized(path); 238 | assert_eq!( 239 | normalized.as_str(), 240 | expected, 241 | "Failed to remove Windows prefix in '{input}'" 242 | ); 243 | } 244 | 245 | Ok(()) 246 | } 247 | 248 | #[test] 249 | fn test_normalizer_into_normalize_no_change_needed() -> Result<()> { 250 | // -- Setup & Fixtures 251 | let paths = ["path/to/file.txt", "/absolute/path/file.txt", "../parent/dir", "file.txt"]; 252 | 253 | // -- Exec & Check 254 | for input in paths { 255 | let path = Utf8PathBuf::from(input); 256 | let path_clone = path.clone(); 257 | let normalized = into_normalized(path); 258 | // This should be a simple identity return with no changes 259 | assert_eq!( 260 | normalized, path_clone, 261 | "Path should not change when normalization not needed" 262 | ); 263 | } 264 | 265 | Ok(()) 266 | } 267 | 268 | #[test] 269 | fn test_normalizer_into_normalize_trailing_slash() -> Result<()> { 270 | // -- Setup & Fixtures 271 | let paths = [ 272 | ("path/to/dir/", "path/to/dir/"), 273 | (r"path\to\dir\", "path/to/dir/"), 274 | ("path//to///dir///", "path/to/dir/"), 275 | ]; 276 | 277 | // -- Exec & Check 278 | for (input, expected) in paths { 279 | let path = Utf8PathBuf::from(input); 280 | let normalized = into_normalized(path); 281 | assert_eq!( 282 | normalized.as_str(), 283 | expected, 284 | "Should preserve trailing slash in '{input}'" 285 | ); 286 | } 287 | 288 | Ok(()) 289 | } 290 | 291 | #[test] 292 | fn test_normalizer_into_normalize_complex_paths() -> Result<()> { 293 | // -- Setup & Fixtures 294 | let paths = [ 295 | ( 296 | r"C:\Users\.\name\..\admin\//docs\file.txt", 297 | "C:/Users/name/../admin/docs/file.txt", 298 | ), 299 | ( 300 | r"\\?\C:\Program Files\\.\multiple//slashes", 301 | "C:/Program Files/multiple/slashes", 302 | ), 303 | ("./current/dir/./file.txt", "./current/dir/file.txt"), 304 | ]; 305 | 306 | // -- Exec & Check 307 | for (input, expected) in paths { 308 | let path = Utf8PathBuf::from(input); 309 | let normalized = into_normalized(path); 310 | assert_eq!( 311 | normalized.as_str(), 312 | expected, 313 | "Failed to normalize complex path '{input}'" 314 | ); 315 | } 316 | 317 | Ok(()) 318 | } 319 | } 320 | 321 | // endregion: --- Tests 322 | -------------------------------------------------------------------------------- /src/reshape/collapser.rs: -------------------------------------------------------------------------------- 1 | //! Collapse Camino Utf8Path paths similarly to canonicalize, but without performing I/O. 2 | //! 3 | //! Adapted from [cargo-binstall](https://github.com/cargo-bins/cargo-binstall/blob/main/crates/normalize-path/src/lib.rs) 4 | //! and Rust's `path::normalize`. 5 | 6 | use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; 7 | 8 | /// Collapses a path buffer without performing I/O. 9 | /// 10 | /// - Resolves `../` segments where possible. 11 | /// - Removes `./` segments, except when leading 12 | /// - All redundant separators and up-level references are collapsed. 13 | /// 14 | /// Example: 15 | /// - `a/b/../c` becomes `a/c` 16 | /// - `a/../../c` becomes `../c` 17 | /// - `./some` becomes `./some` 18 | /// - `./some/./path` becomes `./some/path` 19 | /// - `/a/../c` becomes `/c` 20 | /// - `/a/../../c` becomes `/c` 21 | /// 22 | /// However, this does not resolve symbolic links. 23 | /// It consumes the input `Utf8PathBuf` and returns a new one. 24 | pub fn into_collapsed(path: impl Into) -> Utf8PathBuf { 25 | let path_buf = path.into(); 26 | 27 | // For empty paths, return empty path 28 | if path_buf.as_str().is_empty() { 29 | return path_buf; 30 | } 31 | 32 | // Fast path: if the path is already collapsed, return it as is 33 | if is_collapsed(&path_buf) { 34 | return path_buf; 35 | } 36 | 37 | let mut components = Vec::new(); 38 | let mut normal_seen = false; 39 | 40 | // Process each component 41 | for component in path_buf.components() { 42 | match component { 43 | Utf8Component::Prefix(prefix) => { 44 | components.push(Utf8Component::Prefix(prefix)); 45 | } 46 | Utf8Component::RootDir => { 47 | components.push(Utf8Component::RootDir); 48 | normal_seen = false; // Reset after root dir 49 | } 50 | Utf8Component::CurDir => { 51 | // Only keep current dir at the beginning of a relative path 52 | if components.is_empty() { 53 | components.push(component); 54 | } 55 | // Otherwise, ignore it (it's redundant) 56 | } 57 | Utf8Component::ParentDir => { 58 | // If we've seen a normal component and we're not at the root, 59 | // pop the last component instead of adding the parent 60 | if normal_seen && !components.is_empty() { 61 | match components.last() { 62 | Some(Utf8Component::Normal(_)) => { 63 | components.pop(); 64 | normal_seen = components.iter().any(|c| matches!(c, Utf8Component::Normal(_))); 65 | continue; 66 | } 67 | Some(Utf8Component::ParentDir) => {} 68 | Some(Utf8Component::RootDir) | Some(Utf8Component::Prefix(_)) => { 69 | // For absolute paths, we can discard parent dirs that 70 | // would go beyond the root 71 | continue; 72 | } 73 | _ => {} 74 | } 75 | } 76 | components.push(component); 77 | } 78 | Utf8Component::Normal(name) => { 79 | components.push(Utf8Component::Normal(name)); 80 | normal_seen = true; 81 | } 82 | } 83 | } 84 | 85 | // If we've collapsed everything away, return "." or "" appropriately 86 | if components.is_empty() { 87 | if path_buf.as_str().starts_with("./") { 88 | return Utf8PathBuf::from("."); 89 | } else { 90 | return Utf8PathBuf::from(""); 91 | } 92 | } 93 | 94 | // Reconstruct the path from the collapsed components 95 | let mut result = Utf8PathBuf::new(); 96 | for component in components { 97 | result.push(component.as_str()); 98 | } 99 | 100 | result 101 | } 102 | 103 | /// Same as [`into_collapsed`] except that if `Component::Prefix` or `Component::RootDir` 104 | /// is encountered in a path that is supposed to be relative, or if the path attempts 105 | /// to navigate above its starting point using `..`, it returns `None`. 106 | /// 107 | /// Useful for ensuring a path stays within a certain relative directory structure. 108 | pub fn try_into_collapsed(path: impl Into) -> Option { 109 | let path_buf = path.into(); 110 | 111 | // Fast path: if the path is already collapsed and doesn't contain problematic components, 112 | // return it as is 113 | if is_collapsed(&path_buf) && !contains_problematic_components(&path_buf) { 114 | return Some(path_buf); 115 | } 116 | 117 | let mut components = Vec::new(); 118 | let mut normal_seen = false; 119 | let mut parent_count = 0; 120 | 121 | // Process each component 122 | for component in path_buf.components() { 123 | match component { 124 | Utf8Component::Prefix(_) => { 125 | // A prefix indicates this is not a relative path 126 | return None; 127 | } 128 | Utf8Component::RootDir => { 129 | // A root directory indicates this is not a relative path 130 | return None; 131 | } 132 | Utf8Component::CurDir => { 133 | // Only keep current dir at the beginning of a relative path 134 | if components.is_empty() { 135 | components.push(component); 136 | } 137 | // Otherwise, ignore it (it's redundant) 138 | } 139 | Utf8Component::ParentDir => { 140 | if normal_seen { 141 | // If we've seen a normal component, pop the last component 142 | if let Some(Utf8Component::Normal(_)) = components.last() { 143 | components.pop(); 144 | normal_seen = components.iter().any(|c| matches!(c, Utf8Component::Normal(_))); 145 | continue; 146 | } 147 | } else { 148 | // If we haven't seen a normal component, this is a leading ".." 149 | parent_count += 1; 150 | } 151 | components.push(component); 152 | } 153 | Utf8Component::Normal(name) => { 154 | components.push(Utf8Component::Normal(name)); 155 | normal_seen = true; 156 | } 157 | } 158 | } 159 | 160 | // If there are any parent dirs still in the path, check if they would try to go 161 | // beyond the starting dir 162 | if parent_count > 0 && components.iter().filter(|c| matches!(c, Utf8Component::Normal(_))).count() < parent_count { 163 | return None; 164 | } 165 | 166 | // If we've collapsed everything away, return "." or "" appropriately 167 | if components.is_empty() { 168 | if path_buf.as_str().starts_with("./") { 169 | return Some(Utf8PathBuf::from(".")); 170 | } else { 171 | return Some(Utf8PathBuf::from("")); 172 | } 173 | } 174 | 175 | // Reconstruct the path from the collapsed components 176 | let mut result = Utf8PathBuf::new(); 177 | for component in components { 178 | result.push(component.as_str()); 179 | } 180 | 181 | Some(result) 182 | } 183 | 184 | /// Returns `true` if the path is already collapsed. 185 | /// 186 | /// A path is considered collapsed if it contains no `.` components 187 | /// and no `..` components that immediately follow a normal component. 188 | /// Leading `..` components in relative paths are allowed. 189 | /// Absolute paths should not contain `..` at all after the root/prefix. 190 | pub fn is_collapsed(path: impl AsRef) -> bool { 191 | let path = path.as_ref(); 192 | let mut components = path.components().peekable(); 193 | let mut is_absolute = false; 194 | let mut previous_was_normal = false; 195 | 196 | while let Some(component) = components.next() { 197 | match component { 198 | Utf8Component::Prefix(_) | Utf8Component::RootDir => { 199 | is_absolute = true; 200 | } 201 | Utf8Component::CurDir => { 202 | // Current dir components are allowed only at the beginning of a relative path 203 | if previous_was_normal || is_absolute || components.peek().is_some() { 204 | return false; 205 | } 206 | } 207 | Utf8Component::ParentDir => { 208 | // In absolute paths, parent dir components should never appear 209 | if is_absolute { 210 | return false; 211 | } 212 | // In relative paths, parent dir should not follow a normal component 213 | if previous_was_normal { 214 | return false; 215 | } 216 | } 217 | Utf8Component::Normal(_) => { 218 | previous_was_normal = true; 219 | } 220 | } 221 | } 222 | 223 | true 224 | } 225 | 226 | // Helper function for try_into_collapsed 227 | fn contains_problematic_components(path: &Utf8Path) -> bool { 228 | let mut has_parent_after_normal = false; 229 | let mut has_prefix_or_root = false; 230 | let mut normal_seen = false; 231 | 232 | for component in path.components() { 233 | match component { 234 | Utf8Component::Prefix(_) | Utf8Component::RootDir => { 235 | has_prefix_or_root = true; 236 | } 237 | Utf8Component::ParentDir => { 238 | if normal_seen { 239 | has_parent_after_normal = true; 240 | } 241 | } 242 | Utf8Component::Normal(_) => { 243 | normal_seen = true; 244 | } 245 | _ => {} 246 | } 247 | } 248 | 249 | has_prefix_or_root || has_parent_after_normal 250 | } 251 | 252 | // region: --- Tests 253 | 254 | #[cfg(test)] 255 | mod tests { 256 | type Result = core::result::Result>; // For tests. 257 | 258 | use super::*; 259 | 260 | // -- Tests for into_collapsed 261 | 262 | #[test] 263 | fn test_reshape_collapser_into_collapsed_simple() -> Result<()> { 264 | // -- Setup & Fixtures 265 | let data = &[ 266 | // Basic cases 267 | ("a/b/c", "a/b/c"), 268 | ("a/./b", "a/b"), 269 | ("./a/b", "./a/b"), 270 | ("./a/b", "./a/b"), 271 | ("a/./b/.", "a/b"), 272 | ("/a/./b/.", "/a/b"), 273 | ("a/../b", "b"), 274 | ("../a/b", "../a/b"), // Keep leading .. 275 | ("../a/b/..", "../a"), // Keep leading .. 276 | ("../a/b/../../..", "../.."), // Keep leading .. 277 | ("a/b/..", "a"), 278 | ("a/b/../..", ""), // Collapses to current dir 279 | ("../../a/b", "../../a/b"), // Keep multiple leading .. 280 | (".", "."), // "." 281 | ("..", ".."), // ".." stays ".." 282 | ]; 283 | 284 | // -- Exec & Check 285 | for (input, expected) in data { 286 | let input_path = Utf8PathBuf::from(input); 287 | let result_path = into_collapsed(input_path); 288 | let expected_path = Utf8PathBuf::from(expected); 289 | assert_eq!( 290 | result_path, expected_path, 291 | "Input: '{input}', Expected: '{expected}', Got: '{result_path}'" 292 | ); 293 | } 294 | 295 | Ok(()) 296 | } 297 | } 298 | 299 | // endregion: --- Tests 300 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Jeremy Chone 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /src/sfile.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result}; 2 | use crate::{SMeta, SPath}; 3 | use camino::{Utf8Path, Utf8PathBuf}; 4 | use core::fmt; 5 | use std::fs::{self, Metadata}; 6 | use std::path::{Path, PathBuf}; 7 | use std::time::SystemTime; 8 | 9 | /// An SFile can be constructed from a Path, io::DirEntry, or walkdir::DirEntry 10 | /// and guarantees the following: 11 | /// 12 | /// - The entry is a file (exists). 13 | /// - It has a file name. 14 | /// - The full path is UTF-8 valid. 15 | #[derive(Debug, Clone)] 16 | pub struct SFile { 17 | path: SPath, 18 | } 19 | 20 | /// Constructors that guarantee the SFile contract described in the struct 21 | impl SFile { 22 | /// Constructor for SFile accepting anything that implements Into. 23 | pub fn new(path: impl Into) -> Result { 24 | let path = SPath::new(path); 25 | validate_sfile_for_result(&path)?; 26 | Ok(Self { path }) 27 | } 28 | 29 | /// Constructor from standard PathBuf. 30 | pub fn from_std_path_buf(path_buf: PathBuf) -> Result { 31 | let path = SPath::from_std_path_buf(path_buf)?; 32 | validate_sfile_for_result(&path)?; 33 | Ok(Self { path }) 34 | } 35 | 36 | /// Constructor from standard Path and all impl AsRef. 37 | pub fn from_std_path(path: impl AsRef) -> Result { 38 | let path = SPath::from_std_path(path)?; 39 | validate_sfile_for_result(&path)?; 40 | Ok(Self { path }) 41 | } 42 | 43 | /// Constructor from walkdir::DirEntry 44 | pub fn from_walkdir_entry(wd_entry: walkdir::DirEntry) -> Result { 45 | let path = SPath::from_walkdir_entry(wd_entry)?; 46 | validate_sfile_for_result(&path)?; 47 | Ok(Self { path }) 48 | } 49 | 50 | /// Constructors for anything that implements AsRef. 51 | /// 52 | /// Returns Option. Useful for filter_map. 53 | pub fn from_std_path_ok(path: impl AsRef) -> Option { 54 | let path = SPath::from_std_path_ok(path)?; 55 | validate_sfile_for_option(&path)?; 56 | Some(Self { path }) 57 | } 58 | 59 | /// Constructor from PathBuf returning an Option, none if validation fails. 60 | /// Useful for filter_map. 61 | pub fn from_std_path_buf_ok(path_buf: PathBuf) -> Option { 62 | let path = SPath::from_std_path_buf_ok(path_buf)?; 63 | validate_sfile_for_option(&path)?; 64 | Some(Self { path }) 65 | } 66 | 67 | /// Constructor from fs::DirEntry returning an Option; none if validation fails. 68 | /// Useful for filter_map. 69 | pub fn from_fs_entry_ok(fs_entry: fs::DirEntry) -> Option { 70 | let path = SPath::from_fs_entry_ok(fs_entry)?; 71 | validate_sfile_for_option(&path)?; 72 | Some(Self { path }) 73 | } 74 | 75 | /// Constructor from walkdir::DirEntry returning an Option; none if validation fails. 76 | /// Useful for filter_map. 77 | pub fn from_walkdir_entry_ok(wd_entry: walkdir::DirEntry) -> Option { 78 | let path = SPath::from_walkdir_entry_ok(wd_entry)?; 79 | validate_sfile_for_option(&path)?; 80 | Some(Self { path }) 81 | } 82 | } 83 | 84 | /// Public into path 85 | impl SFile { 86 | /// Consumes the SFile and returns its PathBuf. 87 | pub fn into_std_path_buf(self) -> PathBuf { 88 | self.path.into_std_path_buf() 89 | } 90 | 91 | /// Returns a reference to the internal standard Path. 92 | pub fn std_path(&self) -> &Path { 93 | self.path.std_path() 94 | } 95 | 96 | /// Returns a reference to the internal Utf8Path. 97 | pub fn path(&self) -> &SPath { 98 | &self.path 99 | } 100 | } 101 | 102 | /// Public getters 103 | impl SFile { 104 | /// Returns the &str of the path. 105 | /// 106 | /// NOTE: We know that this must be Some() since the SFile constructor guarantees that 107 | /// the path.as_str() is valid. 108 | #[deprecated(note = "Use `as_str()` instead")] 109 | pub fn to_str(&self) -> &str { 110 | self.path.as_str() 111 | } 112 | 113 | pub fn as_str(&self) -> &str { 114 | self.path.as_str() 115 | } 116 | 117 | /// Returns the Option<&str> representation of the `path.file_name()`. 118 | pub fn file_name(&self) -> Option<&str> { 119 | self.path.file_name() 120 | } 121 | 122 | /// Returns the &str representation of the `path.file_name()`. 123 | /// 124 | /// Note: If no file name will be an empty string 125 | pub fn name(&self) -> &str { 126 | self.path.name() 127 | } 128 | 129 | /// Returns the parent name, and empty static &str if no present 130 | pub fn parent_name(&self) -> &str { 131 | self.path.parent_name() 132 | } 133 | 134 | /// Returns the Option<&str> representation of the file_stem(). 135 | pub fn file_stem(&self) -> Option<&str> { 136 | self.path.file_stem() 137 | } 138 | 139 | /// Returns the &str representation of the `file_name()`. 140 | /// 141 | /// Note: If no stem, will be an empty string 142 | pub fn stem(&self) -> &str { 143 | self.path.stem() 144 | } 145 | 146 | /// Returns the Option<&str> representation of the extension(). 147 | /// 148 | /// NOTE: This should never be a non-UTF-8 string 149 | /// as the path was validated during SFile construction. 150 | pub fn extension(&self) -> Option<&str> { 151 | self.path.extension() 152 | } 153 | 154 | /// Same as `.extension()` but returns "" if no extension. 155 | pub fn ext(&self) -> &str { 156 | self.path.ext() 157 | } 158 | 159 | /// Returns true if the internal path is absolute. 160 | pub fn is_absolute(&self) -> bool { 161 | self.path.is_absolute() 162 | } 163 | 164 | /// Returns true if the internal path is relative. 165 | pub fn is_relative(&self) -> bool { 166 | self.path.is_relative() 167 | } 168 | } 169 | 170 | /// Meta 171 | impl SFile { 172 | /// Get a Simple Metadata structure `SMeta` with 173 | /// created_epoch_us, modified_epoch_us, and size (all i64) 174 | /// (size will be '0' for any none file) 175 | pub fn meta(&self) -> Result { 176 | self.path.meta() 177 | } 178 | 179 | /// Returns the std metadata 180 | pub fn metadata(&self) -> Result { 181 | self.path.metadata() 182 | } 183 | 184 | /// Returns the path.metadata modified as SystemTime. 185 | /// 186 | #[allow(deprecated)] 187 | #[deprecated = "use spath.meta() or spath.metadata"] 188 | pub fn modified(&self) -> Result { 189 | self.path.modified() 190 | } 191 | 192 | /// Returns the epoch duration in microseconds. 193 | /// Note: The maximum UTC date would be approximately `2262-04-11`. 194 | /// Thus, for all intents and purposes, it is far enough not to worry. 195 | #[deprecated = "use spath.meta()"] 196 | pub fn modified_us(&self) -> Result { 197 | Ok(self.meta()?.modified_epoch_us) 198 | } 199 | 200 | /// Returns the file size in bytes as `u64`. 201 | #[deprecated = "use spath.meta()"] 202 | pub fn file_size(&self) -> Result { 203 | let path = self.std_path(); 204 | let metadata = fs::metadata(path).map_err(|ex| Error::CantGetMetadata((path, ex).into()))?; 205 | Ok(metadata.len()) 206 | } 207 | } 208 | 209 | /// Transformers 210 | impl SFile { 211 | pub fn canonicalize(&self) -> Result { 212 | let path = self.path.canonicalize()?; 213 | // Note: here since the previous path was valid, if the spath canonicalization passes, 214 | // we are ok. 215 | Ok(SFile { path }) 216 | } 217 | 218 | // region: --- Collapse 219 | 220 | /// Collapse a path without performing I/O. 221 | /// 222 | /// All redundant separator and up-level references are collapsed. 223 | /// 224 | /// However, this does not resolve links. 225 | pub fn collapse(&self) -> SFile { 226 | SFile { 227 | path: self.path.collapse(), 228 | } 229 | } 230 | 231 | /// Same as [`collapse`] but consume and create a new SPath only if needed 232 | pub fn into_collapsed(self) -> SFile { 233 | if self.is_collapsed() { self } else { self.collapse() } 234 | } 235 | 236 | /// Return `true` if the path is collapsed. 237 | /// 238 | /// # Quirk 239 | /// 240 | /// If the path does not start with `./` but contains `./` in the middle, 241 | /// then this function might returns `true`. 242 | pub fn is_collapsed(&self) -> bool { 243 | crate::is_collapsed(self) 244 | } 245 | 246 | // endregion: --- Collapse 247 | 248 | // region: --- Parent & Join 249 | 250 | /// Returns the parent directory as SPath, if available. 251 | pub fn parent(&self) -> Option { 252 | self.path.parent() 253 | } 254 | 255 | /// Joins the current path with the specified leaf_path. 256 | /// 257 | /// This method creates a new path by joining the existing path with a specified leaf_path 258 | /// and returns the result as an SPath. 259 | pub fn join(&self, leaf_path: impl Into) -> SPath { 260 | self.path.join(leaf_path) 261 | } 262 | 263 | /// Joins a standard Path to the path of this SFile. 264 | pub fn join_std_path(&self, leaf_path: impl AsRef) -> Result { 265 | self.path.join_std_path(leaf_path) 266 | } 267 | 268 | /// Creates a new sibling path with the specified leaf_path. 269 | /// 270 | /// Generates a new path in the same parent directory as the current file, appending the leaf_path. 271 | pub fn new_sibling(&self, leaf_path: &str) -> SPath { 272 | self.path.new_sibling(leaf_path) 273 | } 274 | 275 | /// Creates a new sibling path with the specified standard path. 276 | pub fn new_sibling_std_path(&self, leaf_path: impl AsRef) -> Result { 277 | self.path.new_sibling_std_path(leaf_path) 278 | } 279 | 280 | // endregion: --- Parent & Join 281 | 282 | // region: --- Diff 283 | 284 | pub fn diff(&self, base: impl AsRef) -> Option { 285 | self.path.diff(base) 286 | } 287 | 288 | pub fn try_diff(&self, base: impl AsRef) -> Result { 289 | self.path.try_diff(base) 290 | } 291 | 292 | // endregion: --- Diff 293 | 294 | // region: --- Replace 295 | 296 | pub fn replace_prefix(&self, base: impl AsRef, with: impl AsRef) -> SPath { 297 | let path = &self.path; 298 | path.replace_prefix(base, with) 299 | } 300 | 301 | pub fn into_replace_prefix(self, base: impl AsRef, with: impl AsRef) -> SPath { 302 | let path = self.path; 303 | path.into_replace_prefix(base, with) 304 | } 305 | 306 | // endregion: --- Replace 307 | } 308 | 309 | /// Path/UTF8Path/Camino passthrough 310 | impl SFile { 311 | pub fn as_std_path(&self) -> &Path { 312 | self.path.std_path() 313 | } 314 | 315 | /// Returns a path that, when joined onto `base`, yields `self`. 316 | /// 317 | /// # Errors 318 | /// 319 | /// If `base` is not a prefix of `self` 320 | pub fn strip_prefix(&self, prefix: impl AsRef) -> Result { 321 | self.path.strip_prefix(prefix) 322 | } 323 | 324 | /// Determines whether `base` is a prefix of `self`. 325 | /// 326 | /// Only considers whole path components to match. 327 | /// 328 | /// # Examples 329 | /// 330 | /// ``` 331 | /// use camino::Utf8Path; 332 | /// 333 | /// let path = Utf8Path::new("/etc/passwd"); 334 | /// 335 | /// assert!(path.starts_with("/etc")); 336 | /// assert!(path.starts_with("/etc/")); 337 | /// assert!(path.starts_with("/etc/passwd")); 338 | /// assert!(path.starts_with("/etc/passwd/")); // extra slash is okay 339 | /// assert!(path.starts_with("/etc/passwd///")); // multiple extra slashes are okay 340 | /// 341 | /// assert!(!path.starts_with("/e")); 342 | /// assert!(!path.starts_with("/etc/passwd.txt")); 343 | /// 344 | /// assert!(!Utf8Path::new("/etc/foo.rs").starts_with("/etc/foo")); 345 | /// ``` 346 | pub fn starts_with(&self, base: impl AsRef) -> bool { 347 | self.path.starts_with(base) 348 | } 349 | } 350 | 351 | // region: --- Std Traits Impls 352 | 353 | impl AsRef for SFile { 354 | fn as_ref(&self) -> &Path { 355 | self.path.as_ref() 356 | } 357 | } 358 | 359 | impl fmt::Display for SFile { 360 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 361 | write!(f, "{}", self.as_str()) 362 | } 363 | } 364 | 365 | // endregion: --- Std Traits Impls 366 | 367 | // region: --- AsRefs 368 | 369 | impl AsRef for SFile { 370 | fn as_ref(&self) -> &SFile { 371 | self 372 | } 373 | } 374 | 375 | impl AsRef for SFile { 376 | fn as_ref(&self) -> &Utf8Path { 377 | self.path.as_ref() 378 | } 379 | } 380 | 381 | impl AsRef for SFile { 382 | fn as_ref(&self) -> &str { 383 | self.as_str() 384 | } 385 | } 386 | 387 | impl AsRef for SFile { 388 | fn as_ref(&self) -> &SPath { 389 | &self.path 390 | } 391 | } 392 | 393 | // endregion: --- AsRefs 394 | 395 | // region: --- Froms 396 | 397 | impl From for String { 398 | fn from(val: SFile) -> Self { 399 | val.as_str().to_string() 400 | } 401 | } 402 | 403 | impl From<&SFile> for String { 404 | fn from(val: &SFile) -> Self { 405 | val.as_str().to_string() 406 | } 407 | } 408 | 409 | impl From for PathBuf { 410 | fn from(val: SFile) -> Self { 411 | val.into_std_path_buf() 412 | } 413 | } 414 | 415 | impl From<&SFile> for PathBuf { 416 | fn from(val: &SFile) -> Self { 417 | val.std_path().to_path_buf() 418 | } 419 | } 420 | 421 | impl From for Utf8PathBuf { 422 | fn from(val: SFile) -> Self { 423 | val.path.path_buf 424 | } 425 | } 426 | 427 | impl From for SPath { 428 | fn from(val: SFile) -> Self { 429 | val.path 430 | } 431 | } 432 | 433 | impl From<&SFile> for SPath { 434 | fn from(val: &SFile) -> Self { 435 | val.path.clone() 436 | } 437 | } 438 | 439 | // endregion: --- Froms 440 | 441 | // region: --- TryFroms 442 | 443 | impl TryFrom<&str> for SFile { 444 | type Error = Error; 445 | fn try_from(path: &str) -> Result { 446 | let path = SPath::from(path); 447 | validate_sfile_for_result(&path)?; 448 | Ok(Self { path }) 449 | } 450 | } 451 | 452 | impl TryFrom for SFile { 453 | type Error = Error; 454 | fn try_from(path: String) -> Result { 455 | SFile::try_from(path.as_str()) 456 | } 457 | } 458 | 459 | impl TryFrom<&String> for SFile { 460 | type Error = Error; 461 | fn try_from(path: &String) -> Result { 462 | SFile::try_from(path.as_str()) 463 | } 464 | } 465 | 466 | impl TryFrom for SFile { 467 | type Error = Error; 468 | fn try_from(path_buf: PathBuf) -> Result { 469 | SFile::from_std_path_buf(path_buf) 470 | } 471 | } 472 | 473 | impl TryFrom for SFile { 474 | type Error = Error; 475 | fn try_from(fs_entry: fs::DirEntry) -> Result { 476 | let path = SPath::try_from(fs_entry)?; 477 | validate_sfile_for_result(&path)?; 478 | Ok(Self { path }) 479 | } 480 | } 481 | 482 | impl TryFrom for SFile { 483 | type Error = Error; 484 | fn try_from(wd_entry: walkdir::DirEntry) -> Result { 485 | let path = SPath::try_from(wd_entry)?; 486 | validate_sfile_for_result(&path)?; 487 | Ok(Self { path }) 488 | } 489 | } 490 | 491 | impl TryFrom for SFile { 492 | type Error = Error; 493 | fn try_from(path: SPath) -> Result { 494 | validate_sfile_for_result(&path)?; 495 | Ok(Self { path }) 496 | } 497 | } 498 | // endregion: --- TryFroms 499 | 500 | // region: --- File Validation 501 | 502 | fn validate_sfile_for_result(path: &SPath) -> Result<()> { 503 | if path.is_file() { 504 | Ok(()) 505 | } else { 506 | Err(Error::FileNotFound(path.as_str().to_string())) 507 | } 508 | } 509 | 510 | /// Validate but without generating an error (good for the _ok constructors) 511 | fn validate_sfile_for_option(path: &SPath) -> Option<()> { 512 | if path.is_file() { Some(()) } else { None } 513 | } 514 | 515 | // endregion: --- File Validation 516 | -------------------------------------------------------------------------------- /doc/for-llm/api-reference-for-llm.md: -------------------------------------------------------------------------------- 1 | # simple-fs – Public APIs (by category) 2 | 3 | Note: Root crate re-exports most modules, so items are accessible from the crate root unless noted. 4 | 5 | ```toml 6 | simple-fs = "0.9.1" 7 | # or with features 8 | simple-fs = {version = "0.9.1", features = ["with-json", "with-toml", "bin-nums"]} 9 | # or `features = ["full"] 10 | ``` 11 | 12 | ## Core 13 | 14 | - Type alias: `Result = core::result::Result` 15 | 16 | - Error type: `Error` 17 | 18 | 19 | ## Paths (SPath) 20 | 21 | - Type: `SPath` (UTF-8, normalized posix-style path) 22 | 23 | - Constructors 24 | - `SPath::new(path: impl Into) -> SPath` 25 | 26 | - `SPath::from_std_path_buf(path_buf: PathBuf) -> Result` 27 | 28 | - `SPath::from_std_path(path: impl AsRef) -> Result` 29 | 30 | - `SPath::from_walkdir_entry(wd_entry: walkdir::DirEntry) -> Result` 31 | 32 | - `SPath::from_std_path_ok(path: impl AsRef) -> Option` 33 | 34 | - `SPath::from_std_path_buf_ok(path_buf: PathBuf) -> Option` 35 | 36 | - `SPath::from_fs_entry_ok(fs_entry: fs::DirEntry) -> Option` 37 | 38 | - `SPath::from_walkdir_entry_ok(wd_entry: walkdir::DirEntry) -> Option` 39 | 40 | - Conversions (consuming / views) 41 | - `SPath::into_std_path_buf(self) -> PathBuf` 42 | 43 | - `SPath::std_path(&self) -> &Path` 44 | 45 | - `SPath::path(&self) -> &Utf8Path` 46 | 47 | - `SPath::as_std_path(&self) -> &Path` 48 | 49 | - Getters 50 | - `SPath::to_str(&self) -> &str` (deprecated) 51 | 52 | - `SPath::as_str(&self) -> &str` 53 | 54 | - `SPath::file_name(&self) -> Option<&str>` 55 | 56 | - `SPath::name(&self) -> &str` 57 | 58 | - `SPath::parent_name(&self) -> &str` 59 | 60 | - `SPath::file_stem(&self) -> Option<&str>` 61 | 62 | - `SPath::stem(&self) -> &str` 63 | 64 | - `SPath::extension(&self) -> Option<&str>` 65 | 66 | - `SPath::ext(&self) -> &str` 67 | 68 | - `SPath::is_dir(&self) -> bool` 69 | 70 | - `SPath::is_file(&self) -> bool` 71 | 72 | - `SPath::exists(&self) -> bool` 73 | 74 | - `SPath::is_absolute(&self) -> bool` 75 | 76 | - `SPath::is_relative(&self) -> bool` 77 | 78 | - Metadata 79 | - `SPath::meta(&self) -> Result` 80 | 81 | - `SPath::metadata(&self) -> Result` 82 | 83 | - `SPath::modified(&self) -> Result` (deprecated) 84 | 85 | - `SPath::modified_us(&self) -> Result` (deprecated) 86 | 87 | - Transformers 88 | - `SPath::canonicalize(&self) -> Result` 89 | 90 | - `SPath::collapse(&self) -> SPath` 91 | 92 | - `SPath::into_collapsed(self) -> SPath` 93 | 94 | - `SPath::is_collapsed(&self) -> bool` 95 | 96 | - Parent & Join 97 | - `SPath::parent(&self) -> Option` 98 | 99 | - `SPath::append_suffix(&self, suffix: &str) -> SPath` 100 | 101 | - `SPath::join(&self, leaf_path: impl Into) -> SPath` 102 | 103 | - `SPath::join_std_path(&self, leaf_path: impl AsRef) -> Result` 104 | 105 | - `SPath::new_sibling(&self, leaf_path: impl AsRef) -> SPath` 106 | 107 | - `SPath::new_sibling_std_path(&self, leaf_path: impl AsRef) -> Result` 108 | 109 | - Diff 110 | - `SPath::diff(&self, base: impl AsRef) -> Option` 111 | 112 | - `SPath::try_diff(&self, base: impl AsRef) -> Result` 113 | 114 | - Replace 115 | - `SPath::replace_prefix(&self, base: impl AsRef, with: impl AsRef) -> SPath` 116 | 117 | - `SPath::into_replace_prefix(self, base: impl AsRef, with: impl AsRef) -> SPath` 118 | 119 | - Extensions 120 | - `SPath::into_ensure_extension(self, ext: &str) -> SPath` 121 | 122 | - `SPath::ensure_extension(&self, ext: &str) -> SPath` 123 | 124 | - `SPath::append_extension(&self, ext: &str) -> SPath` 125 | 126 | - Other 127 | - `SPath::strip_prefix(&self, prefix: impl AsRef) -> Result` 128 | 129 | - `SPath::starts_with(&self, base: impl AsRef) -> bool` 130 | 131 | - `SPath::dir_before_glob(&self) -> Option` 132 | 133 | - Traits (summary) 134 | - Display 135 | 136 | - AsRef / AsRef / AsRef / AsRef 137 | 138 | - From / From<&Utf8Path> / From / From<&String> / From<&str> 139 | 140 | - From for String / PathBuf / Utf8PathBuf 141 | 142 | - From<&SPath> for String / PathBuf 143 | 144 | - TryFrom / TryFrom / TryFrom 145 | 146 | 147 | ## Files (SFile) 148 | 149 | - Type: `SFile` (valid UTF-8 path guaranteed to be an existing file) 150 | 151 | - Constructors 152 | - `SFile::new(path: impl Into) -> Result` 153 | 154 | - `SFile::from_std_path_buf(path_buf: PathBuf) -> Result` 155 | 156 | - `SFile::from_std_path(path: impl AsRef) -> Result` 157 | 158 | - `SFile::from_walkdir_entry(wd_entry: walkdir::DirEntry) -> Result` 159 | 160 | - `SFile::from_std_path_ok(path: impl AsRef) -> Option` 161 | 162 | - `SFile::from_std_path_buf_ok(path_buf: PathBuf) -> Option` 163 | 164 | - `SFile::from_fs_entry_ok(fs_entry: fs::DirEntry) -> Option` 165 | 166 | - `SFile::from_walkdir_entry_ok(wd_entry: walkdir::DirEntry) -> Option` 167 | 168 | - Conversions (consuming / views) 169 | - `SFile::into_std_path_buf(self) -> PathBuf` 170 | 171 | - `SFile::std_path(&self) -> &Path` 172 | 173 | - `SFile::path(&self) -> &SPath` 174 | 175 | - `SFile::as_std_path(&self) -> &Path` 176 | 177 | - Getters 178 | - `SFile::to_str(&self) -> &str` (deprecated) 179 | 180 | - `SFile::as_str(&self) -> &str` 181 | 182 | - `SFile::file_name(&self) -> Option<&str>` 183 | 184 | - `SFile::name(&self) -> &str` 185 | 186 | - `SFile::parent_name(&self) -> &str` 187 | 188 | - `SFile::file_stem(&self) -> Option<&str>` 189 | 190 | - `SFile::stem(&self) -> &str` 191 | 192 | - `SFile::extension(&self) -> Option<&str>` 193 | 194 | - `SFile::ext(&self) -> &str` 195 | 196 | - `SFile::is_absolute(&self) -> bool` 197 | 198 | - `SFile::is_relative(&self) -> bool` 199 | 200 | - Metadata 201 | - `SFile::meta(&self) -> Result` 202 | 203 | - `SFile::metadata(&self) -> Result` 204 | 205 | - `SFile::modified(&self) -> Result` (deprecated) 206 | 207 | - `SFile::modified_us(&self) -> Result` (deprecated) 208 | 209 | - `SFile::file_size(&self) -> Result` (deprecated) 210 | 211 | - Transformers 212 | - `SFile::canonicalize(&self) -> Result` 213 | 214 | - `SFile::collapse(&self) -> SFile` 215 | 216 | - `SFile::into_collapsed(self) -> SFile` 217 | 218 | - `SFile::is_collapsed(&self) -> bool` 219 | 220 | - Parent & Join 221 | - `SFile::parent(&self) -> Option` 222 | 223 | - `SFile::join(&self, leaf_path: impl Into) -> SPath` 224 | 225 | - `SFile::join_std_path(&self, leaf_path: impl AsRef) -> Result` 226 | 227 | - `SFile::new_sibling(&self, leaf_path: &str) -> SPath` 228 | 229 | - `SFile::new_sibling_std_path(&self, leaf_path: impl AsRef) -> Result` 230 | 231 | - Diff 232 | - `SFile::diff(&self, base: impl AsRef) -> Option` 233 | 234 | - `SFile::try_diff(&self, base: impl AsRef) -> Result` 235 | 236 | - Replace 237 | - `SFile::replace_prefix(&self, base: impl AsRef, with: impl AsRef) -> SPath` 238 | 239 | - `SFile::into_replace_prefix(self, base: impl AsRef, with: impl AsRef) -> SPath` 240 | 241 | - Other 242 | - `SFile::strip_prefix(&self, prefix: impl AsRef) -> Result` 243 | 244 | - `SFile::starts_with(&self, base: impl AsRef) -> bool` 245 | 246 | - Traits (summary) 247 | - Display 248 | 249 | - AsRef / AsRef / AsRef / AsRef / AsRef 250 | 251 | - From for String / PathBuf / Utf8PathBuf / SPath 252 | 253 | - From<&SFile> for String / PathBuf / SPath 254 | 255 | - TryFrom<&str> / String / &String / PathBuf / fs::DirEntry / walkdir::DirEntry / SPath 256 | 257 | 258 | ## File I/O 259 | 260 | - `create_file(file_path: impl AsRef) -> Result` 261 | 262 | - `read_to_string(file_path: impl AsRef) -> Result` 263 | 264 | - `open_file(path: impl AsRef) -> Result` 265 | 266 | - `get_buf_reader(file: impl AsRef) -> Result>` 267 | 268 | - `get_buf_writer(file_path: impl AsRef) -> Result>` 269 | 270 | 271 | ## Spans 272 | 273 | - `read_span(path: impl AsRef, start: usize, end: usize) -> Result` 274 | 275 | - `line_spans(path: impl AsRef) -> Result>` 276 | 277 | - `csv_row_spans(path: impl AsRef) -> Result>` 278 | 279 | 280 | ## Directories 281 | 282 | - `ensure_dir(dir: impl AsRef) -> Result` 283 | 284 | - `ensure_file_dir(file_path: impl AsRef) -> Result` 285 | 286 | 287 | ## Listing & Globbing 288 | 289 | - Directory iteration 290 | - `iter_dirs(dir: impl AsRef, include_globs: Option<&[&str]>, list_options: Option>) -> Result>` 291 | 292 | - `list_dirs(dir: impl AsRef, include_globs: Option<&[&str]>, list_options: Option>) -> Result>` 293 | 294 | - File iteration 295 | - `iter_files(dir: impl AsRef, include_globs: Option<&[&str]>, list_options: Option>) -> Result` 296 | 297 | - `list_files(dir: impl AsRef, include_globs: Option<&[&str]>, list_options: Option>) -> Result>` 298 | 299 | - Type: `GlobsFileIter` (Iterator) 300 | 301 | - Options 302 | - `ListOptions<'a> { exclude_globs: Option>, relative_glob: bool, depth: Option }` 303 | 304 | - `ListOptions::new(globs: Option<&'a [&'a str]>) -> ListOptions<'a>` 305 | 306 | - `ListOptions::from_relative_glob(val: bool) -> ListOptions<'a>` 307 | 308 | - `ListOptions::with_exclude_globs(self, globs: &'a [&'a str]) -> Self` 309 | 310 | - `ListOptions::with_relative_glob(self) -> Self` 311 | 312 | - `ListOptions::exclude_globs(&'a self) -> Option<&'a [&'a str]>` 313 | 314 | - From conversions: `From<&'a [&'a str]>`, `From>`, `From>` 315 | 316 | - Glob utilities 317 | - `DEFAULT_EXCLUDE_GLOBS: &[&str]` 318 | 319 | - `get_glob_set(globs: &[&str]) -> Result` 320 | 321 | - `longest_base_path_wild_free(pattern: &SPath) -> SPath` 322 | 323 | - `get_depth(patterns: &[&str], depth: Option) -> usize` 324 | 325 | - Sorting 326 | - `sort_by_globs(items: Vec, globs: &[&str], end_weighted: bool) -> Result> where T: AsRef` 327 | 328 | 329 | ## Reshape / Normalize 330 | 331 | - Normalizer 332 | - `needs_normalize(path: &Utf8Path) -> bool` 333 | 334 | - `into_normalized(path: Utf8PathBuf) -> Utf8PathBuf` 335 | 336 | - Collapser 337 | - `into_collapsed(path: impl Into) -> Utf8PathBuf` 338 | 339 | - `try_into_collapsed(path: impl Into) -> Option` 340 | 341 | - `is_collapsed(path: impl AsRef) -> bool` 342 | 343 | 344 | ## Safer Remove 345 | 346 | - Function: `safer_remove_dir(dir_path: &SPath, options: impl Into>) -> Result` 347 | - Function: `safer_remove_file(file_path: &SPath, options: impl Into>) -> Result` 348 | 349 | - Type: `SaferRemoveOptions<'a>` 350 | - `SaferRemoveOptions::default()` (which defaults `restrict_to_current_dir` to `true`) 351 | - `SaferRemoveOptions::with_must_contain_any(self, patterns: &'a [&'a str]) -> Self` 352 | - `SaferRemoveOptions::with_must_contain_all(self, patterns: &'a [&'a str]) -> Self` 353 | - `SaferRemoveOptions::with_restrict_to_current_dir(self, val: bool) -> Self` 354 | 355 | 356 | ## Common 357 | 358 | - Pretty size 359 | - `struct PrettySizeOptions { lowest_unit: SizeUnit }` 360 | 361 | - `enum SizeUnit { B, KB, MB, GB, TB }` 362 | 363 | - `SizeUnit::new(val: &str) -> SizeUnit` 364 | 365 | - `pretty_size(size_in_bytes: u64) -> String` 366 | 367 | - `pretty_size_with_options(size_in_bytes: u64, options: impl Into) -> String` 368 | 369 | - File metadata 370 | - `struct SMeta { created_epoch_us: i64, modified_epoch_us: i64, size: u64, is_file: bool, is_dir: bool }` 371 | 372 | 373 | ## Watch 374 | 375 | - `watch(path: impl AsRef) -> Result` 376 | 377 | - `struct SWatcher { rx: flume::Receiver>, /* keeps internal debouncer alive */ }` 378 | 379 | - `struct SEvent { spath: SPath, skind: SEventKind }` 380 | 381 | - `enum SEventKind { Create, Modify, Remove, Other }` 382 | 383 | - Re-export: `DebouncedEvent` (from `notify_debouncer_full`) 384 | 385 | 386 | ## Feature-gated: with-json 387 | 388 | - Load 389 | - `load_json(file: impl AsRef) -> Result` 390 | 391 | - `load_ndjson(file: impl AsRef) -> Result>` 392 | 393 | - `stream_ndjson(file: impl AsRef) -> Result>>` 394 | 395 | - Parse (NDJSON) 396 | - `parse_ndjson_iter(input: &str) -> impl Iterator>` 397 | 398 | - `parse_ndjson(input: &str) -> Result>` 399 | 400 | - `parse_ndjson_from_reader(reader: R) -> Result>` 401 | 402 | - `parse_ndjson_iter_from_reader(reader: R) -> impl Iterator>` 403 | 404 | - Save 405 | - `save_json(file: impl AsRef, data: &T) -> Result<()>` 406 | 407 | - `save_json_pretty(file: impl AsRef, data: &T) -> Result<()>` 408 | 409 | - `append_json_line(file: impl AsRef, value: &T) -> Result<()>` 410 | 411 | - `append_json_lines<'a, T: serde::Serialize + 'a, I: IntoIterator>(file: impl AsRef, values: I) -> Result<()>` 412 | 413 | 414 | ## Feature-gated: with-toml 415 | 416 | - `load_toml(file_path: impl AsRef) -> Result` 417 | 418 | - `save_toml(file_path: impl AsRef, data: &T) -> Result<()>` 419 | 420 | 421 | ## Feature-gated: bin-nums 422 | 423 | - Load (binary) 424 | - `load_be_f64(file) -> Result>`, `load_le_f64(file) -> Result>` 425 | 426 | - `load_be_f32(file) -> Result>`, `load_le_f32(file) -> Result>` 427 | 428 | - `load_be_u64(file) -> Result>`, `load_le_u64(file) -> Result>` 429 | 430 | - `load_be_u32(file) -> Result>`, `load_le_u32(file) -> Result>` 431 | 432 | - `load_be_u16(file) -> Result>`, `load_le_u16(file) -> Result>` 433 | 434 | - `load_be_i64(file) -> Result>`, `load_le_i64(file) -> Result>` 435 | 436 | - `load_be_i32(file) -> Result>`, `load_le_i32(file) -> Result>` 437 | 438 | - `load_be_i16(file) -> Result>`, `load_le_i16(file) -> Result>` 439 | 440 | - Save (binary) 441 | - `save_be_f64(file, data: &[f64]) -> Result<()>`, `save_le_f64(file, data: &[f64]) -> Result<()>` 442 | 443 | - `save_be_f32(file, data: &[f32]) -> Result<()>`, `save_le_f32(file, data: &[f32]) -> Result<()>` 444 | 445 | - `save_be_u64(file, data: &[u64]) -> Result<()>`, `save_le_u64(file, data: &[u64]) -> Result<()>` 446 | 447 | - `save_be_u32(file, data: &[u32]) -> Result<()>`, `save_le_u32(file, data: &[u32]) -> Result<()>` 448 | 449 | - `save_be_u16(file, data: &[u16]) -> Result<()>`, `save_le_u16(file, data: &[u16]) -> Result<()>` 450 | 451 | - `save_be_i64(file, data: &[i64]) -> Result<()>`, `save_le_i64(file, data: &[i64]) -> Result<()>` 452 | 453 | - `save_be_i32(file, data: &[i32]) -> Result<()>`, `save_le_i32(file, data: &[i32]) -> Result<()>` 454 | 455 | - `save_be_i16(file, data: &[i16]) -> Result<()>`, `save_le_i16(file, data: &[i16]) -> Result()` 456 | -------------------------------------------------------------------------------- /tests/tests_list_files.rs: -------------------------------------------------------------------------------- 1 | use simple_fs::{ListOptions, SFile, SPath, iter_files, list_files}; 2 | 3 | pub type Result = core::result::Result>; 4 | 5 | #[test] 6 | fn test_list_files_one_level_dotted() -> Result<()> { 7 | // -- Exec 8 | let res = list_files("./tests-data/", Some(&["./tests-data/*.txt"]), None)?; 9 | 10 | // -- Check 11 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 12 | assert_eq!(res.len(), 1, "Should have 1 file with *.txt in tests-data"); 13 | assert!( 14 | res_paths.contains(&"./tests-data/file2.txt"), 15 | "Should contain file2.txt" 16 | ); 17 | assert!( 18 | res_paths.iter().any(|p| p.ends_with("file2.txt")), 19 | "Should contain file2.txt" 20 | ); 21 | 22 | Ok(()) 23 | } 24 | 25 | #[test] 26 | fn test_list_files_rel_one_level_dotted() -> Result<()> { 27 | // NOTE With relative_glob, "*.txt" now works 28 | // -- Exec 29 | let res = list_files( 30 | "./tests-data/", 31 | Some(&["*.txt"]), 32 | Some(ListOptions::from_relative_glob(true)), 33 | )?; 34 | 35 | // -- Check 36 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 37 | assert_eq!(res.len(), 1, "Should have 1 file with *.txt in tests-data"); 38 | assert!( 39 | res_paths.iter().any(|p| p.ends_with("file2.txt")), 40 | "Should contain file2.txt" 41 | ); 42 | 43 | Ok(()) 44 | } 45 | 46 | #[test] 47 | fn test_list_files_rel_one_level_no_file() -> Result<()> { 48 | // -- Exec 49 | let res = list_files( 50 | "./tests-data/", 51 | Some(&["*.rs"]), 52 | Some(ListOptions::from_relative_glob(true)), 53 | )?; 54 | 55 | // -- Check 56 | assert_eq!(res.len(), 0, "Should have 0 files with *.rs in tests-data dir"); 57 | 58 | Ok(()) 59 | } 60 | 61 | #[test] 62 | fn test_list_files_one_level_no_file() -> Result<()> { 63 | // -- Exec 64 | let res = list_files("./tests-data/", Some(&["./tests-data/*.rs"]), None)?; 65 | 66 | // -- Check 67 | assert_eq!(res.len(), 0, "Should have 0 files with *.rs in tests-data dir"); 68 | 69 | Ok(()) 70 | } 71 | 72 | #[test] 73 | fn test_list_files_one_file_dotted() -> Result<()> { 74 | // -- Exec 75 | let res = list_files("./tests-data", Some(&["./tests-data/file1.md"]), None)?; 76 | 77 | // -- Check 78 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 79 | assert_eq!(res.len(), 1, "Should have 1 file"); 80 | assert!(res_paths.contains(&"./tests-data/file1.md"), "Should contain file1.md"); 81 | 82 | Ok(()) 83 | } 84 | 85 | #[test] 86 | fn test_list_files_sub_level_dotted() -> Result<()> { 87 | // -- Exec 88 | let res = list_files("./tests-data/", Some(&["./tests-data/**/*.md"]), None)?; 89 | 90 | // -- Check 91 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 92 | assert_md_files_res(&res_paths); 93 | 94 | Ok(()) 95 | } 96 | 97 | #[test] 98 | fn test_list_files_sub_dir_full_path() -> Result<()> { 99 | // -- Exec 100 | let res = list_files("./tests-data/dir1/", Some(&["./tests-data/dir1/**/*.md"]), None)?; 101 | 102 | // -- Check 103 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 104 | assert_eq!(res_paths.len(), 3, "Should have 3 markdown files in dir1"); 105 | assert!( 106 | res_paths.contains(&"./tests-data/dir1/file3.md"), 107 | "Should contain dir1/file3.md" 108 | ); 109 | assert!( 110 | res_paths.contains(&"./tests-data/dir1/dir2/file5.md"), 111 | "Should contain dir1/dir2/file5.md" 112 | ); 113 | assert!( 114 | res_paths.contains(&"./tests-data/dir1/dir2/dir3/file7.md"), 115 | "Should contain dir1/dir2/dir3/file7.md" 116 | ); 117 | 118 | Ok(()) 119 | } 120 | 121 | /// Here the globs are relative to the base dir given (here `./tests-data/`) 122 | #[test] 123 | fn test_list_files_sub_dir_rel_glob() -> Result<()> { 124 | // -- Exec 125 | let res = list_files( 126 | "./tests-data/", 127 | Some(&["**/*.md"]), 128 | Some(ListOptions::from_relative_glob(true)), 129 | )?; 130 | 131 | // -- Check 132 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 133 | assert_md_files_res(&res_paths); 134 | 135 | Ok(()) 136 | } 137 | 138 | #[test] 139 | fn test_list_files_absolute_wildcard() -> Result<()> { 140 | // Get the absolute path to the "tests-data" directory. 141 | let test_data_abs = SPath::new("./tests-data"); 142 | let test_data_abs_str = test_data_abs.as_str(); 143 | 144 | // Construct a glob pattern that should match the "file1.md" file. 145 | let pattern = format!("{test_data_abs_str}/**/*1.md"); 146 | 147 | // -- Exec 148 | // Execute list_files using the tests-data directory and the wildcard pattern. 149 | let files = list_files("./tests-data/", Some(&[pattern.as_str()]), None)?; 150 | 151 | // -- Check 152 | // Check that at least one file's path ends with "file1.md" 153 | let found = files.iter().any(|p| p.as_str().ends_with("file1.md")); 154 | assert!(found, "Expected to find file1.md file with wildcard absolute pattern"); 155 | 156 | Ok(()) 157 | } 158 | 159 | #[test] 160 | fn test_list_files_absolute_direct() -> Result<()> { 161 | // Get the absolute path to "tests-data/file1.md". 162 | let file_abs = std::fs::canonicalize("tests-data/file1.md")?; 163 | let file_abs = SPath::from_std_path_buf(file_abs)?; 164 | 165 | // Get the parent directory of the file. 166 | let parent_dir = file_abs.parent().ok_or("Should have parent dir")?; 167 | 168 | // -- Exec 169 | // Execute list_files using the parent directory and an exact match glob for the file. 170 | let files = list_files(parent_dir, Some(&[file_abs.as_str()]), None)?; 171 | 172 | // -- Check 173 | assert_eq!(files.len(), 1, "Should have exactly one file with exact match"); 174 | 175 | let returned_path = files[0].as_str(); 176 | assert_eq!( 177 | returned_path, 178 | file_abs.as_str(), 179 | "The file path should match the absolute file path" 180 | ); 181 | 182 | Ok(()) 183 | } 184 | 185 | #[test] 186 | fn test_list_files_mixed_absolute_and_relative_globs() -> Result<()> { 187 | // -- Exec 188 | // Mix an absolute glob and a relative glob in the same call. 189 | let abs_pattern = SPath::new("./tests-data/file1.md").canonicalize()?; 190 | let patterns = [abs_pattern.as_str(), "tests-data/file2.txt"]; 191 | let res = list_files("./", Some(&patterns), None)?; 192 | 193 | // -- Check 194 | let res_paths: Vec<&str> = res.iter().map(|p| p.as_str()).collect(); 195 | 196 | assert_eq!(res.len(), 2, "Expected both files to be found using mixed patterns"); 197 | assert!( 198 | res_paths.iter().any(|&p| p.ends_with("file1.md")), 199 | "Should contain file1.md" 200 | ); 201 | assert!( 202 | res_paths.iter().any(|&p| p.ends_with("file2.txt")), 203 | "Should contain file2.txt" 204 | ); 205 | Ok(()) 206 | } 207 | 208 | #[test] 209 | fn test_list_files_mixed_absolute_and_relative_globs_with_relative_option() -> Result<()> { 210 | // -- Exec 211 | // Mix an absolute glob and a relative glob with the relative_glob option enabled. 212 | let abs_pattern = SPath::new("./tests-data/file1.md"); 213 | let patterns = ["**/*.txt", abs_pattern.as_str()]; 214 | let res = list_files( 215 | "./tests-data/", 216 | Some(&patterns), 217 | Some(ListOptions::from_relative_glob(true)), 218 | )?; 219 | 220 | // -- Check 221 | let res_paths: Vec<&str> = res.iter().map(|p| p.as_str()).collect(); 222 | assert!( 223 | res.len() >= 6, 224 | "Expected at least 6 files to be found using mixed patterns with relative_glob option" 225 | ); 226 | assert!( 227 | res_paths.iter().any(|&p| p.ends_with("file1.md")), 228 | "Should contain file1.md" 229 | ); 230 | assert!( 231 | res_paths.iter().any(|&p| p.ends_with("file2.txt")), 232 | "Should contain file2.txt" 233 | ); 234 | Ok(()) 235 | } 236 | 237 | #[test] 238 | fn test_list_iter_files_simple_glob_ok() -> Result<()> { 239 | // -- Exec 240 | let iter = iter_files("./tests-data/", Some(&["./tests-data/*.md"]), None)?; 241 | let res: Vec = iter.collect(); 242 | 243 | // -- Check 244 | let count = res.len(); 245 | assert_eq!(count, 1, "Expected 1 file matching pattern"); 246 | Ok(()) 247 | } 248 | 249 | #[test] 250 | fn test_list_iter_files_nested_and_exclude_ok() -> Result<()> { 251 | // -- Exec 252 | let excludes = [simple_fs::DEFAULT_EXCLUDE_GLOBS, &["**/.devai", "*.lock", "**/dir2/**"]].concat(); 253 | let iter = iter_files("./tests-data/", Some(&["./tests-data/**/*.md"]), Some(excludes.into()))?; 254 | 255 | // -- Check 256 | let count = iter.count(); 257 | assert_eq!(count, 5, "Expected 5 files matching pattern after exclusions"); 258 | Ok(()) 259 | } 260 | 261 | #[test] 262 | fn test_list_files_with_negative_glob() -> Result<()> { 263 | // -- Exec 264 | // Include all markdown files but exclude those in dir2 265 | let res = list_files( 266 | "./tests-data/", 267 | Some(&["./tests-data/**/*.md", "!./tests-data/**/dir2/**"]), 268 | None, 269 | )?; 270 | 271 | // -- Check 272 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 273 | assert_eq!(res.len(), 5, "Should have 5 markdown files (excluding dir2)"); 274 | 275 | assert!(res_paths.contains(&"./tests-data/file1.md"), "Should contain file1.md"); 276 | assert!( 277 | res_paths.contains(&"./tests-data/dir1/file3.md"), 278 | "Should contain dir1/file3.md" 279 | ); 280 | assert!( 281 | !res_paths.contains(&"./tests-data/dir1/dir2/file5.md"), 282 | "Should not contain dir1/dir2/file5.md" 283 | ); 284 | assert!( 285 | !res_paths.contains(&"./tests-data/dir1/dir2/dir3/file7.md"), 286 | "Should not contain dir1/dir2/dir3/file7.md" 287 | ); 288 | assert!( 289 | res_paths.contains(&"./tests-data/another-dir/notes.md"), 290 | "Should contain another-dir/notes.md" 291 | ); 292 | assert!( 293 | res_paths.contains(&"./tests-data/another-dir/sub-dir/example.md"), 294 | "Should contain another-dir/sub-dir/example.md" 295 | ); 296 | assert!( 297 | res_paths.contains(&"./tests-data/another-dir/sub-dir/deep-folder/final.md"), 298 | "Should contain another-dir/sub-dir/deep-folder/final.md" 299 | ); 300 | 301 | Ok(()) 302 | } 303 | 304 | #[test] 305 | fn test_list_files_with_multiple_negative_globs() -> Result<()> { 306 | // -- Exec 307 | // Include all markdown files but exclude multiple patterns 308 | let res = list_files( 309 | "./tests-data/", 310 | Some(&[ 311 | "./tests-data/**/*.md", // Include all markdown files 312 | "!./tests-data/**/dir2/**", // Exclude dir2 files 313 | "!./tests-data/**/*final.md", // Exclude final.md files 314 | ]), 315 | None, 316 | )?; 317 | 318 | // -- Check 319 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 320 | assert_eq!(res.len(), 4, "Should have 4 markdown files after multiple exclusions"); 321 | 322 | assert!(res_paths.contains(&"./tests-data/file1.md"), "Should contain file1.md"); 323 | assert!( 324 | res_paths.contains(&"./tests-data/dir1/file3.md"), 325 | "Should contain dir1/file3.md" 326 | ); 327 | assert!( 328 | !res_paths.contains(&"./tests-data/dir1/dir2/file5.md"), 329 | "Should not contain dir1/dir2/file5.md" 330 | ); 331 | assert!( 332 | !res_paths.contains(&"./tests-data/dir1/dir2/dir3/file7.md"), 333 | "Should not contain dir1/dir2/dir3/file7.md" 334 | ); 335 | assert!( 336 | res_paths.contains(&"./tests-data/another-dir/notes.md"), 337 | "Should contain another-dir/notes.md" 338 | ); 339 | assert!( 340 | res_paths.contains(&"./tests-data/another-dir/sub-dir/example.md"), 341 | "Should contain another-dir/sub-dir/example.md" 342 | ); 343 | assert!( 344 | !res_paths.contains(&"./tests-data/another-dir/sub-dir/deep-folder/final.md"), 345 | "Should not contain another-dir/sub-dir/deep-folder/final.md" 346 | ); 347 | 348 | Ok(()) 349 | } 350 | 351 | #[test] 352 | fn test_list_files_with_only_negative_globs() -> Result<()> { 353 | // -- Exec 354 | // Only use negative patterns (should default to ** for includes) 355 | let res = list_files( 356 | "./tests-data/", 357 | Some(&[ 358 | "!./tests-data/**/*.txt", // Exclude all txt files 359 | ]), 360 | None, 361 | )?; 362 | 363 | // -- Check 364 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 365 | assert!(res.len() >= 7, "Should have at least 7 files after excluding txt files"); 366 | 367 | // Ensure no txt files are included 368 | assert!( 369 | !res_paths.iter().any(|p| p.ends_with(".txt")), 370 | "Should not contain any .txt files" 371 | ); 372 | 373 | // Check for md files as a sanity check 374 | assert!(res_paths.iter().any(|p| p.ends_with(".md")), "Should contain .md files"); 375 | 376 | Ok(()) 377 | } 378 | 379 | #[test] 380 | fn test_list_files_relative_negative_glob() -> Result<()> { 381 | // -- Exec 382 | // Use relative globs with negative patterns 383 | let res = list_files( 384 | "./tests-data/", 385 | Some(&[ 386 | "**/*.md", // Include all markdown files 387 | "!**/dir2/**", // Exclude dir2 files (using relative glob) 388 | ]), 389 | Some(ListOptions::from_relative_glob(true)), 390 | )?; 391 | 392 | // -- Check 393 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 394 | assert_eq!(res.len(), 5, "Should have 5 markdown files (excluding dir2)"); 395 | 396 | assert!(res_paths.contains(&"./tests-data/file1.md"), "Should contain file1.md"); 397 | assert!( 398 | res_paths.contains(&"./tests-data/dir1/file3.md"), 399 | "Should contain dir1/file3.md" 400 | ); 401 | assert!( 402 | !res_paths.contains(&"./tests-data/dir1/dir2/file5.md"), 403 | "Should not contain dir1/dir2/file5.md" 404 | ); 405 | assert!( 406 | !res_paths.contains(&"./tests-data/dir1/dir2/dir3/file7.md"), 407 | "Should not contain dir1/dir2/dir3/file7.md" 408 | ); 409 | 410 | Ok(()) 411 | } 412 | 413 | #[test] 414 | fn test_list_files_with_nonexistent_folder_glob_relative() -> Result<()> { 415 | // -- Exec 416 | // Mix a glob for a non-existent folder with a valid glob using relative_glob option 417 | let res = list_files( 418 | "./tests-data/", 419 | Some(&["nonexistent-folder/**/*.rs", "./**/*.md"]), 420 | Some(ListOptions::from_relative_glob(true)), 421 | )?; 422 | 423 | // -- Check 424 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 425 | assert_eq!( 426 | res.len(), 427 | 7, 428 | "Should have 7 markdown files despite non-existent folder glob" 429 | ); 430 | assert!(res_paths.contains(&"./tests-data/file1.md"), "Should contain file1.md"); 431 | assert!( 432 | res_paths.contains(&"./tests-data/dir1/file3.md"), 433 | "Should contain dir1/file3.md" 434 | ); 435 | 436 | Ok(()) 437 | } 438 | 439 | #[test] 440 | fn test_list_files_with_combined_exclusion_methods() -> Result<()> { 441 | // -- Exec 442 | // Combine both ListOptions exclude_globs and negative patterns in include_globs 443 | let list_options = ListOptions::default() 444 | .with_exclude_globs(&["**/deep-folder/**"]) // Exclude deep-folder files via ListOptions 445 | .with_relative_glob(); // Use relative glob mode 446 | 447 | let res = list_files( 448 | "./tests-data/", 449 | Some(&[ 450 | "**/*.md", // Include all markdown files 451 | "!**/dir2/**", // Exclude dir2 files via negative pattern 452 | ]), 453 | Some(list_options), 454 | )?; 455 | 456 | // -- Check 457 | let res_paths = res.iter().map(|p| p.as_str()).collect::>(); 458 | assert_eq!(res.len(), 4, "Should have 4 markdown files after combined exclusions"); 459 | 460 | // Files that should be included 461 | assert!(res_paths.contains(&"./tests-data/file1.md"), "Should contain file1.md"); 462 | assert!( 463 | res_paths.contains(&"./tests-data/dir1/file3.md"), 464 | "Should contain dir1/file3.md" 465 | ); 466 | assert!( 467 | res_paths.contains(&"./tests-data/another-dir/notes.md"), 468 | "Should contain another-dir/notes.md" 469 | ); 470 | assert!( 471 | res_paths.contains(&"./tests-data/another-dir/sub-dir/example.md"), 472 | "Should contain another-dir/sub-dir/example.md" 473 | ); 474 | 475 | // Files that should be excluded by negative pattern 476 | assert!( 477 | !res_paths.contains(&"./tests-data/dir1/dir2/file5.md"), 478 | "Should not contain dir1/dir2/file5.md (excluded by negative pattern)" 479 | ); 480 | assert!( 481 | !res_paths.contains(&"./tests-data/dir1/dir2/dir3/file7.md"), 482 | "Should not contain dir1/dir2/dir3/file7.md (excluded by negative pattern)" 483 | ); 484 | 485 | // Files that should be excluded by ListOptions 486 | assert!( 487 | !res_paths.contains(&"./tests-data/another-dir/sub-dir/deep-folder/final.md"), 488 | "Should not contain another-dir/sub-dir/deep-folder/final.md (excluded by ListOptions)" 489 | ); 490 | 491 | Ok(()) 492 | } 493 | 494 | // region: --- Support 495 | 496 | /// Reusable function for checking markdown files in test-data directory 497 | fn assert_md_files_res(res_paths: &[&str]) { 498 | assert_eq!(res_paths.len(), 7, "Should have 7 markdown files in total"); 499 | assert!(res_paths.contains(&"./tests-data/file1.md"), "Should contain file1.md"); 500 | assert!( 501 | res_paths.contains(&"./tests-data/dir1/file3.md"), 502 | "Should contain dir1/file3.md" 503 | ); 504 | assert!( 505 | res_paths.contains(&"./tests-data/dir1/dir2/file5.md"), 506 | "Should contain dir1/dir2/file5.md" 507 | ); 508 | assert!( 509 | res_paths.contains(&"./tests-data/dir1/dir2/dir3/file7.md"), 510 | "Should contain dir1/dir2/dir3/file7.md" 511 | ); 512 | assert!( 513 | res_paths.contains(&"./tests-data/another-dir/notes.md"), 514 | "Should contain another-dir/notes.md" 515 | ); 516 | assert!( 517 | res_paths.contains(&"./tests-data/another-dir/sub-dir/deep-folder/final.md"), 518 | "Should contain another-dir/sub-dir/deep-folder/final.md" 519 | ); 520 | assert!( 521 | res_paths.contains(&"./tests-data/another-dir/sub-dir/example.md"), 522 | "Should contain another-dir/sub-dir/example.md" 523 | ); 524 | } 525 | 526 | // endregion: --- Support 527 | -------------------------------------------------------------------------------- /src/list/globs_file_iter.rs: -------------------------------------------------------------------------------- 1 | use super::glob::{DEFAULT_EXCLUDE_GLOBS, get_glob_set, longest_base_path_wild_free}; 2 | use crate::{ListOptions, Result, SFile, SPath, get_depth}; 3 | use std::collections::HashSet; 4 | use std::path::Path; 5 | use std::sync::Arc; 6 | use walkdir::WalkDir; 7 | 8 | pub struct GlobsFileIter { 9 | inner: Box>, 10 | } 11 | 12 | impl GlobsFileIter { 13 | pub fn new( 14 | dir: impl AsRef, 15 | include_globs: Option<&[&str]>, 16 | list_options: Option>, 17 | ) -> Result { 18 | // main_base for relative globs comes from the directory passed in 19 | let main_base = SPath::from_std_path(dir.as_ref())?; 20 | 21 | // Process include_globs to separate includes and negated excludes (starting with !) 22 | let (include_patterns, negated_excludes) = if let Some(globs) = include_globs { 23 | let mut includes = Vec::new(); 24 | let mut excludes = Vec::new(); 25 | 26 | for &pattern in globs { 27 | if let Some(negative_pattern) = pattern.strip_prefix("!") { 28 | excludes.push(negative_pattern); 29 | } else { 30 | includes.push(pattern); 31 | } 32 | } 33 | 34 | // If all patterns were negated, use a default include pattern 35 | if includes.is_empty() && !excludes.is_empty() { 36 | (vec!["**"], excludes) 37 | } else { 38 | (includes, excludes) 39 | } 40 | } else { 41 | (vec!["**"], Vec::new()) 42 | }; 43 | 44 | // Create or extend the ListOptions with negated_excludes 45 | let list_options = if !negated_excludes.is_empty() { 46 | match list_options { 47 | Some(opts) => { 48 | let mut new_opts = ListOptions { 49 | exclude_globs: opts.exclude_globs.clone(), 50 | relative_glob: opts.relative_glob, 51 | depth: opts.depth, 52 | }; 53 | 54 | if let Some(existing_excludes) = &mut new_opts.exclude_globs { 55 | // Append negated excludes to existing excludes 56 | let mut combined = existing_excludes.clone(); 57 | combined.extend(negated_excludes); 58 | new_opts.exclude_globs = Some(combined); 59 | } else { 60 | // Create new excludes from negated patterns 61 | new_opts.exclude_globs = Some(negated_excludes); 62 | } 63 | 64 | Some(new_opts) 65 | } 66 | None => { 67 | // Create a new ListOptions with just the negated excludes 68 | Some(ListOptions { 69 | exclude_globs: Some(negated_excludes), 70 | relative_glob: false, 71 | depth: None, 72 | }) 73 | } 74 | } 75 | } else { 76 | list_options 77 | }; 78 | 79 | // Process the globs into groups: each group is a (base_dir, Vec) 80 | let groups = process_globs(&main_base, &include_patterns)?; 81 | 82 | // Get the relative_glob setting from list_options 83 | let use_relative_glob = list_options.as_ref().is_some_and(|o| o.relative_glob); 84 | 85 | // Prepare exclude globs applied uniformly on each group 86 | let exclude_globs_raw: Option<&[&str]> = list_options.as_ref().and_then(|o| o.exclude_globs()); 87 | let exclude_globs_set = exclude_globs_raw 88 | .or(Some(DEFAULT_EXCLUDE_GLOBS)) 89 | .map(get_glob_set) 90 | .transpose()?; 91 | 92 | // For each group, create a WalkDir iterator with its own base and globset 93 | let mut group_iterators: Vec>> = Vec::new(); 94 | 95 | let max_depth = list_options.and_then(|o| o.depth); 96 | 97 | let exclude_globs_set = Arc::new(exclude_globs_set); 98 | for GlobGroup { 99 | base: group_base, 100 | patterns, 101 | prefixes, 102 | } in groups.into_iter() 103 | { 104 | // Compute maximum depth among the group's relative glob patterns 105 | let pats: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); 106 | let depth = get_depth(&pats, max_depth); 107 | 108 | // Build the globset for the group from its relative patterns 109 | let globset = get_glob_set(&pats)?; 110 | 111 | let allowed_prefixes = Arc::new(prefixes); 112 | 113 | // Clone group_base for use in closures 114 | let base_clone_for_dirs = group_base.clone(); 115 | let exclude_globs_set_clone = exclude_globs_set.clone(); 116 | let allowed_prefixes_for_dirs = allowed_prefixes.clone(); 117 | let iter = WalkDir::new(group_base.path()) 118 | .max_depth(depth) 119 | .into_iter() 120 | .filter_entry(move |e| { 121 | let Ok(path) = SPath::from_std_path(e.path()) else { 122 | return false; 123 | }; 124 | 125 | // This uses the walkdir file_type which does not make a system call 126 | let is_dir = e.file_type().is_dir(); 127 | 128 | if is_dir { 129 | if let Some(exclude_globs) = exclude_globs_set_clone.as_ref() { 130 | if use_relative_glob { 131 | if let Some(rel_path) = path.diff(&base_clone_for_dirs) 132 | && exclude_globs.is_match(&rel_path) 133 | { 134 | return false; 135 | } 136 | } else if exclude_globs.is_match(&path) { 137 | return false; 138 | } 139 | } 140 | 141 | if !allowed_prefixes_for_dirs.is_empty() 142 | && !directory_matches_allowed_prefixes( 143 | &path, 144 | &base_clone_for_dirs, 145 | allowed_prefixes_for_dirs.as_ref(), 146 | ) { 147 | return false; 148 | } 149 | } 150 | 151 | true 152 | }) 153 | .filter_map(|entry| entry.ok()) 154 | .filter(|entry| entry.file_type().is_file()) 155 | .filter_map(SFile::from_walkdir_entry_ok); 156 | 157 | let exclude_globs_set_clone = exclude_globs_set.clone(); 158 | let main_base_clone = main_base.clone(); 159 | let base_clone = group_base.clone(); 160 | 161 | let iter = iter.filter(move |sfile| { 162 | // First check if the file should be excluded by the exclude_globs 163 | if let Some(exclude) = exclude_globs_set_clone.as_ref() { 164 | // Use appropriate path based on relative_glob setting 165 | if use_relative_glob { 166 | if let Some(rel_path) = sfile.diff(&main_base_clone) 167 | && exclude.is_match(&rel_path) 168 | { 169 | return false; 170 | } 171 | } else if exclude.is_match(sfile) { 172 | return false; 173 | } 174 | } 175 | 176 | // Always compute the relative path based on the group base 177 | let rel_path = match sfile.diff(base_clone.path()) { 178 | Some(p) => p, 179 | None => return false, 180 | }; 181 | 182 | // Accept only those files that match the group's globset 183 | globset.is_match(rel_path) 184 | }); 185 | group_iterators.push(Box::new(iter)); 186 | } 187 | 188 | // Combine all group iterators into one combined iterator 189 | let combined_iter = group_iterators.into_iter().fold( 190 | Box::new(std::iter::empty()) as Box>, 191 | |acc, iter| Box::new(acc.chain(iter)) as Box>, 192 | ); 193 | 194 | // Use scan to keep track of absolute file paths and remove duplicates. 195 | let dedup_iter = combined_iter 196 | .scan(HashSet::::new(), |seen, file| { 197 | let path = file.path().clone(); 198 | if seen.insert(path) { 199 | Some(Some(file)) 200 | } else { 201 | Some(None) 202 | } 203 | }) 204 | .flatten(); 205 | 206 | Ok(GlobsFileIter { 207 | inner: Box::new(dedup_iter), 208 | }) 209 | } 210 | } 211 | 212 | impl Iterator for GlobsFileIter { 213 | type Item = SFile; 214 | fn next(&mut self) -> Option { 215 | self.inner.next() 216 | } 217 | } 218 | 219 | struct GlobGroup { 220 | base: SPath, 221 | patterns: Vec, 222 | prefixes: Vec, 223 | } 224 | 225 | // region: --- Support 226 | 227 | /// Processes the provided globs into groups with collapsed base directories. 228 | /// For relative globs, the pattern is adjusted to be relative to main_base. 229 | /// Groups glob patterns by their longest shared base directory. 230 | /// 231 | /// # Example 232 | /// 233 | /// ```text 234 | /// inputs: main_base="/project", globs=["/project/src/**/*.rs", "*.md"] 235 | /// output: [GlobGroup { base="/project/src", patterns=["**/*.rs"], .. }, GlobGroup { base="/project", patterns=["*.md"], .. }] 236 | /// ``` 237 | fn process_globs(main_base: &SPath, globs: &[&str]) -> Result> { 238 | let mut groups: Vec<(SPath, Vec)> = Vec::new(); 239 | let mut relative_patterns: Vec = Vec::new(); 240 | 241 | for &glob in globs { 242 | let path_glob = SPath::new(glob); 243 | if path_glob.is_absolute() { 244 | let abs_base = longest_base_path_wild_free(&path_glob); 245 | let rel_pattern = relative_from_absolute(&path_glob, &abs_base); 246 | 247 | // Add to groups: if exists with same base, push; else create new. 248 | if let Some((_, patterns)) = groups.iter_mut().find(|(b, _)| b.as_str() == abs_base.as_str()) { 249 | patterns.push(rel_pattern); 250 | } else { 251 | groups.push((abs_base, vec![rel_pattern])); 252 | } 253 | } else { 254 | // Remove any leading "./" from the glob 255 | let cleaned = glob.trim_start_matches("./").to_string(); 256 | // Collapse the relative glob by stripping the main_base prefix if present. 257 | let base_candidate: &str = main_base.as_str(); 258 | let base_str_cleaned = { 259 | let s = base_candidate.trim_start_matches("./"); 260 | if s.is_empty() { 261 | String::new() 262 | } else { 263 | let mut t = s.to_string(); 264 | if !t.ends_with("/") { 265 | t.push('/'); 266 | } 267 | t 268 | } 269 | }; 270 | if !base_str_cleaned.is_empty() && cleaned.starts_with(&base_str_cleaned) { 271 | let relative = cleaned[base_str_cleaned.len()..].to_string(); 272 | relative_patterns.push(relative); 273 | } else { 274 | relative_patterns.push(cleaned); 275 | } 276 | } 277 | } 278 | if !relative_patterns.is_empty() { 279 | groups.push((main_base.clone(), relative_patterns)); 280 | } 281 | 282 | // Merge groups with common base directories. 283 | // Sort groups by base path length (shorter first). 284 | groups.sort_by_key(|(base, _)| base.as_str().len()); 285 | let mut final_groups: Vec = Vec::new(); 286 | for (base, patterns) in groups { 287 | let mut merged = false; 288 | for existing_group in final_groups.iter_mut() { 289 | if existing_group.base.starts_with(&base) { 290 | // 'base' is a subdirectory of 'existing_base' 291 | let diff = base.diff(&existing_group.base).map(|p| p.to_string()).unwrap_or_default(); 292 | for pat in patterns.iter() { 293 | let new_pat = if diff.is_empty() { 294 | pat.to_string() 295 | } else { 296 | SPath::new(&diff).join(pat).to_string() 297 | }; 298 | existing_group.patterns.push(new_pat.clone()); 299 | } 300 | 301 | // Recalculate prefixes for the merged pattern set 302 | let mut new_prefixes = Vec::new(); 303 | let mut full_traversal_needed = false; 304 | for pat in existing_group.patterns.iter() { 305 | let pfx = glob_literal_prefixes(pat); 306 | if pfx.is_empty() { 307 | full_traversal_needed = true; 308 | break; 309 | } 310 | append_adjusted(&mut new_prefixes, &pfx); 311 | } 312 | 313 | if full_traversal_needed { 314 | existing_group.prefixes.clear(); 315 | } else { 316 | normalize_prefixes(&mut new_prefixes); 317 | existing_group.prefixes = new_prefixes; 318 | } 319 | 320 | merged = true; 321 | break; 322 | } else if base.starts_with(&existing_group.base) { 323 | // 'existing_base' is a prefix of 'base'. Keep existing_group.base as it is the broader base. 324 | // Example: existing_group.base = /a, base = /a/b. 325 | 326 | // 1. Calculate path segment from existing_group.base to base (e.g., 'b'). 327 | // This segment is used to adjust incoming patterns (relative to 'base') to be relative to 'existing_group.base'. 328 | let diff_segment = base.diff(&existing_group.base).map(|p| p.to_string()).unwrap_or_default(); 329 | 330 | // 2. Adjust incoming patterns and merge them into existing_group.patterns. 331 | for pat in patterns.iter() { 332 | let new_pat = if diff_segment.is_empty() { 333 | pat.clone() 334 | } else { 335 | SPath::new(&diff_segment).join(pat).to_string() 336 | }; 337 | existing_group.patterns.push(new_pat); 338 | } 339 | 340 | // 3. Recalculate prefixes for the combined pattern set (existing + newly merged patterns). 341 | let mut new_prefixes = Vec::new(); 342 | let mut full_traversal_needed = false; 343 | 344 | for pat in existing_group.patterns.iter() { 345 | let pfx = glob_literal_prefixes(pat); 346 | if pfx.is_empty() { 347 | full_traversal_needed = true; 348 | break; 349 | } 350 | append_adjusted(&mut new_prefixes, &pfx); 351 | } 352 | 353 | // 4. Update prefixes (base remains unchanged). 354 | if full_traversal_needed { 355 | existing_group.prefixes.clear(); 356 | } else { 357 | normalize_prefixes(&mut new_prefixes); 358 | existing_group.prefixes = new_prefixes; 359 | } 360 | 361 | merged = true; 362 | break; 363 | } 364 | } 365 | if !merged { 366 | let mut prefixes = Vec::new(); 367 | let mut full_traversal_needed = false; 368 | 369 | for pat in patterns.iter() { 370 | let pfx = glob_literal_prefixes(pat); 371 | if pfx.is_empty() { 372 | full_traversal_needed = true; 373 | break; 374 | } 375 | append_adjusted(&mut prefixes, &pfx); 376 | } 377 | 378 | if full_traversal_needed { 379 | prefixes.clear(); 380 | } else { 381 | normalize_prefixes(&mut prefixes); 382 | } 383 | 384 | final_groups.push(GlobGroup { 385 | base, 386 | patterns, 387 | prefixes, 388 | }); 389 | } 390 | } 391 | 392 | Ok(final_groups) 393 | } 394 | 395 | /// Given an absolute glob pattern and its computed base, returns the relative glob 396 | /// by removing the base prefix and any leading path separator. 397 | /// Rewrites an absolute glob so it becomes relative to `group_base`. 398 | /// 399 | /// # Example 400 | /// 401 | /// ```text 402 | /// inputs: glob="/root/a/**/*.txt", group_base="/root/a" 403 | /// output: "**/*.txt" 404 | /// ``` 405 | fn relative_from_absolute(glob: &SPath, group_base: &SPath) -> String { 406 | glob.diff(group_base).map(|p| p.to_string()).unwrap_or_else(|| glob.to_string()) 407 | } 408 | 409 | /// Checks whether a directory path aligns with one of the candidate prefixes. 410 | /// 411 | /// # Example 412 | /// 413 | /// ```text 414 | /// inputs: path="/root/a/b", base="/root", prefixes=["a", "docs"] 415 | /// output: true 416 | /// ``` 417 | fn directory_matches_allowed_prefixes(path: &SPath, base: &SPath, prefixes: &[String]) -> bool { 418 | if prefixes.is_empty() { 419 | return true; 420 | } 421 | if path.as_str() == base.as_str() { 422 | return true; 423 | } 424 | 425 | let Some(mut rel_path) = path.diff(base.path()) else { 426 | return true; 427 | }; 428 | 429 | { 430 | let rel_str = rel_path.as_str(); 431 | 432 | if let Some(stripped) = rel_str.strip_prefix("./") { 433 | if stripped.is_empty() { 434 | return true; 435 | } 436 | rel_path = SPath::new(stripped); 437 | } else if rel_str.is_empty() { 438 | return true; 439 | } 440 | } 441 | 442 | prefixes.iter().any(|prefix| { 443 | let prefix = prefix.as_str(); 444 | if prefix.is_empty() { 445 | return true; 446 | } 447 | 448 | let prefix_spath = SPath::new(prefix); 449 | 450 | rel_path.starts_with(&prefix_spath) || prefix_spath.starts_with(&rel_path) 451 | }) 452 | } 453 | 454 | /// Extracts literal directory prefixes from a glob pattern. 455 | /// 456 | /// # Example 457 | /// 458 | /// ```text 459 | /// input: "assets/images/*.png" 460 | /// output: ["assets", "assets/images"] 461 | /// ``` 462 | fn glob_literal_prefixes(pattern: &str) -> Vec { 463 | let clean = pattern.trim_start_matches("./"); 464 | if clean.is_empty() { 465 | return Vec::new(); 466 | } 467 | 468 | let segments: Vec<&str> = clean.split('/').filter(|s| !s.is_empty() && *s != ".").collect(); 469 | 470 | // If there are no segments or only one segment (just a filename), no directory prefixes 471 | if segments.len() <= 1 { 472 | return Vec::new(); 473 | } 474 | 475 | let mut prefixes = vec![String::new()]; 476 | 477 | // Process all segments except the last one (which is the filename/pattern) 478 | for &segment in segments.iter().take(segments.len() - 1) { 479 | if segment == ".." || segment_contains_wildcard(segment) { 480 | break; 481 | } 482 | 483 | let mut next = Vec::new(); 484 | if let Some(options) = expand_brace_segment(segment) { 485 | for prefix in &prefixes { 486 | for option in options.iter() { 487 | let new_prefix = if prefix.is_empty() { 488 | option.clone() 489 | } else { 490 | SPath::new(prefix).join(option).to_string() 491 | }; 492 | next.push(new_prefix); 493 | } 494 | } 495 | } else if segment.contains('{') || segment.contains('}') { 496 | break; 497 | } else { 498 | for prefix in &prefixes { 499 | let new_prefix = if prefix.is_empty() { 500 | segment.to_string() 501 | } else { 502 | SPath::new(prefix).join(segment).to_string() 503 | }; 504 | next.push(new_prefix); 505 | } 506 | } 507 | 508 | if next.is_empty() { 509 | break; 510 | } 511 | 512 | prefixes = next; 513 | } 514 | 515 | // If we only have the empty string, return empty 516 | if prefixes.len() == 1 && prefixes[0].is_empty() { 517 | Vec::new() 518 | } else { 519 | prefixes 520 | } 521 | } 522 | 523 | /// Expands a single `{a,b}` brace segment into concrete options. 524 | /// 525 | /// # Example 526 | /// 527 | /// ```text 528 | /// input: "{foo,bar}" 529 | /// output: Some(["foo", "bar"]) 530 | /// ``` 531 | fn expand_brace_segment(segment: &str) -> Option> { 532 | if segment.starts_with('{') && segment.ends_with('}') { 533 | let inner = &segment[1..segment.len() - 1]; 534 | if inner.contains('{') || inner.contains('}') { 535 | return None; 536 | } 537 | let options: Vec = inner 538 | .split(',') 539 | .map(|s| s.trim()) 540 | .filter(|s| !s.is_empty()) 541 | .map(|s| s.to_string()) 542 | .collect(); 543 | if options.is_empty() { None } else { Some(options) } 544 | } else { 545 | None 546 | } 547 | } 548 | 549 | /// Reports whether the provided segment contains glob wildcards. 550 | /// 551 | /// # Example 552 | /// 553 | /// ```text 554 | /// input: "src*" 555 | /// output: true 556 | /// ``` 557 | fn segment_contains_wildcard(segment: &str) -> bool { 558 | segment.contains('*') || segment.contains('?') || segment.contains('[') 559 | } 560 | 561 | /// Appends cloned prefix values into the running list. 562 | /// 563 | /// # Example 564 | /// 565 | /// ```text 566 | /// inputs: target=["a"], values=["b","c"] 567 | /// result: target=["a","b","c"] 568 | /// ``` 569 | fn append_adjusted(target: &mut Vec, values: &[String]) { 570 | for value in values { 571 | target.push(value.to_string()); 572 | } 573 | } 574 | 575 | /// Normalizes prefix candidates by removing empties and duplicates. 576 | /// 577 | /// # Example 578 | /// 579 | /// ```text 580 | /// input: ["", "a", "a"] 581 | /// output: [] 582 | /// ``` 583 | fn normalize_prefixes(prefixes: &mut Vec) { 584 | if prefixes.is_empty() { 585 | return; 586 | } 587 | if prefixes.iter().any(|p| p.is_empty()) { 588 | prefixes.clear(); 589 | return; 590 | } 591 | prefixes.sort(); 592 | prefixes.dedup(); 593 | } 594 | 595 | // endregion: --- Support 596 | -------------------------------------------------------------------------------- /src/spath.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result, SMeta, reshape}; 2 | use camino::{Utf8Path, Utf8PathBuf}; 3 | use core::fmt; 4 | use pathdiff::diff_utf8_paths; 5 | use std::fs::{self, Metadata}; 6 | use std::path::{Path, PathBuf}; 7 | use 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)] 15 | pub struct SPath { 16 | pub(crate) path_buf: Utf8PathBuf, 17 | } 18 | 19 | /// Constructors that guarantee the SPath contract described in the struct 20 | impl SPath { 21 | /// Constructor for SPath accepting anything that implements Into. 22 | /// IMPORTANT: This will normalize the path (posix style 23 | pub fn new(path: impl Into) -> 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 { 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. 36 | pub fn from_std_path(path: impl AsRef) -> Result { 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 { 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. 50 | /// 51 | /// Returns Option. Useful for filter_map. 52 | pub fn from_std_path_ok(path: impl AsRef) -> Option { 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 { 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 { 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 { 76 | let path_buf = validate_spath_for_option(wd_entry.path())?; 77 | Some(SPath::new(path_buf)) 78 | } 79 | } 80 | 81 | /// Public into path 82 | impl 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 100 | impl 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 187 | impl 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 { 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 { 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 { 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 { 247 | Ok(self.meta()?.modified_epoch_us) 248 | } 249 | } 250 | 251 | /// Transformers 252 | impl SPath { 253 | /// This perform a OS Canonicalization. 254 | pub fn canonicalize(&self) -> Result { 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. 294 | pub fn parent(&self) -> Option { 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) -> 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) -> Result { 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) -> 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) -> Result { 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) -> Option { 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) -> Result { 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, with: impl AsRef) -> 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, with: impl AsRef) -> 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 430 | impl 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) -> Result { 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) -> bool { 473 | self.path_buf.starts_with(base) 474 | } 475 | } 476 | 477 | /// Extensions 478 | impl 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 515 | impl 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 { 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 | 542 | impl 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 | 552 | impl AsRef for SPath { 553 | fn as_ref(&self) -> &SPath { 554 | self 555 | } 556 | } 557 | 558 | impl AsRef for SPath { 559 | fn as_ref(&self) -> &Path { 560 | self.path_buf.as_ref() 561 | } 562 | } 563 | 564 | impl AsRef for SPath { 565 | fn as_ref(&self) -> &Utf8Path { 566 | self.path_buf.as_ref() 567 | } 568 | } 569 | 570 | impl AsRef 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 | 580 | impl From for String { 581 | fn from(val: SPath) -> Self { 582 | val.as_str().to_string() 583 | } 584 | } 585 | 586 | impl From<&SPath> for String { 587 | fn from(val: &SPath) -> Self { 588 | val.as_str().to_string() 589 | } 590 | } 591 | 592 | impl From for PathBuf { 593 | fn from(val: SPath) -> Self { 594 | val.into_std_path_buf() 595 | } 596 | } 597 | 598 | impl From<&SPath> for PathBuf { 599 | fn from(val: &SPath) -> Self { 600 | val.path_buf.clone().into() 601 | } 602 | } 603 | 604 | impl From 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 | 614 | impl From for SPath { 615 | fn from(path_buf: Utf8PathBuf) -> Self { 616 | SPath::new(path_buf) 617 | } 618 | } 619 | 620 | impl From<&Utf8Path> for SPath { 621 | fn from(path: &Utf8Path) -> Self { 622 | SPath::new(path) 623 | } 624 | } 625 | 626 | impl From for SPath { 627 | fn from(path: String) -> Self { 628 | SPath::new(path) 629 | } 630 | } 631 | 632 | impl From<&String> for SPath { 633 | fn from(path: &String) -> Self { 634 | SPath::new(path) 635 | } 636 | } 637 | 638 | impl 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 | 648 | impl TryFrom for SPath { 649 | type Error = Error; 650 | fn try_from(path_buf: PathBuf) -> Result { 651 | SPath::from_std_path_buf(path_buf) 652 | } 653 | } 654 | 655 | impl TryFrom for SPath { 656 | type Error = Error; 657 | fn try_from(fs_entry: fs::DirEntry) -> Result { 658 | SPath::from_std_path_buf(fs_entry.path()) 659 | } 660 | } 661 | 662 | impl TryFrom for SPath { 663 | type Error = Error; 664 | fn try_from(wd_entry: walkdir::DirEntry) -> Result { 665 | SPath::from_std_path(wd_entry.path()) 666 | } 667 | } 668 | 669 | // endregion: --- TryFrom 670 | 671 | // region: --- Path Validation 672 | 673 | pub(crate) fn validate_spath_for_result(path: impl Into) -> Result { 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) 681 | pub(crate) fn validate_spath_for_option(path: impl Into) -> Option { 682 | Utf8PathBuf::from_path_buf(path.into()).ok() 683 | } 684 | 685 | // endregion: --- Path Validation 686 | --------------------------------------------------------------------------------