Skip to main content

bynk_driver/
lib.rs

1//! bynk-driver — the shared front-end of the `bynkc` and `bynk` CLIs (#521).
2//!
3//! Both binaries expose `fmt` and `check` with identical semantics; before
4//! this crate each re-implemented the command bodies (and the project-failure
5//! flattening layer, and the project-rooting rule) as by-hand copies pinned
6//! only by comments and a skip-able parity test. The single implementation
7//! lives here, parameterised by the program name that prefixes messages.
8
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::ExitCode;
13
14use bynk_emit::project::{self, CompileOptions, ProjectPathsError, try_read_project_paths_with};
15use bynk_fmt::{FormatOptions, IndentStyle, format_source};
16
17pub mod coverage;
18pub mod discovery;
19pub mod output;
20pub mod probe;
21pub mod schema_lock;
22pub mod test_json;
23pub mod test_runner;
24
25pub use output::{write_document, write_output};
26
27/// Root a directory project the way every project command should (#46): a
28/// `bynk.toml` or a `src/` subdir selects **project** mode, whose flat
29/// `[paths] include`/`exclude` layout (v0.113, DECISION S) defaults to the
30/// conventional roots that exist (`src`, `tests`) or the project root itself;
31/// otherwise the legacy **single-tree** where `<dir>` is itself the root.
32/// `check`, `compile`, `test`, and `dev` all route through this so the
33/// conventional layout works the same from any of them.
34///
35/// #1077 (R2.3/T0.7 residue): reads and populates `.sources(...)` itself —
36/// `bynk-emit` no longer discovers or reads project files on disk, so this is
37/// now the one real place that walk happens for the live CLI path.
38///
39/// #1081 review: returns `Result` because that walk is real I/O against a
40/// user-controlled `bynk.toml` (a missing `include` root, an unreadable
41/// directory) — [`discovery::DiscoveryError`], not a panic.
42pub fn project_options(input: &Path) -> Result<CompileOptions, discovery::DiscoveryError> {
43    if input.join("bynk.toml").exists() || input.join("src").is_dir() {
44        let paths = try_read_project_paths_with(input, &manifest_overlay(input))
45            .unwrap_or_else(|_| project::ProjectPaths::conventional(input));
46        options_for_split(input, paths)
47    } else {
48        let sources = discovery::read_bynk_tree_single(input)?;
49        Ok(CompileOptions::single(input.to_path_buf()).sources(sources))
50    }
51}
52
53/// [`project_options`], but a malformed `bynk.toml` is an error rather than a
54/// silent fall-back to the conventional layout — the one input a user
55/// hand-edits that the compiler otherwise reads without checking, after which
56/// a cascade of `bynk.uses.unknown_target` errors points at units that
57/// plainly exist on disk.
58pub fn try_project_options(input: &Path) -> Result<CompileOptions, ProjectOptionsError> {
59    Ok(match project_sources(input)? {
60        (Some(paths), sources) => {
61            CompileOptions::split(input.to_path_buf(), paths).sources(sources)
62        }
63        (None, sources) => CompileOptions::single(input.to_path_buf()).sources(sources),
64    })
65}
66
67/// The `.bynk` files [`try_project_options`] reads for the directory `input`,
68/// sorted: what `check`, `test` and `compile` see. #1753: `fmt` expands a
69/// directory argument through this, so `fmt --check <dir>` and
70/// `check <dir>` cover the same files.
71pub fn project_source_files(input: &Path) -> Result<Vec<PathBuf>, ProjectOptionsError> {
72    let (_, sources) = project_sources(input)?;
73    let mut files: Vec<PathBuf> = sources.into_keys().collect();
74    files.sort();
75    Ok(files)
76}
77
78/// [`try_project_options`]' rooting and walk, shared with
79/// [`project_source_files`] so the two can't disagree on which files a
80/// directory holds. A `bynk.toml` or a `src/` subdir selects project mode,
81/// returning its `[paths]` layout alongside the sources; otherwise `input` is
82/// a single tree and the layout is `None`.
83fn project_sources(
84    input: &Path,
85) -> Result<(Option<project::ProjectPaths>, HashMap<PathBuf, String>), ProjectOptionsError> {
86    if input.join("bynk.toml").exists() || input.join("src").is_dir() {
87        let overlay = manifest_overlay(input);
88        // #1665: an unknown table is refused here, on the CLIs' path, before
89        // `[paths]` is read. (The language server reads `[paths]` alone.)
90        project::check_manifest(input, &overlay)?;
91        let paths = try_read_project_paths_with(input, &overlay)?;
92        let sources = split_sources(input, &paths)?;
93        Ok((Some(paths), sources))
94    } else {
95        Ok((None, discovery::read_bynk_tree_single(input)?))
96    }
97}
98
99/// `bynk.toml`'s own content, keyed exactly as [`try_read_project_paths_with`]
100/// looks it up (`project_root.join("bynk.toml")`, unmodified — the literal-path
101/// branch of `discovery::read_source`'s overlay lookup, so this never needs to
102/// match a canonicalised key).
103///
104/// #1077 review: without this, both entry points above read `bynk.toml`
105/// through `bynk-emit`'s own disk-fallback (`read_source`'s `fs::read_to_string`
106/// on an overlay miss) — the one on-disk read #1081 left the CLI path still
107/// implicitly depending on `bynk-emit` for, despite that PR's claim of a fully
108/// fallback-free CLI path. A missing/unreadable `bynk.toml` yields an empty
109/// overlay, which `try_read_project_paths_with` already treats as "no
110/// manifest" (falls back to the conventional layout) — the same degrade
111/// `try_read_project_paths` itself provides.
112fn manifest_overlay(input: &Path) -> HashMap<PathBuf, String> {
113    let toml_path = input.join("bynk.toml");
114    match fs::read_to_string(&toml_path) {
115        Ok(text) => HashMap::from([(toml_path, text)]),
116        Err(_) => HashMap::new(),
117    }
118}
119
120/// The split-layout half of `project_options`: walk the project's sources
121/// ([`split_sources`]) and hand them to `CompileOptions::split` alongside the
122/// layout.
123fn options_for_split(
124    input: &Path,
125    paths: project::ProjectPaths,
126) -> Result<CompileOptions, discovery::DiscoveryError> {
127    let sources = split_sources(input, &paths)?;
128    Ok(CompileOptions::split(input.to_path_buf(), paths).sources(sources))
129}
130
131/// The sources of a project rooted at `input` with layout `paths`: build the
132/// one `Roots` value the project resolves to and walk exactly that (via
133/// [`discovery::sources_for_roots`] — #1081 review, so the CLI's walk can't
134/// drift from what `Roots::trees`/`Roots::excludes` themselves say). The one
135/// copy of the split-layout walk, shared by [`project_options`] and
136/// [`try_project_options`]/[`project_source_files`] (#1755 review).
137fn split_sources(
138    input: &Path,
139    paths: &project::ProjectPaths,
140) -> Result<HashMap<PathBuf, String>, discovery::DiscoveryError> {
141    let roots = project::Roots::Split {
142        project_root: input.to_path_buf(),
143        paths: paths.clone(),
144    };
145    discovery::sources_for_roots(&roots)
146}
147
148/// Why [`try_project_options`] could not produce a usable [`CompileOptions`]:
149/// either the manifest itself is unreadable ([`ProjectPathsError`]), or a
150/// well-formed manifest names a project tree that can't be walked
151/// ([`discovery::DiscoveryError`]) — #1081 review.
152#[derive(Debug)]
153pub enum ProjectOptionsError {
154    Paths(ProjectPathsError),
155    Discovery(discovery::DiscoveryError),
156}
157
158impl std::fmt::Display for ProjectOptionsError {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            Self::Paths(e) => write!(f, "{e}"),
162            Self::Discovery(e) => write!(f, "{e}"),
163        }
164    }
165}
166
167impl std::error::Error for ProjectOptionsError {
168    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
169        match self {
170            // `ProjectPathsError` doesn't itself implement `Error` (no
171            // further cause to chain to — it's already a leaf).
172            Self::Paths(_) => None,
173            Self::Discovery(e) => Some(e),
174        }
175    }
176}
177
178impl From<ProjectPathsError> for ProjectOptionsError {
179    fn from(e: ProjectPathsError) -> Self {
180        Self::Paths(e)
181    }
182}
183
184impl From<discovery::DiscoveryError> for ProjectOptionsError {
185    fn from(e: discovery::DiscoveryError) -> Self {
186        Self::Discovery(e)
187    }
188}
189
190/// Render a project build failure with per-file ariadne context, exactly as
191/// single-file mode had rich rendering. Unattributed (project-level) errors
192/// keep the plain form.
193///
194/// This is the **flattening layer** (ADR 0100): it attributes each
195/// `AttributedError` to its file snapshot and delegates the actual rendering to
196/// [`bynk_render::print_errors`]. The `ProjectFailure → CompileError` flattening
197/// stays here, above `bynk-render`, so there is no `render → emit` edge.
198pub fn print_project_failure(failure: &project::ProjectFailure) {
199    for ae in &failure.errors {
200        match attributed_snapshot(ae, &failure.snapshots, &failure.display_root) {
201            Some((label, text)) => {
202                bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
203            }
204            None => {
205                eprintln!("[{}] {}", ae.error.category, ae.error.message);
206                for note in &ae.error.notes {
207                    eprintln!("  note: {note}");
208                }
209                // Finding #47: a label's text still surfaces even with no
210                // file to underline it against.
211                for (_, label) in &ae.error.labels {
212                    eprintln!("  label: {label}");
213                }
214            }
215        }
216    }
217}
218
219/// v0.89 (ADR 0117): print a successful build's non-failing warnings, with
220/// real per-file ariadne context now that a successful build's `snapshots`
221/// (mirroring `ProjectFailure::snapshots`) make that possible. A warning whose
222/// source isn't attributable (or doesn't fit the snapshot) falls back to the
223/// plain `warning[<category>]: <message>` form.
224pub fn print_project_warnings(
225    warnings: &[project::AttributedError],
226    snapshots: &[(PathBuf, String)],
227    display_root: &Path,
228) {
229    for w in warnings {
230        match attributed_snapshot(w, snapshots, display_root) {
231            Some((label, text)) => {
232                bynk_render::print_errors(std::slice::from_ref(&w.error), text, &label)
233            }
234            None => {
235                let where_ = w
236                    .source_path
237                    .as_deref()
238                    .map(|p| format!("{}: ", p.to_string_lossy().replace('\\', "/")))
239                    .unwrap_or_default();
240                eprintln!("{where_}warning[{}]: {}", w.error.category, w.error.message);
241                for note in &w.error.notes {
242                    eprintln!("  note: {note}");
243                }
244                for (_, label) in &w.error.labels {
245                    eprintln!("  label: {label}");
246                }
247            }
248        }
249    }
250}
251
252/// [`print_project_warnings`]'s `--format short` analogue: one
253/// `path:line:col: warning[category]: message` line per warning, falling
254/// back to `warning[category]: message` when unattributed. Strictly one
255/// line per warning throughout (like [`bynk_render::render_errors_short`],
256/// this mirrors the VS Code problem-matcher's contract), so — unlike
257/// [`print_project_warnings`] — finding #47 doesn't reach this one.
258pub fn print_project_warnings_short(
259    warnings: &[project::AttributedError],
260    snapshots: &[(PathBuf, String)],
261    display_root: &Path,
262) {
263    for w in warnings {
264        match attributed_snapshot(w, snapshots, display_root) {
265            Some((label, text)) => eprintln!("{}", bynk_render::short_line(&label, text, &w.error)),
266            // Every entry in `warnings` is warning-severity by construction
267            // (ADR 0117's own split), so `severity_word` here is always
268            // "warning" — read off the shared helper (finding #48) rather
269            // than hardcoding the string a second time.
270            None => eprintln!(
271                "{}[{}]: {}",
272                bynk_render::severity_word(&w.error),
273                w.error.category,
274                w.error.message
275            ),
276        }
277    }
278}
279
280/// The `(label, source text)` an `AttributedError`'s `source_path` resolves
281/// to in `snapshots`, if any — the one attribution lookup every renderer in
282/// this file shares (finding #48; previously `print_project_failure` and
283/// [`project_failure_short_lines`] each hand-rolled their own copy).
284///
285/// #1772: the label is the file's path as typed from the working directory
286/// ([`display_path`]), not its identity path. `snapshots` stay keyed by
287/// identity.
288fn attributed_snapshot<'a>(
289    ae: &project::AttributedError,
290    snapshots: &'a [(PathBuf, String)],
291    display_root: &Path,
292) -> Option<(String, &'a str)> {
293    let path = ae.source_path.as_deref()?;
294    let text = snapshots
295        .iter()
296        .find(|(p, _)| p.as_path() == path)
297        .map(|(_, t)| t.as_str())?;
298    Some((display_path(display_root, path), text))
299}
300
301/// #1772: the path a diagnostic names, as a user would type it from the
302/// working directory: the build's root as the caller spelled it, joined with
303/// the file's identity path (relative to that root). A relative root is
304/// already relative to the working directory; an absolute one (`bynk dev`
305/// resolves the project root) is shown relative to the working directory when
306/// it lies inside it. This matches the path `fmt` reports for the same file,
307/// so a problem matcher resolving against the working directory finds it.
308/// Separators print as `/` on every platform.
309///
310/// #1774 review: a `.` component is dropped (`bynk check`'s default input is
311/// `.`, and `Roots::trees` already keeps `./` out of identity paths for the
312/// same reason), and an absolute path is compared against the cwd both as
313/// given and canonicalised, since a canonical root on Windows is a verbatim
314/// `\\?\C:\…` path that a plain cwd never prefixes. One that still isn't
315/// under the cwd is shown absolute, without a verbatim prefix.
316pub fn display_path(display_root: &Path, identity: &Path) -> String {
317    use std::path::Component;
318    let joined: PathBuf = display_root
319        .join(identity)
320        .components()
321        .filter(|c| !matches!(c, Component::CurDir))
322        .collect();
323    let shown = if joined.is_absolute() {
324        let cwd = std::env::current_dir().ok();
325        let canonical_cwd = cwd.as_ref().and_then(|c| c.canonicalize().ok());
326        cwd.iter()
327            .chain(canonical_cwd.iter())
328            .find_map(|base| joined.strip_prefix(base).ok().map(Path::to_path_buf))
329            .unwrap_or(joined)
330    } else {
331        joined
332    };
333    let text = shown.to_string_lossy().replace('\\', "/");
334    // A verbatim prefix (`//?/C:/…`, `//?/UNC/server/…`) is Windows-internal.
335    if let Some(unc) = text.strip_prefix("//?/UNC/") {
336        format!("//{unc}")
337    } else if let Some(rest) = text.strip_prefix("//?/") {
338        rest.to_string()
339    } else {
340        text
341    }
342}
343
344/// #1774: a path the user named directly (a `fmt` input, a single-file
345/// `check`), shown by the same rule as [`display_path`], so `fmt` and `check`
346/// print one file identically on every platform. On Windows a directory's
347/// expansion joined `\` onto a `/`-typed input, giving mixed separators.
348fn shown(path: &Path) -> String {
349    display_path(Path::new(""), path)
350}
351
352/// The project-failure analogue of [`bynk_render::print_errors_short`]: each
353/// attributed error is positioned against its file's snapshot; an unattributed
354/// (project-level) error falls back to `<severity>[<category>]: <message>`.
355pub fn print_project_failure_short(failure: &project::ProjectFailure) {
356    for line in project_failure_short_lines(failure) {
357        eprintln!("{line}");
358    }
359}
360
361/// The string form of [`print_project_failure_short`]: one `path:line:col:
362/// severity[category]: message` line per attributed error (an unattributed
363/// project-level error falls back to `severity[category]: message`). Backs both
364/// the printer above and the `bynkc test --format json` compile-error document,
365/// whose `diagnostics` the VS Code `bynkc` problem-matcher re-parses — each
366/// `Vec` entry is exactly one line by that contract, so unlike the other
367/// renderers in this file this one deliberately does *not* grow note/label
368/// continuation lines (finding #47): doing so would break a machine consumer
369/// that re-parses every entry as a single diagnostic line.
370///
371/// The flattening layer (ADR 0100): it delegates the per-error formatting to
372/// [`bynk_render::short_line`] / [`bynk_render::severity_word`], and the
373/// attribution lookup to the crate-private `attributed_snapshot` (finding #48).
374pub fn project_failure_short_lines(failure: &project::ProjectFailure) -> Vec<String> {
375    failure
376        .errors
377        .iter()
378        .map(
379            |ae| match attributed_snapshot(ae, &failure.snapshots, &failure.display_root) {
380                Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
381                None => format!(
382                    "{}[{}]: {}",
383                    bynk_render::severity_word(&ae.error),
384                    ae.error.category,
385                    ae.error.message
386                ),
387            },
388        )
389        .collect()
390}
391
392/// Render every diagnostic from a [`project::ProjectCheck`] (finding #64) with
393/// the same per-file ariadne context [`print_project_failure`] gives its own,
394/// errors-only list. Unlike that renderer, a `ProjectCheck`'s list can
395/// legitimately mix both severities — the unattributed fallback line names its
396/// actual severity ([`bynk_render::severity_word`]) rather than
397/// `print_project_failure`'s bare `[category]: message` (silently correct only
398/// because that list is errors-only by construction).
399pub fn print_project_check(check: &project::ProjectCheck) {
400    for ae in &check.errors {
401        match attributed_snapshot(ae, &check.snapshots, &check.display_root) {
402            Some((label, text)) => {
403                bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
404            }
405            None => {
406                eprintln!(
407                    "{}[{}]: {}",
408                    bynk_render::severity_word(&ae.error),
409                    ae.error.category,
410                    ae.error.message
411                );
412                for note in &ae.error.notes {
413                    eprintln!("  note: {note}");
414                }
415                for (_, label) in &ae.error.labels {
416                    eprintln!("  label: {label}");
417                }
418            }
419        }
420    }
421}
422
423/// [`print_project_check`]'s `--format short` analogue, mirroring
424/// [`project_failure_short_lines`].
425pub fn project_check_short_lines(check: &project::ProjectCheck) -> Vec<String> {
426    check
427        .errors
428        .iter()
429        .map(
430            |ae| match attributed_snapshot(ae, &check.snapshots, &check.display_root) {
431                Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
432                None => format!(
433                    "{}[{}]: {}",
434                    bynk_render::severity_word(&ae.error),
435                    ae.error.category,
436                    ae.error.message
437                ),
438            },
439        )
440        .collect()
441}
442
443/// [`print_project_check`] via [`project_check_short_lines`].
444pub fn print_project_check_short(check: &project::ProjectCheck) {
445    for line in project_check_short_lines(check) {
446        eprintln!("{line}");
447    }
448}
449
450/// How `--indent` spells the two [`IndentStyle`] variants. The words match the
451/// `[fmt] indent` key in `bynk.toml`, which the language server already reads,
452/// so a project states the same choice the same way in either place.
453#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
454pub enum IndentKind {
455    /// One tab per nesting level. The default — a reader sets their own tab
456    /// width in the editor, which space indentation takes away from them.
457    Tab,
458    /// `--indent-width` spaces per nesting level.
459    Spaces,
460}
461
462/// The `fmt` subcommand's arguments, flattened by both `bynkc::cli` and
463/// `bynk::cli` so the two spell one contract rather than two copies of it (the
464/// [`test_runner::TestArgs`] pattern, findings #40/#72). Field docs here are
465/// the CLI help text for both commands' flags.
466///
467/// Every style field is an `Option`, and deliberately carries no clap
468/// `default_value` (#972). The three sources are layered — spec default, then
469/// the project's `bynk.toml` `[fmt]`, then the flag — and a clap default would
470/// make "the user asked for 100" indistinguishable from "the user said
471/// nothing", so a manifest's `max_line_width = 120` would be overwritten by a
472/// flag nobody passed. `None` means *defer to the layer below*.
473#[derive(clap::Args, Debug)]
474pub struct FmtArgs {
475    /// Files or directories to format. A directory formats the `.bynk` files
476    /// `check` would read in it: a project root's `[paths] include` trees minus
477    /// `exclude`, or any other directory walked recursively. Use `-` for stdin
478    /// → stdout.
479    pub inputs: Vec<PathBuf>,
480    /// Check formatting without writing changes. Exits non-zero if any
481    /// file is not already canonical.
482    #[arg(long)]
483    pub check: bool,
484    /// Indent with tabs or spaces. Defaults to the project's `[fmt] indent`,
485    /// or tabs.
486    #[arg(long, value_enum)]
487    pub indent: Option<IndentKind>,
488    /// Spaces per nesting level, with spaces indentation. Defaults to the
489    /// project's `[fmt] indent_width`, or 2. Rejected when the effective
490    /// indentation is tabs, where it would have no effect.
491    #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(0..=64))]
492    pub indent_width: Option<u8>,
493    /// Soft target line width in columns. A construct wider than this wraps
494    /// across lines where the grammar allows; one with no break point in it
495    /// (a long string literal) is left long. Defaults to the project's
496    /// `[fmt] max_line_width`, or 100.
497    #[arg(long, value_name = "COLUMNS", value_parser = clap::value_parser!(u32).range(1..))]
498    pub max_line_width: Option<u32>,
499    /// Emit a trailing comma in multi-line records, sums, list literals and
500    /// `exports` clauses. Overrides a project's `trailing_comma = false`, and
501    /// overrides an earlier `--no-trailing-comma`.
502    #[arg(long, overrides_with = "no_trailing_comma")]
503    pub trailing_comma: bool,
504    /// Omit the trailing comma in multi-line records, sums, list literals and
505    /// `exports` clauses. (Parameter and argument lists never carry one — the
506    /// grammar rejects it — regardless of this flag.)
507    #[arg(long, overrides_with = "trailing_comma")]
508    pub no_trailing_comma: bool,
509    /// Ignore the project's `bynk.toml` `[fmt]` section and format to the
510    /// canonical style, plus whatever flags this run passes. For a script that
511    /// wants one fixed rendering whatever project it is pointed at.
512    #[arg(long)]
513    pub no_config: bool,
514}
515
516impl FmtArgs {
517    /// Layer these arguments over `base` — the manifest-resolved options for
518    /// the file about to be formatted — or report why they describe nothing
519    /// usable. A field the run did not state leaves `base` untouched.
520    pub fn apply_to(&self, base: FormatOptions) -> Result<FormatOptions, String> {
521        // The width already in `base` (from `[fmt] indent_width`, or the spec
522        // default), so `--indent spaces` alone over a manifest's `indent_width
523        // = 4` lands on four spaces rather than resetting to two.
524        let base_width = match base.indent {
525            IndentStyle::Spaces(n) => Some(n),
526            IndentStyle::Tab => None,
527        };
528        let kind = self.indent.unwrap_or(match base.indent {
529            IndentStyle::Tab => IndentKind::Tab,
530            IndentStyle::Spaces(_) => IndentKind::Spaces,
531        });
532        let indent = match (kind, self.indent_width) {
533            (IndentKind::Tab, None) => IndentStyle::Tab,
534            // A width alongside tab indentation is silently meaningless, which
535            // is exactly the kind of ignored flag that costs an hour to
536            // notice. Say so instead — naming the *effective* indentation,
537            // since it may have come from the manifest rather than this run.
538            (IndentKind::Tab, Some(_)) => {
539                return Err(
540                    "`--indent-width` applies only to spaces indentation, and this run resolves \
541                     to tabs (pass `--indent spaces`, or set `[fmt] indent` in bynk.toml)"
542                        .to_string(),
543                );
544            }
545            // 2 matches the `bynk.toml` `[fmt] indent_width` fallback, so the
546            // CLI and the editor agree from the same words.
547            (IndentKind::Spaces, width) => IndentStyle::Spaces(width.or(base_width).unwrap_or(2)),
548        };
549        Ok(FormatOptions {
550            indent,
551            max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
552            // Neither flag set defers to `base`; clap's `overrides_with` pair
553            // makes the last one given win.
554            trailing_comma: if self.no_trailing_comma {
555                false
556            } else if self.trailing_comma {
557                true
558            } else {
559                base.trailing_comma
560            },
561        })
562    }
563}
564
565/// Why a run could not settle on the options to format an input with.
566enum FmtOptionsError {
567    /// The project's `bynk.toml` `[fmt]` section is unusable.
568    Manifest(PathBuf, bynk_fmt::ConfigError),
569    /// #1665: the project's `bynk.toml` has a table (or a `[project]`/`[lsp]`
570    /// key) it doesn't define.
571    ManifestTables(PathBuf, project::ProjectPathsError),
572    /// The flags this run passed contradict each other or the manifest.
573    Args(String),
574}
575
576impl std::fmt::Display for FmtOptionsError {
577    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578        match self {
579            // Named, because the manifest governing a file is not necessarily
580            // the one in the working directory.
581            Self::Manifest(path, e) => write!(f, "{}: {e}", path.display()),
582            Self::ManifestTables(path, e) => write!(f, "{}: {e}", path.display()),
583            Self::Args(e) => write!(f, "{e}"),
584        }
585    }
586}
587
588/// Per-directory memo of the `[fmt]` section governing an input.
589///
590/// A run typically formats many files under one project (`fmt src/*.bynk`), so
591/// the upward walk for `bynk.toml` and its parse happen once per starting
592/// directory rather than once per file.
593struct ManifestCache {
594    /// `--no-config`: skip discovery entirely and hand back the spec defaults.
595    disabled: bool,
596    by_dir: std::collections::HashMap<PathBuf, FormatOptions>,
597}
598
599impl ManifestCache {
600    fn new(disabled: bool) -> Self {
601        Self {
602            disabled,
603            by_dir: std::collections::HashMap::new(),
604        }
605    }
606
607    /// The options `input` inherits from its project, before this run's flags.
608    fn options_for(&mut self, input: &Path) -> Result<FormatOptions, FmtOptionsError> {
609        if self.disabled {
610            return Ok(FormatOptions::default());
611        }
612        // Stdin carries no path to search from; the working directory is the
613        // only project context a pipe has.
614        let start: PathBuf = if input.as_os_str() == "-" {
615            PathBuf::from(".")
616        } else {
617            match input.parent() {
618                // A bare `x.bynk` has an empty parent, which is the cwd.
619                Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
620                _ => PathBuf::from("."),
621            }
622        };
623        // Absolutise before walking. A relative start has no ancestors to walk
624        // *through*: `Path::new("src").parent()` is `""` and `""`'s parent is
625        // `None`, so the search stops at the working directory and never
626        // reaches the project root above it. Run from `src/`, `fmt calc.bynk`
627        // therefore missed the very manifest `fmt src/calc.bynk` from the root
628        // found — silently formatting to the canonical style, and (under
629        // `--check`) gating CI on a style the editor never produces. Joining
630        // onto the cwd also collapses `src` and `/abs/src` to one cache key.
631        //
632        // `current_dir()` rather than `std::path::absolute`: same result here,
633        // and it does not raise the crate's MSRV.
634        let start = std::env::current_dir()
635            .map(|cwd| cwd.join(&start))
636            .unwrap_or(start);
637        if let Some(hit) = self.by_dir.get(&start) {
638            return Ok(*hit);
639        }
640        let opts = match bynk_fmt::find_manifest(&start) {
641            None => FormatOptions::default(),
642            Some(manifest) => {
643                let text = std::fs::read_to_string(&manifest).map_err(|e| {
644                    FmtOptionsError::Manifest(
645                        manifest.clone(),
646                        bynk_fmt::ConfigError::Read(e.to_string()),
647                    )
648                })?;
649                // `[fmt]` first: its reader owns the detailed TOML parse error
650                // and its own keys. #1665: then the manifest's table set.
651                let config = bynk_fmt::FmtConfig::from_manifest_str(&text)
652                    .map_err(|e| FmtOptionsError::Manifest(manifest.clone(), e))?;
653                project::check_manifest_str(&text)
654                    .map_err(|e| FmtOptionsError::ManifestTables(manifest, e))?;
655                config.apply(FormatOptions::default())
656            }
657        };
658        self.by_dir.insert(start, opts);
659        Ok(opts)
660    }
661}
662
663/// The `fmt` command body shared by `bynkc fmt` and `bynk fmt`: each input is
664/// formatted and rewritten only when it changes; `--check` reports
665/// non-canonical files without writing; `-` reads stdin and writes the
666/// formatted result to stdout. `prog` prefixes messages (`bynk fmt: …`).
667pub fn run_fmt(prog: &str, args: &FmtArgs) -> ExitCode {
668    let check = args.check;
669    if args.inputs.is_empty() {
670        eprintln!("{prog} fmt: no input files (pass file or directory paths, or `-` for stdin)");
671        return ExitCode::FAILURE;
672    }
673    let inputs = match expand_fmt_inputs(&args.inputs) {
674        Ok(inputs) => inputs,
675        Err(e) => {
676            eprintln!("{prog} fmt: {e}");
677            return ExitCode::FAILURE;
678        }
679    };
680    let inputs = &inputs;
681    // Resolve *every* input's options before formatting any of them. Options
682    // are per-input — `[fmt]` belongs to the project the file sits in, so a
683    // path outside the current project obeys that project's style — but a
684    // manifest error found on the third input must not land after the first two
685    // have already been rewritten. Configuration is a whole-run precondition:
686    // it fails before a byte is written, or not at all.
687    let mut manifests = ManifestCache::new(args.no_config);
688    let mut resolved: Vec<FormatOptions> = Vec::with_capacity(inputs.len());
689    for input in inputs {
690        match manifests
691            .options_for(input)
692            .and_then(|base| args.apply_to(base).map_err(FmtOptionsError::Args))
693        {
694            Ok(opts) => resolved.push(opts),
695            Err(e) => {
696                eprintln!("{prog} fmt: {e}");
697                return ExitCode::FAILURE;
698            }
699        }
700    }
701
702    let mut had_diff = false;
703    let mut had_error = false;
704    for (input, opts) in inputs.iter().zip(resolved) {
705        if input.as_os_str() == "-" {
706            use std::io::Read;
707            let mut source = String::new();
708            if let Err(e) = std::io::stdin().read_to_string(&mut source) {
709                eprintln!("{prog} fmt: read from stdin: {e}");
710                return ExitCode::FAILURE;
711            }
712            // #1763: compared, and errors rendered, modulo line endings.
713            let source = bynk_fmt::normalize_line_endings(&source);
714            match format_source(&source, &opts) {
715                Ok(formatted) => {
716                    if check {
717                        // `--check` on stdin must not print the formatted text
718                        // (it would pollute a CI log) and must report a diff the
719                        // same way the file path does — a `generator | bynk fmt
720                        // --check -` gate is otherwise dead, passing green on
721                        // non-canonical input.
722                        if formatted != source {
723                            eprintln!("{prog} fmt: <stdin> is not canonically formatted");
724                            had_diff = true;
725                        }
726                    } else {
727                        print!("{formatted}");
728                    }
729                }
730                Err(e) => {
731                    bynk_render::print_errors(&e.errors, &source, "<stdin>");
732                    return ExitCode::FAILURE;
733                }
734            }
735            continue;
736        }
737        let raw = match std::fs::read_to_string(input) {
738            Ok(s) => s,
739            Err(e) => {
740                eprintln!("{prog} fmt: read `{}`: {e}", shown(input));
741                had_error = true;
742                continue;
743            }
744        };
745        // #1763: line endings are not a formatting difference. A CRLF copy of a
746        // canonical file passes `--check` and isn't rewritten; a file that does
747        // need formatting is written in the LF canonical form. Errors render
748        // against the normalised text, whose spans they carry.
749        let source = bynk_fmt::normalize_line_endings(&raw);
750        let filename = shown(input);
751        match format_source(&source, &opts) {
752            Ok(formatted) => {
753                if check {
754                    if formatted != source {
755                        eprintln!("{prog} fmt: {} is not canonically formatted", filename);
756                        had_diff = true;
757                    }
758                } else if formatted != source
759                    && let Err(e) = atomic_write(input, &formatted)
760                {
761                    eprintln!("{prog} fmt: write `{}`: {e}", filename);
762                    had_error = true;
763                }
764            }
765            Err(e) => {
766                bynk_render::print_errors(&e.errors, &source, &filename);
767                had_error = true;
768            }
769        }
770    }
771    if had_error || (check && had_diff) {
772        ExitCode::FAILURE
773    } else {
774        ExitCode::SUCCESS
775    }
776}
777
778/// #1753: expand `fmt`'s arguments to the files it formats. A directory
779/// becomes the `.bynk` files [`project_source_files`] finds in it, which is what
780/// `check` and `test` read for the same argument: a project root's `[paths]`
781/// `include` trees minus `exclude`, or a plain directory walked recursively,
782/// hidden directories skipped either way. A file or `-` passes through. A file
783/// named more than once, directly or through a directory, is formatted once.
784///
785/// A directory with no `.bynk` files is an error rather than an empty run, so
786/// a mistyped path cannot pass `--check`. Expansion happens before any file is
787/// read, so its errors, like a manifest error, land before anything is written.
788fn expand_fmt_inputs(inputs: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
789    let mut out = Vec::new();
790    let mut seen = std::collections::HashSet::new();
791    for input in inputs {
792        let files = if input.as_os_str() != "-" && input.is_dir() {
793            let files =
794                project_source_files(input).map_err(|e| format!("`{}`: {e}", input.display()))?;
795            if files.is_empty() {
796                return Err(format!("no `.bynk` files under `{}`", input.display()));
797            }
798            files
799        } else {
800            vec![input.clone()]
801        };
802        for file in files {
803            let key = std::fs::canonicalize(&file).unwrap_or_else(|_| file.clone());
804            if seen.insert(key) {
805                out.push(file);
806            }
807        }
808    }
809    Ok(out)
810}
811
812/// Write `contents` to `path` atomically: the bytes land in a sibling temp
813/// file that is then `rename`d over `path`. A plain `std::fs::write` truncates
814/// the destination *before* writing, so an ENOSPC, a signal, or a crash
815/// mid-write leaves the file truncated or empty — and for `fmt`, whose only
816/// copy of the original is the in-memory `source`, that original is then gone.
817/// The rename is atomic on POSIX and Windows, so a reader sees either the whole
818/// old file or the whole new one, never a half-written mix.
819///
820/// The temp file is a sibling (same directory) so the rename stays within one
821/// filesystem — a cross-device rename would fail with `EXDEV`. Its name carries
822/// the PID and a per-process counter so concurrent `fmt` runs, or two files in
823/// one run, never collide, and it is opened with `create_new` (`O_EXCL`): a
824/// pre-existing path — a stale temp from an earlier crashed run, or a symlink a
825/// local actor pre-planted to redirect the formatted bytes — is refused rather
826/// than opened, and we bump the counter and retry. On any failure the temp file
827/// is removed so a botched write leaves no litter beside the untouched original.
828///
829/// The `rename` swaps in a fresh inode, so if `path` was a symlink or a
830/// hardlink the formatted file replaces the link rather than being written
831/// through it (the old `std::fs::write` wrote through). Uncommon for source
832/// files, and the atomicity is worth it.
833fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
834    use std::io::Write as _;
835    use std::sync::atomic::{AtomicU64, Ordering};
836
837    static COUNTER: AtomicU64 = AtomicU64::new(0);
838
839    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
840    let file_name = path
841        .file_name()
842        .map(|n| n.to_string_lossy().into_owned())
843        .unwrap_or_default();
844
845    // Open a fresh sibling temp file exclusively, bumping the counter past any
846    // name that is already taken (stale temp or planted symlink).
847    let (mut file, tmp) = loop {
848        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
849        let tmp_name = format!(".{file_name}.bynk-fmt.{}.{n}.tmp", std::process::id());
850        let tmp = match dir {
851            Some(d) => d.join(tmp_name),
852            None => PathBuf::from(tmp_name),
853        };
854        match std::fs::OpenOptions::new()
855            .write(true)
856            .create_new(true)
857            .open(&tmp)
858        {
859            Ok(f) => break (f, tmp),
860            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
861            Err(e) => return Err(e),
862        }
863    };
864
865    // Scope the write so the handle is flushed and closed before the rename.
866    // The rename replaces the destination inode, so carry the original file's
867    // permissions onto the temp file first — otherwise a formatted file would
868    // silently pick up the process umask's default mode (e.g. an executable or
869    // group-restricted source would lose its bits).
870    let write_result = (|| {
871        // Best-effort: a filesystem that cannot honour the mode must not fail
872        // the whole write.
873        if let Ok(meta) = std::fs::metadata(path) {
874            let _ = file.set_permissions(meta.permissions());
875        }
876        file.write_all(contents.as_bytes())?;
877        file.sync_all()
878    })();
879    if let Err(e) = write_result {
880        let _ = std::fs::remove_file(&tmp);
881        return Err(e);
882    }
883    if let Err(e) = std::fs::rename(&tmp, path) {
884        let _ = std::fs::remove_file(&tmp);
885        return Err(e);
886    }
887    Ok(())
888}
889
890/// The `check` command body shared by `bynkc check` and `bynk check`: a
891/// directory routes through [`project::check_project`] (finding #64 —
892/// non-bailing, so a structural error anywhere does not hide diagnostics
893/// elsewhere the way `compile_project`'s bail-fast `Mode::Build` would), a
894/// single file through [`bynk_emit::compile_with_warnings`]. `short` selects
895/// the one-line `--format short` rendering. `prog` prefixes messages
896/// (`bynk: …`).
897pub fn run_check(prog: &str, input: &Path, short: bool) -> ExitCode {
898    if input.is_dir() {
899        let options = match try_project_options(input) {
900            Ok(o) => o,
901            Err(e) => {
902                eprintln!("{prog}: {e}");
903                return ExitCode::FAILURE;
904            }
905        };
906        let check = project::check_project(&options);
907        let has_errors = check.has_errors();
908        if short {
909            print_project_check_short(&check);
910        } else {
911            print_project_check(&check);
912        }
913        if has_errors {
914            ExitCode::FAILURE
915        } else {
916            ExitCode::SUCCESS
917        }
918    } else {
919        let source = match std::fs::read_to_string(input) {
920            Ok(s) => s,
921            Err(e) => {
922                eprintln!("{prog}: could not read `{}`: {e}", shown(input));
923                return ExitCode::FAILURE;
924            }
925        };
926        let filename = shown(input);
927        match bynk_emit::compile_with_warnings(&source, &filename) {
928            Ok(compiled) => {
929                if !compiled.warnings.is_empty() {
930                    if short {
931                        bynk_render::print_errors_short(&compiled.warnings, &source, &filename);
932                    } else {
933                        bynk_render::print_errors(&compiled.warnings, &source, &filename);
934                    }
935                }
936                ExitCode::SUCCESS
937            }
938            Err(errors) => {
939                if short {
940                    bynk_render::print_errors_short(&errors, &source, &filename);
941                } else {
942                    bynk_render::print_errors(&errors, &source, &filename);
943                }
944                ExitCode::FAILURE
945            }
946        }
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use clap::Parser;
954
955    /// A minimal parser around [`FmtArgs`], so these assert what the real CLIs
956    /// parse rather than what a hand-built struct claims.
957    #[derive(clap::Parser, Debug)]
958    struct Harness {
959        #[command(flatten)]
960        args: FmtArgs,
961    }
962
963    fn parse(argv: &[&str]) -> FmtArgs {
964        let mut full = vec!["fmt"];
965        full.extend_from_slice(argv);
966        Harness::parse_from(full).args
967    }
968
969    /// The options a run resolves with no manifest in play.
970    fn resolve(argv: &[&str]) -> Result<FormatOptions, String> {
971        parse(argv).apply_to(FormatOptions::default())
972    }
973
974    /// A stand-in for what `bynk.toml` `[fmt]` resolved to.
975    fn manifest(toml: &str) -> FormatOptions {
976        bynk_fmt::FmtConfig::from_manifest_str(toml)
977            .expect("manifest parses")
978            .apply(FormatOptions::default())
979    }
980
981    #[test]
982    fn no_flags_is_the_canonical_style() {
983        let opts = resolve(&["a.bynk"]).expect("valid");
984        assert_eq!(opts, FormatOptions::default());
985    }
986
987    #[test]
988    fn spaces_without_a_width_falls_back_to_two() {
989        // The same fallback `bynk.toml`'s `[fmt] indent_width` uses, so the CLI
990        // and the language server land on the same style from the same words.
991        let opts = resolve(&["--indent", "spaces", "a.bynk"]).expect("valid");
992        assert_eq!(opts.indent, IndentStyle::Spaces(2));
993    }
994
995    #[test]
996    fn spaces_takes_the_given_width() {
997        let opts =
998            resolve(&["--indent", "spaces", "--indent-width", "4", "a.bynk"]).expect("valid");
999        assert_eq!(opts.indent, IndentStyle::Spaces(4));
1000    }
1001
1002    #[test]
1003    fn a_width_with_tabs_is_an_error_not_a_silent_no_op() {
1004        let err = resolve(&["--indent", "tab", "--indent-width", "4", "a.bynk"])
1005            .expect_err("a meaningless width must be reported");
1006        assert!(err.contains("--indent-width"), "{err}");
1007        assert!(err.contains("spaces"), "{err}");
1008    }
1009
1010    #[test]
1011    fn the_trailing_comma_pair_is_last_one_wins() {
1012        // `overrides_with` in both directions: a script may append either flag
1013        // to a shared argument list and have it win rather than conflict-error.
1014        assert!(
1015            resolve(&["--no-trailing-comma", "--trailing-comma", "a.bynk"])
1016                .expect("valid")
1017                .trailing_comma
1018        );
1019        assert!(
1020            !resolve(&["--trailing-comma", "--no-trailing-comma", "a.bynk"])
1021                .expect("valid")
1022                .trailing_comma
1023        );
1024    }
1025
1026    #[test]
1027    fn max_line_width_is_taken_verbatim_and_zero_is_refused() {
1028        assert_eq!(
1029            resolve(&["--max-line-width", "60", "a.bynk"])
1030                .expect("valid")
1031                .max_line_width,
1032            60
1033        );
1034        // A zero-column budget is a nonsense input; clap rejects it at parse
1035        // time rather than the formatter wrapping every construct maximally.
1036        assert!(
1037            Harness::try_parse_from(["fmt", "--max-line-width", "0", "a.bynk"]).is_err(),
1038            "`--max-line-width 0` must not parse"
1039        );
1040    }
1041
1042    // -- #972: the `bynk.toml` `[fmt]` layer beneath the flags --
1043
1044    #[test]
1045    fn an_unflagged_run_takes_the_manifest_whole() {
1046        let base = manifest(
1047            "[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\ntrailing_comma = false\n",
1048        );
1049        let opts = parse(&["a.bynk"]).apply_to(base).expect("valid");
1050        assert_eq!(opts.indent, IndentStyle::Spaces(4));
1051        assert_eq!(opts.max_line_width, 120);
1052        assert!(!opts.trailing_comma);
1053    }
1054
1055    #[test]
1056    fn a_flag_beats_the_manifest_field_it_names_and_no_other() {
1057        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\n");
1058        let opts = parse(&["--max-line-width", "80", "a.bynk"])
1059            .apply_to(base)
1060            .expect("valid");
1061        assert_eq!(opts.max_line_width, 80, "the flag wins where it speaks");
1062        assert_eq!(
1063            opts.indent,
1064            IndentStyle::Spaces(4),
1065            "and stays silent everywhere else"
1066        );
1067    }
1068
1069    #[test]
1070    fn an_absent_flag_does_not_reset_the_manifest_to_the_default() {
1071        // The regression a clap `default_value` would have caused: "the user
1072        // said 100" is indistinguishable from "the user said nothing", so a
1073        // project's 120 would be overwritten by a flag nobody passed.
1074        let base = manifest("[fmt]\nmax_line_width = 120\n");
1075        assert_eq!(
1076            parse(&["a.bynk"])
1077                .apply_to(base)
1078                .expect("valid")
1079                .max_line_width,
1080            120
1081        );
1082    }
1083
1084    #[test]
1085    fn indent_spaces_alone_keeps_the_manifest_width() {
1086        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
1087        let opts = parse(&["--indent", "spaces", "a.bynk"])
1088            .apply_to(base)
1089            .expect("valid");
1090        assert_eq!(opts.indent, IndentStyle::Spaces(4), "not reset to 2");
1091    }
1092
1093    #[test]
1094    fn indent_width_alone_applies_to_a_manifest_that_chose_spaces() {
1095        let base = manifest("[fmt]\nindent = \"spaces\"\n");
1096        let opts = parse(&["--indent-width", "8", "a.bynk"])
1097            .apply_to(base)
1098            .expect("valid");
1099        assert_eq!(opts.indent, IndentStyle::Spaces(8));
1100    }
1101
1102    #[test]
1103    fn indent_width_alone_is_refused_when_the_run_resolves_to_tabs() {
1104        // No `--indent`, and a manifest that says tabs (or none at all): the
1105        // width has nothing to apply to, and the message says so rather than
1106        // the flag vanishing.
1107        let err = parse(&["--indent-width", "8", "a.bynk"])
1108            .apply_to(manifest("[fmt]\nindent = \"tab\"\n"))
1109            .expect_err("refused");
1110        assert!(err.contains("resolves"), "{err}");
1111    }
1112
1113    #[test]
1114    fn an_explicit_tab_flag_overrides_a_manifest_choosing_spaces() {
1115        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
1116        let opts = parse(&["--indent", "tab", "a.bynk"])
1117            .apply_to(base)
1118            .expect("valid");
1119        assert_eq!(opts.indent, IndentStyle::Tab);
1120    }
1121
1122    #[test]
1123    fn trailing_comma_flag_overrides_a_manifest_that_turned_it_off() {
1124        let base = manifest("[fmt]\ntrailing_comma = false\n");
1125        assert!(
1126            parse(&["--trailing-comma", "a.bynk"])
1127                .apply_to(base)
1128                .expect("valid")
1129                .trailing_comma
1130        );
1131        // …and an unflagged run still honours the manifest.
1132        assert!(
1133            !parse(&["a.bynk"])
1134                .apply_to(base)
1135                .expect("valid")
1136                .trailing_comma
1137        );
1138    }
1139
1140    #[test]
1141    fn no_config_is_parsed_and_defaults_are_used_in_its_presence() {
1142        // `--no-config` is honoured by the manifest *lookup* (ManifestCache),
1143        // so here it is enough that it parses and leaves the flag layer alone.
1144        let args = parse(&["--no-config", "a.bynk"]);
1145        assert!(args.no_config);
1146        assert_eq!(
1147            args.apply_to(FormatOptions::default()).expect("valid"),
1148            FormatOptions::default()
1149        );
1150    }
1151
1152    /// A throwaway on-disk directory, removed on drop (including on panic) —
1153    /// mirrors `bynk-driver/tests/project_diagnostics.rs`'s own `Scratch`.
1154    struct Scratch(PathBuf);
1155    impl Drop for Scratch {
1156        fn drop(&mut self) {
1157            let _ = fs::remove_dir_all(&self.0);
1158        }
1159    }
1160
1161    fn scratch_dir(tag: &str) -> Scratch {
1162        let dir = std::env::temp_dir().join(format!(
1163            "bynk_1077_{tag}_{}_{:?}",
1164            std::process::id(),
1165            std::thread::current().id()
1166        ));
1167        let _ = fs::remove_dir_all(&dir);
1168        fs::create_dir_all(&dir).unwrap();
1169        Scratch(dir)
1170    }
1171
1172    /// #1077 review: `manifest_overlay` keys its entry exactly as
1173    /// `try_read_project_paths_with` looks it up — `root.join("bynk.toml")`,
1174    /// literal, no canonicalisation — and reads `bynk.toml`'s real content.
1175    /// This is what stops that lookup from falling through to `bynk-emit`'s
1176    /// own disk fallback; a mismatched key would silently degrade to the
1177    /// conventional layout instead of surfacing as a test failure here, so
1178    /// this asserts the map entry directly rather than only the end-to-end
1179    /// behaviour (which the integration test in `project_diagnostics.rs`
1180    /// covers).
1181    #[test]
1182    fn manifest_overlay_keys_and_reads_a_real_bynk_toml() {
1183        let dir = scratch_dir("manifest_overlay");
1184        let toml = "[paths]\ninclude = [\"lib\"]\n";
1185        fs::write(dir.0.join("bynk.toml"), toml).unwrap();
1186
1187        let overlay = manifest_overlay(&dir.0);
1188
1189        assert_eq!(
1190            overlay.get(&dir.0.join("bynk.toml")).map(String::as_str),
1191            Some(toml)
1192        );
1193    }
1194
1195    #[test]
1196    fn manifest_overlay_is_empty_with_no_bynk_toml() {
1197        let dir = scratch_dir("manifest_overlay_missing");
1198        assert!(manifest_overlay(&dir.0).is_empty());
1199    }
1200
1201    /// Review of #1084: `ProjectOptionsError::Paths` had no test anywhere in
1202    /// the repo, despite being the one arm where an overlay/disk divergence
1203    /// in the manifest read would actually be observable — everywhere else,
1204    /// `read_source`'s still-present disk fallback quietly reproduces the
1205    /// same result either way. This also re-pins that `?`'s automatic
1206    /// `From<ProjectPathsError>` conversion (not an explicit `map_err`) still
1207    /// reaches the caller correctly.
1208    #[test]
1209    fn try_project_options_surfaces_an_unknown_paths_key() {
1210        let dir = scratch_dir("try_project_options_unknown_key");
1211        fs::write(dir.0.join("bynk.toml"), "[paths]\ninculde = [\"src\"]\n").unwrap();
1212        fs::create_dir_all(dir.0.join("src")).unwrap();
1213        fs::write(dir.0.join("src/thing.bynk"), "context thing\n").unwrap();
1214
1215        let err = match try_project_options(&dir.0) {
1216            Err(e) => e,
1217            Ok(_) => panic!("an unrecognised [paths] key must be reported, not silently ignored"),
1218        };
1219        assert!(
1220            matches!(
1221                &err,
1222                ProjectOptionsError::Paths(ProjectPathsError::UnknownKey(k)) if k == "inculde"
1223            ),
1224            "expected Paths(UnknownKey(\"inculde\")), got: {err:?}"
1225        );
1226    }
1227
1228    /// #1665: an unknown table reaches the caller from the same strict path,
1229    /// even with a valid `[paths]`. Before, `[dependencies]` built cleanly.
1230    #[test]
1231    fn try_project_options_surfaces_an_unknown_table() {
1232        let dir = scratch_dir("try_project_options_unknown_table");
1233        fs::write(
1234            dir.0.join("bynk.toml"),
1235            "[paths]\ninclude = [\"src\"]\n\n[dependencies]\nacme-utils = \"1.2\"\n",
1236        )
1237        .unwrap();
1238        fs::create_dir_all(dir.0.join("src")).unwrap();
1239        fs::write(dir.0.join("src/thing.bynk"), "context thing\n").unwrap();
1240
1241        let err = match try_project_options(&dir.0) {
1242            Err(e) => e,
1243            Ok(_) => panic!("an unknown table must be reported, not silently ignored"),
1244        };
1245        assert!(
1246            matches!(
1247                &err,
1248                ProjectOptionsError::Paths(ProjectPathsError::UnknownTable(t)) if t == "dependencies"
1249            ),
1250            "expected Paths(UnknownTable(\"dependencies\")), got: {err:?}"
1251        );
1252        assert!(
1253            err.to_string().contains("not yet supported (#843)"),
1254            "{err}"
1255        );
1256    }
1257}
1258
1259#[cfg(test)]
1260mod display_path_tests {
1261    use super::display_path;
1262    use std::path::Path;
1263
1264    #[test]
1265    fn a_relative_root_is_joined_as_typed() {
1266        assert_eq!(
1267            display_path(Path::new("test/fixtures/x"), Path::new("src/a.bynk")),
1268            "test/fixtures/x/src/a.bynk"
1269        );
1270    }
1271
1272    /// `bynk check` defaults its input to `.`; that adds no `./`.
1273    #[test]
1274    fn a_dot_root_adds_no_prefix() {
1275        assert_eq!(
1276            display_path(Path::new("."), Path::new("src/a.bynk")),
1277            "src/a.bynk"
1278        );
1279        assert_eq!(
1280            display_path(Path::new(""), Path::new("./src/a.bynk")),
1281            "src/a.bynk"
1282        );
1283    }
1284
1285    /// `bynk dev` hands an absolute root; inside the cwd it shows relative.
1286    #[test]
1287    fn an_absolute_root_inside_the_cwd_is_shown_relative() {
1288        let cwd = std::env::current_dir().unwrap();
1289        assert_eq!(
1290            display_path(&cwd.join("proj"), Path::new("src/a.bynk")),
1291            "proj/src/a.bynk"
1292        );
1293        // A canonicalised root (on Windows, a verbatim `\\?\` path) too.
1294        let canonical = cwd.canonicalize().unwrap();
1295        assert_eq!(
1296            display_path(&canonical.join("proj"), Path::new("src/a.bynk")),
1297            "proj/src/a.bynk"
1298        );
1299    }
1300
1301    /// Outside the cwd, the path stays absolute, with `/` separators.
1302    #[test]
1303    fn an_absolute_root_outside_the_cwd_is_shown_absolute() {
1304        let cwd = std::env::current_dir().unwrap();
1305        let outside = cwd.parent().unwrap().join("bynk-display-path-elsewhere");
1306        let shown = display_path(&outside, Path::new("src/a.bynk"));
1307        assert!(
1308            shown.ends_with("bynk-display-path-elsewhere/src/a.bynk"),
1309            "{shown}"
1310        );
1311        assert!(
1312            !shown.contains('\\') && !shown.starts_with("//?/"),
1313            "{shown}"
1314        );
1315    }
1316
1317    /// On Windows, a verbatim root outside the cwd loses its `\\?\` prefix.
1318    #[cfg(windows)]
1319    #[test]
1320    fn a_verbatim_root_outside_the_cwd_loses_its_prefix() {
1321        assert_eq!(
1322            display_path(Path::new(r"\\?\C:\elsewhere"), Path::new("src/a.bynk")),
1323            "C:/elsewhere/src/a.bynk"
1324        );
1325    }
1326}