Skip to main content

bynk_emit/project/
paths.rs

1use super::*;
2
3/// v0.17 [DECISION L] stub: a version range is *unpinned* — and rejected — when
4/// it is empty, `*`/`x`/`latest`, or otherwise carries no concrete version
5/// number. A pinned range names at least one digit (`^5`, `~1.2`, `1.2.3`,
6/// `>=1.0 <2`). No allow-list or registry check yet.
7pub(crate) fn is_unpinned_range(range: &str) -> bool {
8    let r = range.trim();
9    if r.is_empty() || r == "*" || r.eq_ignore_ascii_case("x") || r.eq_ignore_ascii_case("latest") {
10        return true;
11    }
12    !r.chars().any(|c| c.is_ascii_digit())
13}
14
15/// Render a minimal `package.json` carrying the adapter-declared dependencies.
16pub(crate) fn render_package_json(deps: &std::collections::BTreeMap<String, String>) -> String {
17    let mut out = String::from("{\n  \"dependencies\": {\n");
18    let entries: Vec<String> = deps
19        .iter()
20        .map(|(pkg, range)| format!("    {}: {}", json_string(pkg), json_string(range)))
21        .collect();
22    out.push_str(&entries.join(",\n"));
23    out.push_str("\n  }\n}\n");
24    out
25}
26
27/// Minimal JSON string escaping for package names and version ranges.
28fn json_string(s: &str) -> String {
29    let mut out = String::from("\"");
30    for c in s.chars() {
31        match c {
32            '"' => out.push_str("\\\""),
33            '\\' => out.push_str("\\\\"),
34            _ => out.push(c),
35        }
36    }
37    out.push('"');
38    out
39}
40
41/// Normalise a relative path by resolving `.` and `..` components, so a binding
42/// clause like `./tokens.binding.ts` beside `src/tokens.bynk` yields the output
43/// path `tokens.binding.ts`.
44pub(crate) fn normalize_rel(p: &Path) -> PathBuf {
45    let mut out: Vec<std::ffi::OsString> = Vec::new();
46    for c in p.components() {
47        match c {
48            Component::CurDir => {}
49            Component::ParentDir => {
50                out.pop();
51            }
52            Component::Normal(s) => out.push(s.to_os_string()),
53            Component::RootDir | Component::Prefix(_) => {}
54        }
55    }
56    out.iter().collect()
57}
58
59/// v0.113 (DECISION S): the project's source tree, read from `bynk.toml`'s
60/// `[paths]` section. Test-ness is a property of the `suite` declaration, not of
61/// a directory, so the layout is a flat **`include`** list of trees to compile
62/// and an **`exclude`** list of subtrees to skip — not the role-named
63/// `src`/`tests` split. Each `include` entry is a root walked for `.bynk` files;
64/// a file's identity path is relative to the `include` root that contains it.
65#[derive(Debug, Clone)]
66pub struct ProjectPaths {
67    /// Trees to compile, relative to the project root. Defaults to the
68    /// conventional roots that exist (`src`, and `tests` when present), else the
69    /// project root itself.
70    pub include: Vec<PathBuf>,
71    /// Subtrees to skip during discovery (monorepo, vendored, or generated
72    /// `.bynk`), relative to the project root.
73    pub exclude: Vec<PathBuf>,
74}
75
76impl ProjectPaths {
77    /// The default layout when `bynk.toml` declares no `[paths] include`: the
78    /// conventional `src`/`tests` roots that exist under `project_root`, or the
79    /// project root itself when neither does. This keeps a conventional
80    /// `src/`(+`tests/`) project working with no config, and lets a flat project
81    /// (`.bynk` at the root, no `src/`) compile with no config either.
82    pub fn conventional(project_root: &Path) -> Self {
83        let mut include = Vec::new();
84        for role in ["src", "tests"] {
85            if project_root.join(role).is_dir() {
86                include.push(PathBuf::from(role));
87            }
88        }
89        if include.is_empty() {
90            include.push(PathBuf::from("."));
91        }
92        ProjectPaths {
93            include,
94            exclude: Vec::new(),
95        }
96    }
97}
98
99/// v0.113: read `bynk.toml` from `project_root`. Returns the conventional layout
100/// (see [`ProjectPaths::conventional`]) if the file is missing or declares no
101/// `[paths] include`. Honours `include` / `exclude` under `[paths]`, each an
102/// array of path strings; anything else is ignored. A minimal hand-rolled TOML
103/// reader — we only need string and string-array values here.
104pub fn read_project_paths(project_root: &Path) -> ProjectPaths {
105    let mut include: Vec<PathBuf> = Vec::new();
106    let mut exclude: Vec<PathBuf> = Vec::new();
107    let toml_path = project_root.join("bynk.toml");
108    let Ok(content) = fs::read_to_string(&toml_path) else {
109        return ProjectPaths::conventional(project_root);
110    };
111    let mut in_paths_section = false;
112    for line in content.lines() {
113        let trimmed = line.trim();
114        if trimmed.is_empty() || trimmed.starts_with('#') {
115            continue;
116        }
117        if let Some(section) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
118            in_paths_section = section.trim() == "paths";
119            continue;
120        }
121        if !in_paths_section {
122            continue;
123        }
124        let Some((key, value)) = trimmed.split_once('=') else {
125            continue;
126        };
127        match key.trim() {
128            "include" => include = parse_path_array(value.trim()),
129            "exclude" => exclude = parse_path_array(value.trim()),
130            _ => {}
131        }
132    }
133    if include.is_empty() {
134        include = ProjectPaths::conventional(project_root).include;
135    }
136    ProjectPaths { include, exclude }
137}
138
139/// Parse a TOML string-array value (`["a", "b"]`) into paths. Tolerates a bare
140/// quoted string (`"a"`) as a one-element list. Whitespace and quotes are
141/// stripped from each element; empty elements are dropped.
142fn parse_path_array(value: &str) -> Vec<PathBuf> {
143    let inner = value
144        .strip_prefix('[')
145        .and_then(|v| v.strip_suffix(']'))
146        .unwrap_or(value);
147    inner
148        .split(',')
149        .map(|el| el.trim())
150        .map(|el| {
151            el.strip_prefix('"')
152                .and_then(|v| v.strip_suffix('"'))
153                .or_else(|| el.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
154                .unwrap_or(el)
155        })
156        .filter(|el| !el.is_empty())
157        .map(PathBuf::from)
158        .collect()
159}
160
161pub(crate) fn commons_dir_for(name: &str) -> PathBuf {
162    let parts: Vec<&str> = name.split('.').collect();
163    let mut p = PathBuf::new();
164    for part in parts {
165        p.push(part);
166    }
167    p
168}
169
170pub(crate) fn ts_output_path(source: &Path) -> PathBuf {
171    let mut out = source.to_path_buf();
172    out.set_extension("ts");
173    out
174}
175
176/// v0.8: directory name of a Worker for a given context, with dots replaced
177/// by dashes (`commerce.payment` → `commerce-payment`).
178pub fn worker_dir_name(context: &str) -> String {
179    context.replace('.', "-")
180}
181
182/// v0.8: project-relative synthetic source path of the workers-mode
183/// handlers file for a given context. Used so the emitter's relative-import
184/// machinery resolves correctly against the workers layout.
185pub fn worker_handlers_source_path(context: &str) -> PathBuf {
186    PathBuf::from(format!(
187        "workers/{}/handlers.bynk",
188        worker_dir_name(context)
189    ))
190}
191
192/// v0.8: project-relative output path of the workers-mode handlers file.
193pub fn worker_handlers_output_path(context: &str) -> PathBuf {
194    PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
195}
196
197/// v0.9.1: shared between source-unit and test-unit path validation. The
198/// caller decides which root to strip from the file path before calling.
199pub(crate) fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
200    let name_parts: Vec<&str> = qualified_name.split('.').collect();
201    let stem = rel_path.with_extension("");
202    let stem_parts: Vec<String> = stem
203        .components()
204        .filter_map(|c| match c {
205            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
206            _ => None,
207        })
208        .collect();
209    let parent_parts: Vec<String> = if stem_parts.is_empty() {
210        Vec::new()
211    } else {
212        stem_parts[..stem_parts.len() - 1].to_vec()
213    };
214    let single_file_match = stem_parts.len() == name_parts.len()
215        && stem_parts
216            .iter()
217            .zip(name_parts.iter())
218            .all(|(a, b)| a == b);
219    let multi_file_match = parent_parts.len() == name_parts.len()
220        && parent_parts
221            .iter()
222            .zip(name_parts.iter())
223            .all(|(a, b)| a == b);
224    single_file_match || multi_file_match
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::path::{Path, PathBuf};
231
232    // -- is_unpinned_range ----------------------------------------------------
233    #[test]
234    fn is_unpinned_range_true_for_wildcards_and_digitless() {
235        assert!(is_unpinned_range(""));
236        assert!(is_unpinned_range("*"));
237        assert!(is_unpinned_range("x"));
238        assert!(is_unpinned_range("X"));
239        assert!(is_unpinned_range("latest"));
240        assert!(is_unpinned_range("LATEST"));
241        assert!(is_unpinned_range("  *  ")); // trimmed before the checks
242        assert!(is_unpinned_range("workspace:*")); // no ascii digit
243        assert!(is_unpinned_range("beta"));
244    }
245
246    #[test]
247    fn is_unpinned_range_false_when_a_digit_is_present() {
248        assert!(!is_unpinned_range("1.0.0"));
249        assert!(!is_unpinned_range("^1.2"));
250        assert!(!is_unpinned_range("~0.1"));
251        assert!(!is_unpinned_range(">=2"));
252        assert!(!is_unpinned_range("18"));
253    }
254
255    // -- normalize_rel --------------------------------------------------------
256    #[test]
257    fn normalize_rel_resolves_dot_and_parent() {
258        assert_eq!(
259            normalize_rel(Path::new("./tokens.binding.ts")),
260            PathBuf::from("tokens.binding.ts")
261        );
262        assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
263        assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
264        assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
265        assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
266    }
267
268    #[test]
269    fn normalize_rel_drops_root_and_pops_through_empty() {
270        // RootDir / Prefix components are dropped.
271        assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
272        // A leading `..` pops an empty stack (a no-op), so it vanishes.
273        assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
274    }
275
276    // -- commons_dir_for / ts_output_path -------------------------------------
277    #[test]
278    fn commons_dir_for_splits_dotted_name_into_dirs() {
279        assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
280        assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
281    }
282
283    #[test]
284    fn ts_output_path_sets_ts_extension() {
285        assert_eq!(
286            ts_output_path(Path::new("foo.bynk")),
287            PathBuf::from("foo.ts")
288        );
289        assert_eq!(
290            ts_output_path(Path::new("a/b.bynk")),
291            PathBuf::from("a/b.ts")
292        );
293        assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
294    }
295
296    // -- worker path helpers --------------------------------------------------
297    #[test]
298    fn worker_paths_dasherise_and_root_under_workers() {
299        assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
300        assert_eq!(worker_dir_name("plain"), "plain");
301        assert_eq!(
302            worker_handlers_source_path("commerce.payment"),
303            PathBuf::from("workers/commerce-payment/handlers.bynk")
304        );
305        assert_eq!(
306            worker_handlers_output_path("commerce.payment"),
307            PathBuf::from("workers/commerce-payment/handlers.ts")
308        );
309    }
310
311    // -- unit_path_matches ----------------------------------------------------
312    #[test]
313    fn unit_path_matches_single_file_layout() {
314        assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
315        assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
316    }
317
318    #[test]
319    fn unit_path_matches_multi_file_layout() {
320        // `a/b/c/<any>.bynk` declaring `a.b.c` (the directory is the unit).
321        assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
322        assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
323    }
324
325    #[test]
326    fn unit_path_matches_rejects_misalignment() {
327        assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
328        assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
329    }
330}