Skip to main content

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}