bynk_emit/project/
paths.rs1use super::*;
2
3pub(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
15pub(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
27fn 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
41pub(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#[derive(Debug, Clone)]
66pub struct ProjectPaths {
67 pub include: Vec<PathBuf>,
71 pub exclude: Vec<PathBuf>,
74}
75
76impl ProjectPaths {
77 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
99pub 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
139fn 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
176pub fn worker_dir_name(context: &str) -> String {
179 context.replace('.', "-")
180}
181
182pub fn worker_handlers_source_path(context: &str) -> PathBuf {
186 PathBuf::from(format!(
187 "workers/{}/handlers.bynk",
188 worker_dir_name(context)
189 ))
190}
191
192pub fn worker_handlers_output_path(context: &str) -> PathBuf {
194 PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
195}
196
197pub(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 #[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(" * ")); assert!(is_unpinned_range("workspace:*")); 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 #[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 assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
272 assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
274 }
275
276 #[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 #[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 #[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 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}