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
8pub 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
20pub 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
32pub 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#[derive(Debug, Clone)]
57pub struct ProjectPaths {
58 pub include: Vec<PathBuf>,
62 pub exclude: Vec<PathBuf>,
65}
66
67impl ProjectPaths {
68 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#[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#[derive(Debug)]
110pub enum ProjectPathsError {
111 Malformed,
113 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
132pub 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
150pub 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
205pub fn worker_dir_name(context: &str) -> String {
208 context.replace('.', "-")
209}
210
211pub fn worker_handlers_source_path(context: &str) -> PathBuf {
215 PathBuf::from(format!(
216 "workers/{}/handlers.bynk",
217 worker_dir_name(context)
218 ))
219}
220
221pub fn worker_handlers_output_path(context: &str) -> PathBuf {
223 PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
224}
225
226fn 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
239pub 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
258fn 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
272pub 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
286pub 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 #[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(" * ")); assert!(is_unpinned_range("workspace:*")); 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 #[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 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 #[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 #[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 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 #[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 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 #[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 assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
467 assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
469 }
470
471 #[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 #[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 #[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 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 #[test]
528 fn is_multi_file_layout_true_only_for_directory_layout() {
529 assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
531 assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
532 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 assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
540 assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
542 assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
544 }
545
546 #[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 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 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 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}