tokmd_module_key/lib.rs
1//! Single-responsibility module-key derivation for deterministic grouping.
2
3/// Compute a module key from an input path.
4///
5/// Rules:
6/// - Root-level files become `"(root)"`.
7/// - If the first directory segment is in `module_roots`, include up to
8/// `module_depth` directory segments.
9/// - Otherwise, the module key is the first directory segment.
10///
11/// # Examples
12///
13/// ```
14/// use tokmd_module_key::module_key;
15///
16/// // Root-level files map to "(root)"
17/// assert_eq!(module_key("Cargo.toml", &[], 2), "(root)");
18///
19/// // Files under a module root include deeper segments
20/// let roots = vec!["crates".into()];
21/// assert_eq!(module_key("crates/foo/src/lib.rs", &roots, 2), "crates/foo");
22///
23/// // Non-root directories use only the first segment
24/// assert_eq!(module_key("src/lib.rs", &roots, 2), "src");
25/// ```
26///
27/// Windows-style paths and empty roots:
28///
29/// ```
30/// use tokmd_module_key::module_key;
31///
32/// let roots = vec!["crates".into()];
33///
34/// // Backslash paths are normalized before key computation
35/// assert_eq!(module_key("crates\\foo\\src\\lib.rs", &roots, 2), "crates/foo");
36///
37/// // With no module roots every path uses the first directory segment
38/// assert_eq!(module_key("crates/foo/src/lib.rs", &[], 2), "crates");
39/// ```
40#[must_use]
41pub fn module_key(path: &str, module_roots: &[String], module_depth: usize) -> String {
42 let mut p = path.replace('\\', "/");
43 if let Some(stripped) = p.strip_prefix("./") {
44 p = stripped.to_string();
45 }
46 p = p.trim_start_matches('/').to_string();
47
48 module_key_from_normalized(&p, module_roots, module_depth)
49}
50
51/// Compute a module key from a normalized path.
52///
53/// Expected input format:
54/// - forward slashes only
55/// - no leading `./`
56/// - no leading `/`
57///
58/// # Examples
59///
60/// ```
61/// use tokmd_module_key::module_key_from_normalized;
62///
63/// let roots = vec!["crates".into()];
64/// assert_eq!(
65/// module_key_from_normalized("crates/foo/src/lib.rs", &roots, 2),
66/// "crates/foo"
67/// );
68///
69/// // Root-level files return "(root)"
70/// assert_eq!(
71/// module_key_from_normalized("README.md", &roots, 2),
72/// "(root)"
73/// );
74/// ```
75///
76/// Depth overflow and non-root directories:
77///
78/// ```
79/// use tokmd_module_key::module_key_from_normalized;
80///
81/// let roots = vec!["crates".into()];
82///
83/// // Non-root directories always map to the first segment
84/// assert_eq!(module_key_from_normalized("src/main.rs", &roots, 2), "src");
85///
86/// // A depth larger than available segments uses all of them
87/// assert_eq!(
88/// module_key_from_normalized("crates/foo/bar/baz.rs", &roots, 10),
89/// "crates/foo/bar"
90/// );
91/// ```
92#[must_use]
93pub fn module_key_from_normalized(
94 path: &str,
95 module_roots: &[String],
96 module_depth: usize,
97) -> String {
98 let Some((dir_part, _file_part)) = path.rsplit_once('/') else {
99 return "(root)".to_string();
100 };
101
102 let mut dirs = dir_part.split('/').filter(|s| !s.is_empty() && *s != ".");
103 let first = match dirs.next() {
104 Some(s) => s,
105 None => return "(root)".to_string(),
106 };
107
108 if !module_roots.iter().any(|r| r == first) {
109 return first.to_string();
110 }
111
112 let depth_needed = module_depth.max(1);
113 let mut key = String::with_capacity(dir_part.len());
114 key.push_str(first);
115
116 for _ in 1..depth_needed {
117 if let Some(seg) = dirs.next() {
118 key.push('/');
119 key.push_str(seg);
120 } else {
121 break;
122 }
123 }
124
125 key
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn module_key_root_level_file() {
134 assert_eq!(module_key("Cargo.toml", &["crates".into()], 2), "(root)");
135 assert_eq!(module_key("./Cargo.toml", &["crates".into()], 2), "(root)");
136 }
137
138 #[test]
139 fn module_key_respects_root_and_depth() {
140 let roots = vec!["crates".into(), "packages".into()];
141 assert_eq!(module_key("crates/foo/src/lib.rs", &roots, 2), "crates/foo");
142 assert_eq!(
143 module_key("packages/bar/src/main.rs", &roots, 2),
144 "packages/bar"
145 );
146 assert_eq!(module_key("crates/foo/src/lib.rs", &roots, 1), "crates");
147 }
148
149 #[test]
150 fn module_key_for_non_root_is_first_directory() {
151 let roots = vec!["crates".into()];
152 assert_eq!(module_key("src/lib.rs", &roots, 2), "src");
153 assert_eq!(module_key("tools/gen.rs", &roots, 2), "tools");
154 }
155
156 #[test]
157 fn module_key_depth_overflow_does_not_include_filename() {
158 let roots = vec!["crates".into()];
159 assert_eq!(module_key("crates/foo.rs", &roots, 2), "crates");
160 assert_eq!(
161 module_key("crates/foo/src/lib.rs", &roots, 10),
162 "crates/foo/src"
163 );
164 }
165
166 #[test]
167 fn module_key_from_normalized_handles_empty_segments() {
168 let roots = vec!["crates".into()];
169 assert_eq!(
170 module_key_from_normalized("crates//foo/src/lib.rs", &roots, 2),
171 "crates/foo"
172 );
173 }
174
175 #[test]
176 fn module_key_from_normalized_ignores_dot_segments() {
177 let roots = vec!["crates".into()];
178 assert_eq!(
179 module_key_from_normalized("crates/./foo/src/lib.rs", &roots, 2),
180 "crates/foo"
181 );
182 }
183
184 #[test]
185 fn module_key_dot_only_dir_becomes_root() {
186 let roots = vec!["crates".into()];
187 assert_eq!(module_key_from_normalized("./lib.rs", &roots, 2), "(root)");
188 }
189}