Skip to main content

simple_fs/list/
sort.rs

1use std::cmp::Ordering;
2
3use globset::{Glob, GlobMatcher};
4
5use crate::{Error, Result, SPath};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct SortByGlobsOptions {
9	pub end_weighted: bool,
10	pub no_match_position: NoMatchPosition,
11}
12
13#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
14pub enum NoMatchPosition {
15	Start,
16	#[default]
17	End,
18}
19
20impl Default for SortByGlobsOptions {
21	fn default() -> Self {
22		Self {
23			end_weighted: false,
24			no_match_position: NoMatchPosition::End,
25		}
26	}
27}
28
29impl From<bool> for SortByGlobsOptions {
30	fn from(end_weighted: bool) -> Self {
31		Self {
32			end_weighted,
33			..Default::default()
34		}
35	}
36}
37
38/// Sort files by glob priority, then by full path.
39///
40/// - Builds a Vec of Glob (no GlobSet).
41/// - The "glob index" used for ordering is chosen as:
42///   - end_weighted = false: first matching glob index (from the beginning).
43///   - end_weighted = true: last matching glob index (from the end).
44/// - Files are ordered by (glob_index, full_path). Non-matches are preserved in their original order.
45pub fn sort_by_globs<T>(items: Vec<T>, globs: &[&str], options: impl Into<SortByGlobsOptions>) -> Result<Vec<T>>
46where
47	T: AsRef<SPath>,
48{
49	let options = options.into();
50
51	// Build individual Glob matchers in order.
52	let mut matchers: Vec<(usize, GlobMatcher)> = Vec::with_capacity(globs.len());
53	for (idx, pat) in globs.iter().enumerate() {
54		let gm = Glob::new(pat).map_err(Error::sort_by_globs)?.compile_matcher();
55		matchers.push((idx, gm));
56	}
57
58	let mut matched = Vec::with_capacity(items.len());
59	let mut unmatched = Vec::with_capacity(items.len());
60
61	for (orig_idx, item) in items.into_iter().enumerate() {
62		let glob_idx = match_index_for_path(item.as_ref(), &matchers, options.end_weighted);
63		if glob_idx == usize::MAX {
64			unmatched.push(item);
65		} else {
66			matched.push((glob_idx, orig_idx, item));
67		}
68	}
69
70	// Sort matched.
71	matched.sort_by(|(ai, a_orig, a_item), (bi, b_orig, b_item)| {
72		match ai.cmp(bi) {
73			Ordering::Equal => {
74				// Tiebreaker: by full path from SPath.
75				let an = a_item.as_ref().as_str();
76				let bn = b_item.as_ref().as_str();
77				match an.cmp(bn) {
78					Ordering::Equal => a_orig.cmp(b_orig),
79					other => other,
80				}
81			}
82			other => other,
83		}
84	});
85
86	let matched: Vec<T> = matched.into_iter().map(|(_, _, item)| item).collect();
87
88	let mut res = Vec::with_capacity(matched.len() + unmatched.len());
89	match options.no_match_position {
90		NoMatchPosition::Start => {
91			res.extend(unmatched);
92			res.extend(matched);
93		}
94		NoMatchPosition::End => {
95			res.extend(matched);
96			res.extend(unmatched);
97		}
98	}
99
100	Ok(res)
101}
102
103// region:    --- Support
104
105#[inline]
106fn match_index_for_path(path: &SPath, matchers: &[(usize, GlobMatcher)], end_weighted: bool) -> usize {
107	if matchers.is_empty() {
108		return usize::MAX;
109	}
110
111	// Normalize the input used for matching: many callers produce paths that start with "./".
112	// Glob patterns typically don't include that leading "./", so strip it for matching purposes.
113	let s = path.as_str();
114	let match_input = s.strip_prefix("./").unwrap_or(s);
115
116	if end_weighted {
117		// Use the last matching glob index (from the end).
118		let mut found: Option<usize> = None;
119		for (idx, gm) in matchers.iter().map(|(i, m)| (*i, m)) {
120			if gm.is_match(match_input) {
121				found = Some(idx);
122			}
123		}
124		found.unwrap_or(usize::MAX)
125	} else {
126		// Use the first matching glob index (from the beginning).
127		for (idx, gm) in matchers.iter().map(|(i, m)| (*i, m)) {
128			if gm.is_match(match_input) {
129				return idx;
130			}
131		}
132		usize::MAX
133	}
134}
135
136// endregion: --- Support
137
138// region:    --- Tests
139
140#[cfg(test)]
141mod tests {
142	type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>; // For tests.
143
144	use super::*;
145	use crate::list_files;
146
147	#[test]
148	fn test_list_sort_sort_files_by_globs_end_weighted_true() -> Result<()> {
149		// -- Setup & Fixtures
150		let globs = ["src/**/*", "src/common/**/*.*", "src/list/sort.rs"];
151		let files = list_files("./", Some(&globs), None)?;
152
153		// -- Exec
154		let files = sort_by_globs(files, &globs, true)?;
155
156		// -- Check
157		let file_names = files.into_iter().map(|v| v.to_string()).collect::<Vec<_>>();
158		let last_file = file_names.last().ok_or("Should have a least one")?;
159
160		assert_eq!(last_file, "./src/list/sort.rs");
161
162		Ok(())
163	}
164}
165
166// endregion: --- Tests