use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use bynk_emit::project::CompileOptions;
#[derive(Debug, PartialEq, Eq)]
pub enum OverlayError {
Malformed(String),
NoSources,
OutsideProject(PathBuf),
NotBynk(PathBuf),
}
impl fmt::Display for OverlayError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OverlayError::Malformed(e) => write!(
f,
"the sources document must be a JSON object of path to text: {e}"
),
OverlayError::NoSources => write!(f, "the project has no sources to overlay"),
OverlayError::OutsideProject(p) => {
write!(f, "`{}` is outside the project", p.display())
}
OverlayError::NotBynk(p) => write!(f, "`{}` is not a .bynk source", p.display()),
}
}
}
impl std::error::Error for OverlayError {}
pub fn parse_overlay(json: &str) -> Result<HashMap<PathBuf, String>, OverlayError> {
let map: HashMap<String, String> =
serde_json::from_str(json).map_err(|e| OverlayError::Malformed(e.to_string()))?;
Ok(map
.into_iter()
.map(|(k, v)| (PathBuf::from(k), v))
.collect())
}
pub fn apply_overlay(
options: CompileOptions,
root: &Path,
overlay: HashMap<PathBuf, String>,
) -> Result<CompileOptions, OverlayError> {
let mut sources = options.sources.clone().ok_or(OverlayError::NoSources)?;
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let by_canonical: HashMap<PathBuf, PathBuf> = sources
.keys()
.map(|k| (k.canonicalize().unwrap_or_else(|_| k.clone()), k.clone()))
.collect();
let mut replacements = Vec::new();
for (path, text) in overlay {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
if let Some(key) = by_canonical.get(&canonical) {
replacements.push((key.clone(), text));
continue;
}
let Ok(rel) = canonical.strip_prefix(&canonical_root) else {
return Err(OverlayError::OutsideProject(path));
};
if canonical.extension().and_then(|e| e.to_str()) != Some("bynk") {
return Err(OverlayError::NotBynk(path));
}
replacements.push((root.join(rel), text));
}
for (key, text) in replacements {
sources.insert(key, text);
}
Ok(options.sources(sources))
}
#[cfg(test)]
mod tests {
use super::*;
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn project(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!(
"bynk_driver_overlay_{tag}_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("bynk.toml"), "[project]\nname = \"p\"\n").unwrap();
std::fs::write(dir.join("src/a.bynk"), "context p.a\n").unwrap();
Scratch(dir.canonicalize().unwrap())
}
fn text_of(options: &CompileOptions, file: &Path) -> Option<String> {
let want = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
options
.sources
.as_ref()?
.iter()
.find(|(k, _)| k.canonicalize().unwrap_or_else(|_| (*k).clone()) == want)
.map(|(_, v)| v.clone())
}
#[test]
fn the_overlay_wins_over_disk() {
let dir = project("wins");
let options = crate::try_project_options(&dir.0).unwrap();
let a = dir.0.join("src/a.bynk");
let out = apply_overlay(
options,
&dir.0,
HashMap::from([(a.clone(), "UNSAVED".into())]),
)
.unwrap();
assert_eq!(text_of(&out, &a).as_deref(), Some("UNSAVED"));
let same = out
.sources
.as_ref()
.unwrap()
.keys()
.filter(|k| k.ends_with("src/a.bynk"))
.count();
assert_eq!(same, 1);
}
#[test]
fn a_new_bynk_file_inside_the_project_is_added() {
let dir = project("new");
let options = crate::try_project_options(&dir.0).unwrap();
let b = dir.0.join("src/b.bynk");
let out = apply_overlay(
options,
&dir.0,
HashMap::from([(b.clone(), "context p.b\n".into())]),
)
.unwrap();
assert_eq!(text_of(&out, &b).as_deref(), Some("context p.b\n"));
}
#[test]
fn a_path_outside_the_project_is_rejected() {
let dir = project("outside");
let options = crate::try_project_options(&dir.0).unwrap();
let elsewhere = std::env::temp_dir().join("elsewhere.bynk");
let err = apply_overlay(
options,
&dir.0,
HashMap::from([(elsewhere.clone(), String::new())]),
)
.err()
.unwrap();
assert_eq!(err, OverlayError::OutsideProject(elsewhere));
}
#[test]
fn a_non_bynk_path_is_rejected() {
let dir = project("notbynk");
let options = crate::try_project_options(&dir.0).unwrap();
let toml = dir.0.join("src/notes.txt");
let err = apply_overlay(
options,
&dir.0,
HashMap::from([(toml.clone(), String::new())]),
)
.err()
.unwrap();
assert_eq!(err, OverlayError::NotBynk(toml));
}
#[test]
fn a_malformed_document_is_reported() {
assert!(matches!(
parse_overlay("[1]"),
Err(OverlayError::Malformed(_))
));
assert_eq!(
parse_overlay(r#"{"/p/a.bynk":"x"}"#).unwrap(),
HashMap::from([(PathBuf::from("/p/a.bynk"), "x".to_string())])
);
}
}