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 UnknownTable(String),
120 TopLevelKey(String),
122 UnknownTableKey { table: &'static str, key: String },
126}
127
128pub const MANIFEST_TABLES: &[(&str, &[&str])] = &[
132 ("project", &["name", "version"]),
133 ("paths", &["include", "exclude"]),
134 ("fmt", &[]),
135 ("lsp", &["diagnostics_mode", "diagnostics_debounce_ms"]),
136];
137
138const PLANNED_TABLES: &[(&str, &str)] = &[
141 ("dependencies", "#843"),
142 ("dev-dependencies", "#843"),
143 ("workspace", "#843"),
144 ("deploy", "#551"),
145];
146
147fn table_list(conj: &str) -> String {
150 let names: Vec<String> = MANIFEST_TABLES
151 .iter()
152 .map(|(t, _)| format!("`[{t}]`"))
153 .collect();
154 match names.split_last() {
155 Some((last, rest)) if !rest.is_empty() => format!("{} {conj} {last}", rest.join(", ")),
156 _ => names.join(""),
157 }
158}
159
160fn did_you_mean<'a>(name: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
162 candidates
163 .into_iter()
164 .map(|c| (edit_distance(name, c), c))
165 .filter(|(d, _)| *d <= 2)
166 .min_by_key(|(d, _)| *d)
167 .map(|(_, c)| c)
168}
169
170fn edit_distance(a: &str, b: &str) -> usize {
172 let b: Vec<char> = b.chars().collect();
173 let mut row: Vec<usize> = (0..=b.len()).collect();
174 for (i, ca) in a.chars().enumerate() {
175 let mut prev = row[0];
176 row[0] = i + 1;
177 for (j, cb) in b.iter().enumerate() {
178 let cur = row[j + 1];
179 row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
180 prev = cur;
181 }
182 }
183 row[b.len()]
184}
185
186impl std::fmt::Display for ProjectPathsError {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 match self {
189 ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
190 ProjectPathsError::UnknownKey(k) => {
191 write!(
192 f,
193 "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
194 )
195 }
196 ProjectPathsError::UnknownTable(name) => {
197 write!(f, "`bynk.toml` has no table named `[{name}]` — ")?;
198 if let Some((_, issue)) = PLANNED_TABLES.iter().find(|(t, _)| *t == name) {
199 write!(f, "it is planned but not yet supported ({issue})")
200 } else if let Some(t) = did_you_mean(name, MANIFEST_TABLES.iter().map(|(t, _)| *t))
201 {
202 write!(f, "did you mean `[{t}]`?")
203 } else {
204 write!(f, "the tables are {}", table_list("and"))
205 }
206 }
207 ProjectPathsError::TopLevelKey(key) => {
208 if MANIFEST_TABLES.iter().any(|(t, _)| t == key) {
211 return write!(
212 f,
213 "`{key}` in `bynk.toml` must be a table — write it as `[{key}]`"
214 );
215 }
216 write!(f, "`bynk.toml` has a key `{key}` outside any table — ")?;
217 match MANIFEST_TABLES
218 .iter()
219 .find(|(_, keys)| keys.contains(&key.as_str()))
220 {
221 Some((table, _)) => write!(f, "did you mean it under `[{table}]`?"),
222 None => write!(f, "keys belong in {}", table_list("or")),
223 }
224 }
225 ProjectPathsError::UnknownTableKey { table, key } => {
226 let keys = MANIFEST_TABLES
227 .iter()
228 .find(|(t, _)| t == table)
229 .map(|(_, k)| *k)
230 .unwrap_or_default();
231 write!(f, "`[{table}]` has no key named `{key}` — ")?;
232 match did_you_mean(key, keys.iter().copied()) {
233 Some(k) => write!(f, "did you mean `{k}`?"),
234 None => {
235 let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
236 write!(f, "its keys are {}", list.join(", "))
237 }
238 }
239 }
240 }
241 }
242}
243
244pub fn check_manifest(
255 project_root: &Path,
256 overlay: &HashMap<PathBuf, String>,
257) -> Result<(), ProjectPathsError> {
258 let toml_path = project_root.join("bynk.toml");
259 let Ok(content) = read_source(&toml_path, overlay) else {
260 return Ok(());
261 };
262 check_manifest_str(&content)
263}
264
265pub fn check_manifest_str(content: &str) -> Result<(), ProjectPathsError> {
267 let doc = content
268 .parse::<toml::Table>()
269 .map_err(|_| ProjectPathsError::Malformed)?;
270 for (name, value) in &doc {
271 if PLANNED_TABLES.iter().any(|(t, _)| t == name) {
274 return Err(ProjectPathsError::UnknownTable(name.clone()));
275 }
276 let Some((table, keys)) = MANIFEST_TABLES.iter().find(|(t, _)| t == name) else {
277 return Err(if value.is_table() {
278 ProjectPathsError::UnknownTable(name.clone())
279 } else {
280 ProjectPathsError::TopLevelKey(name.clone())
281 });
282 };
283 let Some(entries) = value.as_table() else {
286 return Err(ProjectPathsError::TopLevelKey(name.clone()));
287 };
288 if keys.is_empty() {
289 continue;
290 }
291 for key in entries.keys() {
292 if !keys.contains(&key.as_str()) {
293 return Err(match *table {
294 "paths" => ProjectPathsError::UnknownKey(key.clone()),
295 _ => ProjectPathsError::UnknownTableKey {
296 table,
297 key: key.clone(),
298 },
299 });
300 }
301 }
302 }
303 Ok(())
304}
305
306pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
316 let toml_path = project_root.join("bynk.toml");
317 let overlay = match fs::read_to_string(&toml_path) {
318 Ok(text) => HashMap::from([(toml_path, text)]),
319 Err(_) => HashMap::new(),
320 };
321 try_read_project_paths_with(project_root, &overlay)
322}
323
324pub fn try_read_project_paths_with(
327 project_root: &Path,
328 overlay: &HashMap<PathBuf, String>,
329) -> Result<ProjectPaths, ProjectPathsError> {
330 let toml_path = project_root.join("bynk.toml");
331 let Ok(content) = read_source(&toml_path, overlay) else {
332 return Ok(ProjectPaths::conventional(project_root));
333 };
334 let Ok(doc) = content.parse::<toml::Table>() else {
335 return Err(ProjectPathsError::Malformed);
336 };
337 let paths = doc.get("paths").and_then(|v| v.as_table());
338 if let Some(t) = paths {
339 for k in t.keys() {
340 if k != "include" && k != "exclude" {
341 return Err(ProjectPathsError::UnknownKey(k.clone()));
342 }
343 }
344 }
345 let list = |key: &str| -> Vec<PathBuf> {
346 match paths.and_then(|t| t.get(key)) {
347 Some(toml::Value::Array(items)) => items
348 .iter()
349 .filter_map(|v| v.as_str())
350 .map(PathBuf::from)
351 .collect(),
352 Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
353 _ => Vec::new(),
354 }
355 };
356 let mut include = list("include");
357 let exclude = list("exclude");
358 if include.is_empty() {
359 include = ProjectPaths::conventional(project_root).include;
360 }
361 Ok(ProjectPaths { include, exclude })
362}
363
364pub fn commons_dir_for(name: &str) -> PathBuf {
365 let parts: Vec<&str> = name.split('.').collect();
366 let mut p = PathBuf::new();
367 for part in parts {
368 p.push(part);
369 }
370 p
371}
372
373pub fn ts_output_path(source: &Path) -> PathBuf {
374 let mut out = source.to_path_buf();
375 out.set_extension("ts");
376 out
377}
378
379pub fn worker_dir_name(context: &str) -> String {
382 context.replace('.', "-")
383}
384
385pub fn worker_handlers_source_path(context: &str) -> PathBuf {
389 PathBuf::from(format!(
390 "workers/{}/handlers.bynk",
391 worker_dir_name(context)
392 ))
393}
394
395pub fn worker_handlers_output_path(context: &str) -> PathBuf {
397 PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
398}
399
400fn stem_parts(rel_path: &Path) -> Vec<String> {
403 rel_path
404 .with_extension("")
405 .components()
406 .filter_map(|c| match c {
407 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
408 _ => None,
409 })
410 .collect()
411}
412
413pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
422 let name_parts: Vec<&str> = qualified_name.split('.').collect();
423 let stem_parts = stem_parts(rel_path);
424 let single_file_match = stem_parts.len() == name_parts.len()
425 && stem_parts
426 .iter()
427 .zip(name_parts.iter())
428 .all(|(a, b)| a == b);
429 single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
430}
431
432fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
435 if stem_parts.is_empty() {
436 return false;
437 }
438 let parent_parts = &stem_parts[..stem_parts.len() - 1];
439 parent_parts.len() == name_parts.len()
440 && parent_parts
441 .iter()
442 .zip(name_parts.iter())
443 .all(|(a, b)| a == b)
444}
445
446pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
456 let name_parts: Vec<&str> = qualified_name.split('.').collect();
457 is_multi_file_parts(&stem_parts(rel_path), &name_parts)
458}
459
460pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
475 let name_parts: Vec<&str> = old_name.split('.').collect();
476 let old_stem = stem_parts(old_rel);
477 let new_stem = stem_parts(new_rel);
478
479 let suffix_matches = |haystack: &[String]| {
480 haystack.len() >= name_parts.len() && {
481 let prefix_len = haystack.len() - name_parts.len();
482 haystack[prefix_len..]
483 .iter()
484 .zip(name_parts.iter())
485 .all(|(a, b)| a == b)
486 }
487 };
488
489 if suffix_matches(&old_stem) {
490 let prefix_len = old_stem.len() - name_parts.len();
491 return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
492 }
493 if !old_stem.is_empty() {
494 let old_parent = &old_stem[..old_stem.len() - 1];
495 if suffix_matches(old_parent) {
496 let prefix_len = old_parent.len() - name_parts.len();
497 if new_stem.is_empty() {
498 return None;
499 }
500 let new_parent = &new_stem[..new_stem.len() - 1];
501 return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
502 }
503 }
504 None
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use std::path::{Path, PathBuf};
511
512 #[test]
514 fn is_unpinned_range_true_for_wildcards_and_digitless() {
515 assert!(is_unpinned_range(""));
516 assert!(is_unpinned_range("*"));
517 assert!(is_unpinned_range("x"));
518 assert!(is_unpinned_range("X"));
519 assert!(is_unpinned_range("latest"));
520 assert!(is_unpinned_range("LATEST"));
521 assert!(is_unpinned_range(" * ")); assert!(is_unpinned_range("workspace:*")); assert!(is_unpinned_range("beta"));
524 }
525
526 #[test]
527 fn is_unpinned_range_false_when_a_digit_is_present() {
528 assert!(!is_unpinned_range("1.0.0"));
529 assert!(!is_unpinned_range("^1.2"));
530 assert!(!is_unpinned_range("~0.1"));
531 assert!(!is_unpinned_range(">=2"));
532 assert!(!is_unpinned_range("18"));
533 }
534
535 #[test]
537 fn read_project_paths_with_honours_a_virtual_bynk_toml() {
538 let root = PathBuf::from("/nonexistent-bynk-test-root-57");
539 let mut overlay = HashMap::new();
540 overlay.insert(
541 root.join("bynk.toml"),
542 "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
543 );
544 let paths = read_project_paths_with(&root, &overlay);
545 assert_eq!(paths.include, vec![PathBuf::from("app")]);
546 assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
547 }
548
549 #[test]
550 fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
551 let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
554 let paths = read_project_paths_with(&root, &HashMap::new());
555 let conventional = ProjectPaths::conventional(&root);
556 assert_eq!(paths.include, conventional.include);
557 assert_eq!(paths.exclude, conventional.exclude);
558 }
559
560 #[test]
563 fn read_project_paths_with_honours_three_or_more_include_entries() {
564 let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
565 let mut overlay = HashMap::new();
566 overlay.insert(
567 root.join("bynk.toml"),
568 "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
569 );
570 let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
571 assert_eq!(
572 paths.include,
573 vec![
574 PathBuf::from("src"),
575 PathBuf::from("tests"),
576 PathBuf::from("examples"),
577 ]
578 );
579 }
580
581 #[test]
583 fn render_package_json_renders_sorted_dependencies() {
584 let mut deps = std::collections::BTreeMap::new();
585 deps.insert("zod".to_string(), "^3.22.4".to_string());
586 deps.insert("hono".to_string(), "^4.0.0".to_string());
587 let out = render_package_json(&deps);
588 assert!(
590 out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
591 "dependencies render in sorted order:\n{out}"
592 );
593 assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
594 }
595
596 #[test]
602 fn render_package_json_escapes_the_control_range() {
603 let mut deps = std::collections::BTreeMap::new();
604 deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
605 let out = render_package_json(&deps);
606 assert!(out.contains("\"pkg\\nname\""), "{out}");
607 assert!(out.contains("\"^1.0\\u00010\""), "{out}");
608 assert!(
611 !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
612 "a raw control character reached the output:\n{out:?}"
613 );
614 }
615
616 #[test]
617 fn render_package_json_escapes_structural_characters() {
618 let mut deps = std::collections::BTreeMap::new();
619 deps.insert("a\"b".to_string(), "c\\d".to_string());
620 let out = render_package_json(&deps);
621 assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
622 }
623
624 #[test]
626 fn normalize_rel_resolves_dot_and_parent() {
627 assert_eq!(
628 normalize_rel(Path::new("./tokens.binding.ts")),
629 PathBuf::from("tokens.binding.ts")
630 );
631 assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
632 assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
633 assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
634 assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
635 }
636
637 #[test]
638 fn normalize_rel_drops_root_and_pops_through_empty() {
639 assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
641 assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
643 }
644
645 #[test]
647 fn commons_dir_for_splits_dotted_name_into_dirs() {
648 assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
649 assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
650 }
651
652 #[test]
653 fn ts_output_path_sets_ts_extension() {
654 assert_eq!(
655 ts_output_path(Path::new("foo.bynk")),
656 PathBuf::from("foo.ts")
657 );
658 assert_eq!(
659 ts_output_path(Path::new("a/b.bynk")),
660 PathBuf::from("a/b.ts")
661 );
662 assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
663 }
664
665 #[test]
667 fn worker_paths_dasherise_and_root_under_workers() {
668 assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
669 assert_eq!(worker_dir_name("plain"), "plain");
670 assert_eq!(
671 worker_handlers_source_path("commerce.payment"),
672 PathBuf::from("workers/commerce-payment/handlers.bynk")
673 );
674 assert_eq!(
675 worker_handlers_output_path("commerce.payment"),
676 PathBuf::from("workers/commerce-payment/handlers.ts")
677 );
678 }
679
680 #[test]
682 fn unit_path_matches_single_file_layout() {
683 assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
684 assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
685 }
686
687 #[test]
688 fn unit_path_matches_multi_file_layout() {
689 assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
691 assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
692 }
693
694 #[test]
695 fn unit_path_matches_rejects_misalignment() {
696 assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
697 assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
698 }
699
700 #[test]
702 fn is_multi_file_layout_true_only_for_directory_layout() {
703 assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
705 assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
706 assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
708 }
709
710 #[test]
711 fn is_multi_file_layout_false_for_single_file_and_misalignment() {
712 assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
714 assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
716 assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
718 }
719
720 #[test]
722 fn renamed_unit_name_single_file() {
723 assert_eq!(
724 renamed_unit_name(
725 Path::new("a/b/c.bynk"),
726 "a.b.c",
727 Path::new("a/b/renamed.bynk")
728 ),
729 Some("a.b.renamed".to_string())
730 );
731 assert_eq!(
732 renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
733 Some("bar".to_string())
734 );
735 }
736
737 #[test]
738 fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
739 assert_eq!(
743 renamed_unit_name(
744 Path::new("a/b/c/old.bynk"),
745 "a.b.c",
746 Path::new("a/b/c/new.bynk")
747 ),
748 Some("a.b.c".to_string())
749 );
750 }
751
752 #[test]
753 fn renamed_unit_name_multi_file_directory_move() {
754 assert_eq!(
755 renamed_unit_name(
756 Path::new("a/b/c/handlers.bynk"),
757 "a.b.c",
758 Path::new("a/b/renamed/handlers.bynk")
759 ),
760 Some("a.b.renamed".to_string())
761 );
762 }
763
764 #[test]
765 fn renamed_unit_name_none_on_preexisting_misalignment() {
766 assert_eq!(
767 renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
768 None
769 );
770 }
771
772 #[test]
773 fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
774 assert_eq!(
780 renamed_unit_name(
781 Path::new("src/billing/charge.bynk"),
782 "billing.charge",
783 Path::new("src/billing/pay.bynk")
784 ),
785 Some("billing.pay".to_string())
786 );
787 assert_eq!(
789 renamed_unit_name(
790 Path::new("src/a/b/c/handlers.bynk"),
791 "a.b.c",
792 Path::new("src/a/b/renamed/handlers.bynk")
793 ),
794 Some("a.b.renamed".to_string())
795 );
796 }
797}
798
799#[cfg(test)]
800mod manifest_tests {
801 use super::*;
802
803 fn err(manifest: &str) -> String {
804 check_manifest_str(manifest)
805 .expect_err("the manifest is refused")
806 .to_string()
807 }
808
809 #[test]
811 fn the_documented_tables_are_accepted() {
812 check_manifest_str(
813 "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"src\"]\nexclude = []\n\n\
814 [fmt]\nindent = \"tab\"\nmax_line_width = 100\n\n[lsp]\ndiagnostics_mode = \"live\"\n\
815 diagnostics_debounce_ms = 300\n",
816 )
817 .expect("accepted");
818 check_manifest_str("").expect("an empty manifest is accepted");
819 }
820
821 #[test]
822 fn a_planned_table_names_its_issue() {
823 assert!(err("[dependencies]\nx = \"1\"\n").contains("not yet supported (#843)"));
824 assert!(err("[deploy]\ngroups = []\n").contains("not yet supported (#551)"));
825 assert!(err("[workspace]\nmembers = []\n").contains("(#843)"));
826 }
827
828 #[test]
829 fn a_typod_table_suggests_the_nearest() {
830 assert!(err("[pahts]\n").contains("did you mean `[paths]`?"));
831 assert!(err("[fnt]\n").contains("did you mean `[fmt]`?"));
832 assert!(err("[zzzzzz]\n").contains("the tables are"));
833 }
834
835 #[test]
836 fn an_unknown_key_in_project_or_lsp_suggests_the_nearest() {
837 assert!(err("[project]\nnmae = \"p\"\n").contains("did you mean `name`?"));
838 assert!(
839 err("[lsp]\ndiagnostics_mod = \"live\"\n").contains("did you mean `diagnostics_mode`?")
840 );
841 assert!(err("[project]\nlicense = \"MIT\"\n").contains("its keys are `name`, `version`"));
842 }
843
844 #[test]
845 fn a_paths_key_keeps_its_own_error() {
846 assert!(matches!(
847 check_manifest_str("[paths]\nout = \"out\"\n"),
848 Err(ProjectPathsError::UnknownKey(k)) if k == "out"
849 ));
850 }
851
852 #[test]
853 fn a_top_level_key_points_at_its_table() {
854 assert!(err("name = \"p\"\n").contains("did you mean it under `[project]`?"));
855 }
856
857 #[test]
860 fn a_table_name_with_a_plain_value_must_be_a_table() {
861 assert!(err("paths = \"src\"\n").contains("must be a table — write it as `[paths]`"));
862 assert!(err("lsp = 3\n").contains("`[lsp]`"));
863 assert!(err("fmt = true\n").contains("`[fmt]`"));
864 check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
866 }
867
868 #[test]
870 fn a_planned_table_in_any_shape_names_its_issue() {
871 assert!(err("[[dependencies]]\nname = \"acme\"\n").contains("(#843)"));
872 assert!(err("dependencies = [\"acme\"]\n").contains("(#843)"));
873 }
874
875 #[test]
877 fn fmt_keys_are_left_to_the_formatter() {
878 check_manifest_str("[fmt]\nanything = 1\n").expect("not this check's concern");
879 }
880
881 #[test]
882 fn a_malformed_manifest_is_malformed() {
883 assert!(matches!(
884 check_manifest_str("[project\n"),
885 Err(ProjectPathsError::Malformed)
886 ));
887 }
888}