Skip to main content

bynk_project/
paths.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use crate::discovery::read_source;
6use crate::json::json_string;
7
8/// v0.17 [DECISION L] stub: a version range is *unpinned* — and rejected — when
9/// it is empty, `*`/`x`/`latest`, or otherwise carries no concrete version
10/// number. A pinned range names at least one digit (`^5`, `~1.2`, `1.2.3`,
11/// `>=1.0 <2`). No allow-list or registry check yet.
12pub fn is_unpinned_range(range: &str) -> bool {
13    let r = range.trim();
14    if r.is_empty() || r == "*" || r.eq_ignore_ascii_case("x") || r.eq_ignore_ascii_case("latest") {
15        return true;
16    }
17    !r.chars().any(|c| c.is_ascii_digit())
18}
19
20/// Render a minimal `package.json` carrying the adapter-declared dependencies.
21pub fn render_package_json(deps: &std::collections::BTreeMap<String, String>) -> String {
22    let mut out = String::from("{\n  \"dependencies\": {\n");
23    let entries: Vec<String> = deps
24        .iter()
25        .map(|(pkg, range)| format!("    {}: {}", json_string(pkg), json_string(range)))
26        .collect();
27    out.push_str(&entries.join(",\n"));
28    out.push_str("\n  }\n}\n");
29    out
30}
31
32/// Normalise a relative path by resolving `.` and `..` components, so a binding
33/// clause like `./tokens.binding.ts` beside `src/tokens.bynk` yields the output
34/// path `tokens.binding.ts`.
35pub fn normalize_rel(p: &Path) -> PathBuf {
36    let mut out: Vec<std::ffi::OsString> = Vec::new();
37    for c in p.components() {
38        match c {
39            Component::CurDir => {}
40            Component::ParentDir => {
41                out.pop();
42            }
43            Component::Normal(s) => out.push(s.to_os_string()),
44            Component::RootDir | Component::Prefix(_) => {}
45        }
46    }
47    out.iter().collect()
48}
49
50/// v0.113 (DECISION S): the project's source tree, read from `bynk.toml`'s
51/// `[paths]` section. Test-ness is a property of the `suite` declaration, not of
52/// a directory, so the layout is a flat **`include`** list of trees to compile
53/// and an **`exclude`** list of subtrees to skip — not the role-named
54/// `src`/`tests` split. Each `include` entry is a root walked for `.bynk` files;
55/// a file's identity path is relative to the `include` root that contains it.
56#[derive(Debug, Clone)]
57pub struct ProjectPaths {
58    /// Trees to compile, relative to the project root. Defaults to the
59    /// conventional roots that exist (`src`, and `tests` when present), else the
60    /// project root itself.
61    pub include: Vec<PathBuf>,
62    /// Subtrees to skip during discovery (monorepo, vendored, or generated
63    /// `.bynk`), relative to the project root.
64    pub exclude: Vec<PathBuf>,
65}
66
67impl ProjectPaths {
68    /// The default layout when `bynk.toml` declares no `[paths] include`: the
69    /// conventional `src`/`tests` roots that exist under `project_root`, or the
70    /// project root itself when neither does. This keeps a conventional
71    /// `src/`(+`tests/`) project working with no config, and lets a flat project
72    /// (`.bynk` at the root, no `src/`) compile with no config either.
73    pub fn conventional(project_root: &Path) -> Self {
74        let mut include = Vec::new();
75        for role in ["src", "tests"] {
76            if project_root.join(role).is_dir() {
77                include.push(PathBuf::from(role));
78            }
79        }
80        if include.is_empty() {
81            include.push(PathBuf::from("."));
82        }
83        ProjectPaths {
84            include,
85            exclude: Vec::new(),
86        }
87    }
88}
89
90/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
91/// itself — the in-memory test seam's (#57) one remaining disk read outside
92/// `discovery::read_source`, now routed through the same helper so a test
93/// can supply a virtual `bynk.toml` with no on-disk file at all. `#[cfg(test)]`
94/// because that's its only consumer today; drop the gate if a non-test caller
95/// needs it (`try_read_project_paths_with`, which this wraps, has none of that
96/// restriction — production code already reaches it through the always-on
97/// `try_read_project_paths`).
98#[cfg(test)]
99pub(crate) fn read_project_paths_with(
100    project_root: &Path,
101    overlay: &HashMap<PathBuf, String>,
102) -> ProjectPaths {
103    try_read_project_paths_with(project_root, overlay)
104        .unwrap_or_else(|_| ProjectPaths::conventional(project_root))
105}
106
107/// A problem in `bynk.toml` that [`try_read_project_paths`] surfaces instead
108/// of silently falling back to the conventional layout.
109#[derive(Debug)]
110pub enum ProjectPathsError {
111    /// `bynk.toml` exists but does not parse as TOML (e.g. a trailing comma).
112    Malformed,
113    /// `[paths]` has a key other than `include`/`exclude` — most likely a typo
114    /// (`inculde`) that was silently read as "no include list".
115    UnknownKey(String),
116}
117
118impl std::fmt::Display for ProjectPathsError {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
122            ProjectPathsError::UnknownKey(k) => {
123                write!(
124                    f,
125                    "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
126                )
127            }
128        }
129    }
130}
131
132/// Read `bynk.toml`'s `[paths]` section, surfacing a malformed manifest — a
133/// parse failure or an unrecognised `[paths]` key — as an error instead of
134/// silently falling back to the conventional layout (the previous
135/// `read_project_paths` total form's behaviour, R3.8 — deleted in favour of
136/// this at all 18 of its callers, #1113).
137///
138/// R3.9 (#1113): `[paths] include` is no longer capped at one or two trees —
139/// [`crate::roots::Roots::trees`] walks every entry, so this no longer rejects
140/// a longer list.
141pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
142    let toml_path = project_root.join("bynk.toml");
143    let overlay = match fs::read_to_string(&toml_path) {
144        Ok(text) => HashMap::from([(toml_path, text)]),
145        Err(_) => HashMap::new(),
146    };
147    try_read_project_paths_with(project_root, &overlay)
148}
149
150/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
151/// itself, the same way `discovery::read_source` does for every other file.
152pub fn try_read_project_paths_with(
153    project_root: &Path,
154    overlay: &HashMap<PathBuf, String>,
155) -> Result<ProjectPaths, ProjectPathsError> {
156    let toml_path = project_root.join("bynk.toml");
157    let Ok(content) = read_source(&toml_path, overlay) else {
158        return Ok(ProjectPaths::conventional(project_root));
159    };
160    let Ok(doc) = content.parse::<toml::Table>() else {
161        return Err(ProjectPathsError::Malformed);
162    };
163    let paths = doc.get("paths").and_then(|v| v.as_table());
164    if let Some(t) = paths {
165        for k in t.keys() {
166            if k != "include" && k != "exclude" {
167                return Err(ProjectPathsError::UnknownKey(k.clone()));
168            }
169        }
170    }
171    let list = |key: &str| -> Vec<PathBuf> {
172        match paths.and_then(|t| t.get(key)) {
173            Some(toml::Value::Array(items)) => items
174                .iter()
175                .filter_map(|v| v.as_str())
176                .map(PathBuf::from)
177                .collect(),
178            Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
179            _ => Vec::new(),
180        }
181    };
182    let mut include = list("include");
183    let exclude = list("exclude");
184    if include.is_empty() {
185        include = ProjectPaths::conventional(project_root).include;
186    }
187    Ok(ProjectPaths { include, exclude })
188}
189
190pub fn commons_dir_for(name: &str) -> PathBuf {
191    let parts: Vec<&str> = name.split('.').collect();
192    let mut p = PathBuf::new();
193    for part in parts {
194        p.push(part);
195    }
196    p
197}
198
199pub fn ts_output_path(source: &Path) -> PathBuf {
200    let mut out = source.to_path_buf();
201    out.set_extension("ts");
202    out
203}
204
205/// v0.8: directory name of a Worker for a given context, with dots replaced
206/// by dashes (`commerce.payment` → `commerce-payment`).
207pub fn worker_dir_name(context: &str) -> String {
208    context.replace('.', "-")
209}
210
211/// v0.8: project-relative synthetic source path of the workers-mode
212/// handlers file for a given context. Used so the emitter's relative-import
213/// machinery resolves correctly against the workers layout.
214pub fn worker_handlers_source_path(context: &str) -> PathBuf {
215    PathBuf::from(format!(
216        "workers/{}/handlers.bynk",
217        worker_dir_name(context)
218    ))
219}
220
221/// v0.8: project-relative output path of the workers-mode handlers file.
222pub fn worker_handlers_output_path(context: &str) -> PathBuf {
223    PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
224}
225
226/// The src-stripped stem components of a path (`learner/uln.bynk` → `["learner",
227/// "uln"]`), dropping the extension and any non-`Normal` components.
228fn stem_parts(rel_path: &Path) -> Vec<String> {
229    rel_path
230        .with_extension("")
231        .components()
232        .filter_map(|c| match c {
233            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
234            _ => None,
235        })
236        .collect()
237}
238
239/// v0.9.1: shared between source-unit and test-unit path validation. The
240/// caller decides which root to strip from the file path before calling.
241///
242/// A file belongs to `qualified_name` when it is either the single file
243/// `<name>.bynk` (`single_file_match`: stem parts == name parts) or one file of
244/// the directory layout `<name>/*.bynk` (`multi_file_match`: parent-dir parts ==
245/// name parts). These two branches are the single source of truth the v0.132
246/// barrel trigger reads via [`is_multi_file_layout`].
247pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
248    let name_parts: Vec<&str> = qualified_name.split('.').collect();
249    let stem_parts = stem_parts(rel_path);
250    let single_file_match = stem_parts.len() == name_parts.len()
251        && stem_parts
252            .iter()
253            .zip(name_parts.iter())
254            .all(|(a, b)| a == b);
255    single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
256}
257
258/// True when `stem_parts` is one file of the `<name>/*.bynk` directory layout —
259/// the file's parent-directory parts equal the name parts.
260fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
261    if stem_parts.is_empty() {
262        return false;
263    }
264    let parent_parts = &stem_parts[..stem_parts.len() - 1];
265    parent_parts.len() == name_parts.len()
266        && parent_parts
267            .iter()
268            .zip(name_parts.iter())
269            .all(|(a, b)| a == b)
270}
271
272/// v0.132: does `rel_path` (src-stripped) place `qualified_name` under a
273/// directory of that name — the `multi_file_match` branch of
274/// [`unit_path_matches`]?
275///
276/// This is the layout where production emits `out/<name>/*.ts` per file and no
277/// aggregate `out/<name>.ts`, so the test path's `import * as ns from
278/// "./<name>.js"` dangles and needs an aggregating barrel. A single-file commons
279/// (`<name>.bynk`) already owns `out/<name>.ts` and returns false, so a barrel
280/// keyed on this predicate can never collide with it.
281pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
282    let name_parts: Vec<&str> = qualified_name.split('.').collect();
283    is_multi_file_parts(&stem_parts(rel_path), &name_parts)
284}
285
286/// #302: the qualified name a file moved from `old_rel` to `new_rel` should now
287/// declare, preserving whichever [`unit_path_matches`] arrangement `old_rel`
288/// used to satisfy against `old_name` — the dotted stem for a single-file
289/// unit, or the dotted parent-directory for one file of a multi-file unit.
290/// Returns `None` if `old_rel`/`old_name` don't actually satisfy either
291/// arrangement (a pre-existing inconsistency the caller should not guess at).
292///
293/// `old_name` is matched as a **suffix** of `old_rel`'s stem/parent, not the
294/// whole thing: the LSP's caller passes project-relative paths, which (unlike
295/// the `source_path` `unit_path_matches` itself is checked against) still
296/// carry a leading `include`-root segment (e.g. `src/`) that the qualified
297/// name never mentions. Whatever prefix length that suffix match implies for
298/// `old_rel` is applied unchanged to `new_rel` — correct as long as the file
299/// stays under the same `include` root, which a rename/move normally does.
300pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
301    let name_parts: Vec<&str> = old_name.split('.').collect();
302    let old_stem = stem_parts(old_rel);
303    let new_stem = stem_parts(new_rel);
304
305    let suffix_matches = |haystack: &[String]| {
306        haystack.len() >= name_parts.len() && {
307            let prefix_len = haystack.len() - name_parts.len();
308            haystack[prefix_len..]
309                .iter()
310                .zip(name_parts.iter())
311                .all(|(a, b)| a == b)
312        }
313    };
314
315    if suffix_matches(&old_stem) {
316        let prefix_len = old_stem.len() - name_parts.len();
317        return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
318    }
319    if !old_stem.is_empty() {
320        let old_parent = &old_stem[..old_stem.len() - 1];
321        if suffix_matches(old_parent) {
322            let prefix_len = old_parent.len() - name_parts.len();
323            if new_stem.is_empty() {
324                return None;
325            }
326            let new_parent = &new_stem[..new_stem.len() - 1];
327            return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
328        }
329    }
330    None
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use std::path::{Path, PathBuf};
337
338    // -- is_unpinned_range ----------------------------------------------------
339    #[test]
340    fn is_unpinned_range_true_for_wildcards_and_digitless() {
341        assert!(is_unpinned_range(""));
342        assert!(is_unpinned_range("*"));
343        assert!(is_unpinned_range("x"));
344        assert!(is_unpinned_range("X"));
345        assert!(is_unpinned_range("latest"));
346        assert!(is_unpinned_range("LATEST"));
347        assert!(is_unpinned_range("  *  ")); // trimmed before the checks
348        assert!(is_unpinned_range("workspace:*")); // no ascii digit
349        assert!(is_unpinned_range("beta"));
350    }
351
352    #[test]
353    fn is_unpinned_range_false_when_a_digit_is_present() {
354        assert!(!is_unpinned_range("1.0.0"));
355        assert!(!is_unpinned_range("^1.2"));
356        assert!(!is_unpinned_range("~0.1"));
357        assert!(!is_unpinned_range(">=2"));
358        assert!(!is_unpinned_range("18"));
359    }
360
361    // -- read_project_paths_with (#57, in-memory test seam) -------------------
362    #[test]
363    fn read_project_paths_with_honours_a_virtual_bynk_toml() {
364        let root = PathBuf::from("/nonexistent-bynk-test-root-57");
365        let mut overlay = HashMap::new();
366        overlay.insert(
367            root.join("bynk.toml"),
368            "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
369        );
370        let paths = read_project_paths_with(&root, &overlay);
371        assert_eq!(paths.include, vec![PathBuf::from("app")]);
372        assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
373    }
374
375    #[test]
376    fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
377        // No overlay entry and no real file at this (nonexistent) root — same
378        // fallback a missing on-disk `bynk.toml` gives.
379        let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
380        let paths = read_project_paths_with(&root, &HashMap::new());
381        let conventional = ProjectPaths::conventional(&root);
382        assert_eq!(paths.include, conventional.include);
383        assert_eq!(paths.exclude, conventional.exclude);
384    }
385
386    /// R3.9 (#1113): three or more `[paths] include` entries all round-trip —
387    /// `try_read_project_paths_with` no longer caps the list at two.
388    #[test]
389    fn read_project_paths_with_honours_three_or_more_include_entries() {
390        let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
391        let mut overlay = HashMap::new();
392        overlay.insert(
393            root.join("bynk.toml"),
394            "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
395        );
396        let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
397        assert_eq!(
398            paths.include,
399            vec![
400                PathBuf::from("src"),
401                PathBuf::from("tests"),
402                PathBuf::from("examples"),
403            ]
404        );
405    }
406
407    // -- render_package_json --------------------------------------------------
408    #[test]
409    fn render_package_json_renders_sorted_dependencies() {
410        let mut deps = std::collections::BTreeMap::new();
411        deps.insert("zod".to_string(), "^3.22.4".to_string());
412        deps.insert("hono".to_string(), "^4.0.0".to_string());
413        let out = render_package_json(&deps);
414        // BTreeMap ordering keeps the file byte-stable across builds.
415        assert!(
416            out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
417            "dependencies render in sorted order:\n{out}"
418        );
419        assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
420    }
421
422    /// A package name and version range reach here from adapter declarations in
423    /// Bynk source, so they are arbitrary text. This module used to escape only
424    /// `"` and `\`, which let a control character through as a literal — and a
425    /// literal control character inside a JSON string is a parse error, so the
426    /// emitted `package.json` was invalid rather than merely odd.
427    #[test]
428    fn render_package_json_escapes_the_control_range() {
429        let mut deps = std::collections::BTreeMap::new();
430        deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
431        let out = render_package_json(&deps);
432        assert!(out.contains("\"pkg\\nname\""), "{out}");
433        assert!(out.contains("\"^1.0\\u00010\""), "{out}");
434        // No raw control character survives into the rendered document (the
435        // pretty-printer's own newlines are all that remain).
436        assert!(
437            !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
438            "a raw control character reached the output:\n{out:?}"
439        );
440    }
441
442    #[test]
443    fn render_package_json_escapes_structural_characters() {
444        let mut deps = std::collections::BTreeMap::new();
445        deps.insert("a\"b".to_string(), "c\\d".to_string());
446        let out = render_package_json(&deps);
447        assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
448    }
449
450    // -- normalize_rel --------------------------------------------------------
451    #[test]
452    fn normalize_rel_resolves_dot_and_parent() {
453        assert_eq!(
454            normalize_rel(Path::new("./tokens.binding.ts")),
455            PathBuf::from("tokens.binding.ts")
456        );
457        assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
458        assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
459        assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
460        assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
461    }
462
463    #[test]
464    fn normalize_rel_drops_root_and_pops_through_empty() {
465        // RootDir / Prefix components are dropped.
466        assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
467        // A leading `..` pops an empty stack (a no-op), so it vanishes.
468        assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
469    }
470
471    // -- commons_dir_for / ts_output_path -------------------------------------
472    #[test]
473    fn commons_dir_for_splits_dotted_name_into_dirs() {
474        assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
475        assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
476    }
477
478    #[test]
479    fn ts_output_path_sets_ts_extension() {
480        assert_eq!(
481            ts_output_path(Path::new("foo.bynk")),
482            PathBuf::from("foo.ts")
483        );
484        assert_eq!(
485            ts_output_path(Path::new("a/b.bynk")),
486            PathBuf::from("a/b.ts")
487        );
488        assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
489    }
490
491    // -- worker path helpers --------------------------------------------------
492    #[test]
493    fn worker_paths_dasherise_and_root_under_workers() {
494        assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
495        assert_eq!(worker_dir_name("plain"), "plain");
496        assert_eq!(
497            worker_handlers_source_path("commerce.payment"),
498            PathBuf::from("workers/commerce-payment/handlers.bynk")
499        );
500        assert_eq!(
501            worker_handlers_output_path("commerce.payment"),
502            PathBuf::from("workers/commerce-payment/handlers.ts")
503        );
504    }
505
506    // -- unit_path_matches ----------------------------------------------------
507    #[test]
508    fn unit_path_matches_single_file_layout() {
509        assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
510        assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
511    }
512
513    #[test]
514    fn unit_path_matches_multi_file_layout() {
515        // `a/b/c/<any>.bynk` declaring `a.b.c` (the directory is the unit).
516        assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
517        assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
518    }
519
520    #[test]
521    fn unit_path_matches_rejects_misalignment() {
522        assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
523        assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
524    }
525
526    // -- is_multi_file_layout (v0.132 barrel trigger) -------------------------
527    #[test]
528    fn is_multi_file_layout_true_only_for_directory_layout() {
529        // Directory layout: `<name>/*.bynk` — the branch with no `out/<name>.ts`.
530        assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
531        assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
532        // Dotted commons split across `src/a/b/*.bynk`.
533        assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
534    }
535
536    #[test]
537    fn is_multi_file_layout_false_for_single_file_and_misalignment() {
538        // Single file `<name>.bynk` already owns `out/<name>.ts` — no barrel.
539        assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
540        // Dotted single file `a/b.bynk` for `a.b` — the file *is* `out/a/b.ts`.
541        assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
542        // Wrong directory — not this unit's file.
543        assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
544    }
545
546    // -- renamed_unit_name (#302) ----------------------------------------------
547    #[test]
548    fn renamed_unit_name_single_file() {
549        assert_eq!(
550            renamed_unit_name(
551                Path::new("a/b/c.bynk"),
552                "a.b.c",
553                Path::new("a/b/renamed.bynk")
554            ),
555            Some("a.b.renamed".to_string())
556        );
557        assert_eq!(
558            renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
559            Some("bar".to_string())
560        );
561    }
562
563    #[test]
564    fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
565        // Renaming one member file within the same directory doesn't change
566        // the unit's name — the qualified name is the directory, not the
567        // filename.
568        assert_eq!(
569            renamed_unit_name(
570                Path::new("a/b/c/old.bynk"),
571                "a.b.c",
572                Path::new("a/b/c/new.bynk")
573            ),
574            Some("a.b.c".to_string())
575        );
576    }
577
578    #[test]
579    fn renamed_unit_name_multi_file_directory_move() {
580        assert_eq!(
581            renamed_unit_name(
582                Path::new("a/b/c/handlers.bynk"),
583                "a.b.c",
584                Path::new("a/b/renamed/handlers.bynk")
585            ),
586            Some("a.b.renamed".to_string())
587        );
588    }
589
590    #[test]
591    fn renamed_unit_name_none_on_preexisting_misalignment() {
592        assert_eq!(
593            renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
594            None
595        );
596    }
597
598    #[test]
599    fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
600        // The LSP passes project-relative paths (ADR 0198), which still carry
601        // a split project's `src`/`tests` root segment — `unit_path_matches`
602        // itself is only ever checked against the root-stripped `source_path`.
603        // `old_name` must match as a *suffix*, and the same leading-segment
604        // count is preserved onto `new_rel`.
605        assert_eq!(
606            renamed_unit_name(
607                Path::new("src/billing/charge.bynk"),
608                "billing.charge",
609                Path::new("src/billing/pay.bynk")
610            ),
611            Some("billing.pay".to_string())
612        );
613        // Multi-file arrangement under the same prefix.
614        assert_eq!(
615            renamed_unit_name(
616                Path::new("src/a/b/c/handlers.bynk"),
617                "a.b.c",
618                Path::new("src/a/b/renamed/handlers.bynk")
619            ),
620            Some("a.b.renamed".to_string())
621        );
622    }
623}