bynk_driver/
source_overlay.rs1use std::collections::HashMap;
18use std::fmt;
19use std::path::{Path, PathBuf};
20
21use bynk_emit::project::CompileOptions;
22
23#[derive(Debug, PartialEq, Eq)]
25pub enum OverlayError {
26 Malformed(String),
28 NoSources,
30 OutsideProject(PathBuf),
32 NotBynk(PathBuf),
34}
35
36impl fmt::Display for OverlayError {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 OverlayError::Malformed(e) => write!(
40 f,
41 "the sources document must be a JSON object of path to text: {e}"
42 ),
43 OverlayError::NoSources => write!(f, "the project has no sources to overlay"),
44 OverlayError::OutsideProject(p) => {
45 write!(f, "`{}` is outside the project", p.display())
46 }
47 OverlayError::NotBynk(p) => write!(f, "`{}` is not a .bynk source", p.display()),
48 }
49 }
50}
51
52impl std::error::Error for OverlayError {}
53
54pub fn parse_overlay(json: &str) -> Result<HashMap<PathBuf, String>, OverlayError> {
56 let map: HashMap<String, String> =
57 serde_json::from_str(json).map_err(|e| OverlayError::Malformed(e.to_string()))?;
58 Ok(map
59 .into_iter()
60 .map(|(k, v)| (PathBuf::from(k), v))
61 .collect())
62}
63
64pub fn apply_overlay(
68 options: CompileOptions,
69 root: &Path,
70 overlay: HashMap<PathBuf, String>,
71) -> Result<CompileOptions, OverlayError> {
72 let mut sources = options.sources.clone().ok_or(OverlayError::NoSources)?;
73 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
74 let by_canonical: HashMap<PathBuf, PathBuf> = sources
77 .keys()
78 .map(|k| (k.canonicalize().unwrap_or_else(|_| k.clone()), k.clone()))
79 .collect();
80 let mut replacements = Vec::new();
81 for (path, text) in overlay {
82 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
83 if let Some(key) = by_canonical.get(&canonical) {
84 replacements.push((key.clone(), text));
85 continue;
86 }
87 let Ok(rel) = canonical.strip_prefix(&canonical_root) else {
88 return Err(OverlayError::OutsideProject(path));
89 };
90 if canonical.extension().and_then(|e| e.to_str()) != Some("bynk") {
91 return Err(OverlayError::NotBynk(path));
92 }
93 replacements.push((root.join(rel), text));
94 }
95 for (key, text) in replacements {
96 sources.insert(key, text);
97 }
98 Ok(options.sources(sources))
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 struct Scratch(PathBuf);
106 impl Drop for Scratch {
107 fn drop(&mut self) {
108 let _ = std::fs::remove_dir_all(&self.0);
109 }
110 }
111
112 fn project(tag: &str) -> Scratch {
113 let dir = std::env::temp_dir().join(format!(
114 "bynk_driver_overlay_{tag}_{}_{:?}",
115 std::process::id(),
116 std::thread::current().id()
117 ));
118 let _ = std::fs::remove_dir_all(&dir);
119 std::fs::create_dir_all(dir.join("src")).unwrap();
120 std::fs::write(dir.join("bynk.toml"), "[project]\nname = \"p\"\n").unwrap();
121 std::fs::write(dir.join("src/a.bynk"), "context p.a\n").unwrap();
122 Scratch(dir.canonicalize().unwrap())
123 }
124
125 fn text_of(options: &CompileOptions, file: &Path) -> Option<String> {
126 let want = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
127 options
128 .sources
129 .as_ref()?
130 .iter()
131 .find(|(k, _)| k.canonicalize().unwrap_or_else(|_| (*k).clone()) == want)
132 .map(|(_, v)| v.clone())
133 }
134
135 #[test]
136 fn the_overlay_wins_over_disk() {
137 let dir = project("wins");
138 let options = crate::try_project_options(&dir.0).unwrap();
139 let a = dir.0.join("src/a.bynk");
140 let out = apply_overlay(
141 options,
142 &dir.0,
143 HashMap::from([(a.clone(), "UNSAVED".into())]),
144 )
145 .unwrap();
146 assert_eq!(text_of(&out, &a).as_deref(), Some("UNSAVED"));
147 let same = out
149 .sources
150 .as_ref()
151 .unwrap()
152 .keys()
153 .filter(|k| k.ends_with("src/a.bynk"))
154 .count();
155 assert_eq!(same, 1);
156 }
157
158 #[test]
159 fn a_new_bynk_file_inside_the_project_is_added() {
160 let dir = project("new");
161 let options = crate::try_project_options(&dir.0).unwrap();
162 let b = dir.0.join("src/b.bynk");
163 let out = apply_overlay(
164 options,
165 &dir.0,
166 HashMap::from([(b.clone(), "context p.b\n".into())]),
167 )
168 .unwrap();
169 assert_eq!(text_of(&out, &b).as_deref(), Some("context p.b\n"));
170 }
171
172 #[test]
173 fn a_path_outside_the_project_is_rejected() {
174 let dir = project("outside");
175 let options = crate::try_project_options(&dir.0).unwrap();
176 let elsewhere = std::env::temp_dir().join("elsewhere.bynk");
177 let err = apply_overlay(
178 options,
179 &dir.0,
180 HashMap::from([(elsewhere.clone(), String::new())]),
181 )
182 .err()
183 .unwrap();
184 assert_eq!(err, OverlayError::OutsideProject(elsewhere));
185 }
186
187 #[test]
188 fn a_non_bynk_path_is_rejected() {
189 let dir = project("notbynk");
190 let options = crate::try_project_options(&dir.0).unwrap();
191 let toml = dir.0.join("src/notes.txt");
192 let err = apply_overlay(
193 options,
194 &dir.0,
195 HashMap::from([(toml.clone(), String::new())]),
196 )
197 .err()
198 .unwrap();
199 assert_eq!(err, OverlayError::NotBynk(toml));
200 }
201
202 #[test]
203 fn a_malformed_document_is_reported() {
204 assert!(matches!(
205 parse_overlay("[1]"),
206 Err(OverlayError::Malformed(_))
207 ));
208 assert_eq!(
209 parse_overlay(r#"{"/p/a.bynk":"x"}"#).unwrap(),
210 HashMap::from([(PathBuf::from("/p/a.bynk"), "x".to_string())])
211 );
212 }
213}