Skip to main content

cpd_core/
paths.rs

1// paths.rs — shared source-id path helpers.
2//
3// Source ids may carry a `:format` suffix (multi-format files, e.g.
4// `README.md:javascript`) and, since scan-root relativization, fragments may
5// store their scan root separately in `Fragment.source_root`. These helpers
6// are the single source of truth for turning a fragment back into a real
7// filesystem path; cpd-finder (blame) and cpd-reporter both rely on them.
8
9use crate::models::Fragment;
10
11/// Strip a `:format` suffix from a source id so it can be used as a real path.
12///
13/// Format-qualified IDs look like `README.md:javascript`. A bare colon inside
14/// a Windows drive letter (`C:\…`) or after a path separator is NOT a format
15/// suffix — only a colon preceded by a non-separator, non-colon char with a
16/// valid format name after it qualifies.
17pub fn clean_source_id(source_id: &str) -> &str {
18    match source_id.rfind(':') {
19        Some(pos) if pos > 0 => {
20            let bytes = source_id.as_bytes();
21            let before = bytes[pos - 1];
22            // A colon right after a single drive letter (e.g. `C:`) or after
23            // a path separator (`/`, `\`) is structural, not a format suffix.
24            if pos == 1 && before.is_ascii_alphabetic() {
25                return source_id;
26            }
27            if before == b'/' || before == b'\\' {
28                return source_id;
29            }
30            // A colon followed by a path separator is a drive-letter colon in
31            // a non-prefix position — Windows verbatim paths (`\\?\C:\...`)
32            // put it at position 5. Format suffixes are names like
33            // `:javascript`, never followed by a separator.
34            if matches!(bytes.get(pos + 1), Some(b'/') | Some(b'\\')) {
35                return source_id;
36            }
37            &source_id[..pos]
38        }
39        _ => source_id,
40    }
41}
42
43/// Resolve a fragment's filesystem path by joining `source_root` (if set) with
44/// the cleaned `source_id`. Falls back to the bare `source_id` when no root is
45/// stored (absolute paths, `--absolute` mode, or legacy data).
46pub fn resolve_fragment_path(fragment: &Fragment) -> String {
47    let clean = clean_source_id(&fragment.source_id);
48    match &fragment.source_root {
49        Some(root) => {
50            let joined = std::path::Path::new(root).join(clean);
51            joined.to_string_lossy().into_owned()
52        }
53        None => clean.to_string(),
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::models::Location;
61
62    fn frag(source_id: &str, source_root: Option<&str>) -> Fragment {
63        let loc = Location {
64            line: 1,
65            column: 0,
66            offset: 0,
67        };
68        Fragment {
69            source_id: source_id.to_string(),
70            source_root: source_root.map(str::to_string),
71            start: loc.clone(),
72            end: loc,
73            range: [0, 0],
74            blame: None,
75        }
76    }
77
78    #[test]
79    fn clean_source_id_strips_format_suffix() {
80        assert_eq!(clean_source_id("README.md:javascript"), "README.md");
81    }
82
83    #[test]
84    fn clean_source_id_preserves_bare_path() {
85        assert_eq!(clean_source_id("src/a.js"), "src/a.js");
86    }
87
88    #[test]
89    fn clean_source_id_preserves_windows_drive() {
90        assert_eq!(clean_source_id(r"C:\scan\a.rs"), r"C:\scan\a.rs");
91    }
92
93    #[test]
94    fn clean_source_id_strips_format_from_windows_path() {
95        assert_eq!(clean_source_id(r"C:\scan\a.md:javascript"), r"C:\scan\a.md");
96    }
97
98    #[test]
99    fn clean_source_id_preserves_windows_verbatim_path() {
100        // std::fs::canonicalize on Windows returns verbatim paths whose drive
101        // colon sits at position 5; it must not be mistaken for a format
102        // suffix (which truncated the id to `\\?\C` and broke every snippet
103        // read behind --baseline-from-ref).
104        assert_eq!(clean_source_id(r"\\?\C:\scan\a.rs"), r"\\?\C:\scan\a.rs");
105    }
106
107    #[test]
108    fn clean_source_id_strips_format_from_windows_verbatim_path() {
109        assert_eq!(
110            clean_source_id(r"\\?\C:\scan\a.md:javascript"),
111            r"\\?\C:\scan\a.md"
112        );
113    }
114
115    #[test]
116    fn resolve_fragment_path_joins_root() {
117        // Build the expectation via Path::join too: on Windows the joined
118        // separator is a backslash, so a hardcoded "/project/src/a.js" fails.
119        let expected = std::path::Path::new("/project")
120            .join("src/a.js")
121            .to_string_lossy()
122            .into_owned();
123        assert_eq!(
124            resolve_fragment_path(&frag("src/a.js", Some("/project"))),
125            expected
126        );
127    }
128
129    #[test]
130    fn resolve_fragment_path_falls_back_to_source_id() {
131        assert_eq!(
132            resolve_fragment_path(&frag("/absolute/path/a.js", None)),
133            "/absolute/path/a.js"
134        );
135    }
136
137    #[test]
138    fn resolve_fragment_path_cleans_format_suffix() {
139        let expected = std::path::Path::new("/repo")
140            .join("doc.md")
141            .to_string_lossy()
142            .into_owned();
143        assert_eq!(
144            resolve_fragment_path(&frag("doc.md:javascript", Some("/repo"))),
145            expected
146        );
147    }
148}