1use std::error::Error;
2use std::fmt;
3use std::io::Write;
4use std::path::Path;
5
6use jsonc_parser::cst::{CstInputValue, CstRootNode};
7use rustc_hash::FxHashSet;
8use tempfile::NamedTempFile;
9use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value};
10
11use crate::IgnoreExportRule;
12
13#[derive(Debug)]
14pub enum ConfigWriteError {
15 Io(std::io::Error),
16 JsonParse(jsonc_parser::errors::ParseError),
17 TomlParse(toml_edit::TomlError),
18 InvalidShape(String),
19}
20
21impl fmt::Display for ConfigWriteError {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 Self::Io(e) => write!(f, "{e}"),
25 Self::JsonParse(e) => write!(f, "{e}"),
26 Self::TomlParse(e) => write!(f, "{e}"),
27 Self::InvalidShape(msg) => f.write_str(msg),
28 }
29 }
30}
31
32impl Error for ConfigWriteError {
33 fn source(&self) -> Option<&(dyn Error + 'static)> {
34 match self {
35 Self::Io(e) => Some(e),
36 Self::JsonParse(e) => Some(e),
37 Self::TomlParse(e) => Some(e),
38 Self::InvalidShape(_) => None,
39 }
40 }
41}
42
43impl From<std::io::Error> for ConfigWriteError {
44 fn from(value: std::io::Error) -> Self {
45 Self::Io(value)
46 }
47}
48
49pub type ConfigWriteResult<T> = Result<T, ConfigWriteError>;
50
51pub fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
58 let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
59 let dir = resolved.parent().unwrap_or_else(|| Path::new("."));
60 let mut tmp = NamedTempFile::new_in(dir)?;
61 tmp.write_all(content)?;
62 tmp.as_file().sync_all()?;
63 tmp.persist(&resolved).map_err(|e| e.error)?;
64 Ok(())
65}
66
67pub fn add_ignore_exports_rule(path: &Path, entries: &[IgnoreExportRule]) -> ConfigWriteResult<()> {
72 if entries.is_empty() {
73 return Ok(());
74 }
75 let content = std::fs::read_to_string(path)?;
76 let rendered = add_ignore_exports_rule_to_string(path, &content, entries)?;
77 atomic_write(path, rendered.as_bytes())?;
78 Ok(())
79}
80
81pub fn add_ignore_exports_rule_to_string(
89 path: &Path,
90 content: &str,
91 entries: &[IgnoreExportRule],
92) -> ConfigWriteResult<String> {
93 let had_bom = content.starts_with(BOM);
94 let body = content.strip_prefix(BOM).unwrap_or(content);
95 let config_dir = path.parent().unwrap_or_else(|| Path::new(""));
96 let rendered = if is_json_config(path) {
97 append_json_ignore_exports(body, entries, config_dir)?
98 } else {
99 append_toml_ignore_exports(body, entries, config_dir)?
100 };
101 let with_endings = preserve_line_endings(&rendered, body);
102 Ok(if had_bom {
103 let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
104 out.push(BOM);
105 out.push_str(&with_endings);
106 out
107 } else {
108 with_endings
109 })
110}
111
112const BOM: char = '\u{FEFF}';
113
114fn is_json_config(path: &Path) -> bool {
115 matches!(
116 path.extension().and_then(|ext| ext.to_str()),
117 Some("json" | "jsonc")
118 )
119}
120
121fn append_json_ignore_exports(
122 content: &str,
123 entries: &[IgnoreExportRule],
124 config_dir: &Path,
125) -> ConfigWriteResult<String> {
126 let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
127 .map_err(ConfigWriteError::JsonParse)?;
128 let object = root.object_value_or_create().ok_or_else(|| {
129 ConfigWriteError::InvalidShape("fallow config root must be an object".into())
130 })?;
131 let array = object
132 .array_value_or_create("ignoreExports")
133 .ok_or_else(|| {
134 ConfigWriteError::InvalidShape("ignoreExports must be an array in fallow config".into())
135 })?;
136
137 let mut seen = FxHashSet::default();
138 for element in array.elements() {
139 if let Some(file) = element.to_serde_value().and_then(|value| {
140 value
141 .get("file")
142 .and_then(serde_json::Value::as_str)
143 .map(str::to_owned)
144 }) {
145 record_existing_file(&mut seen, &file, config_dir);
146 }
147 }
148
149 for entry in entries {
150 if seen.insert(entry.file.clone()) {
151 array.append(CstInputValue::Object(vec![
152 ("file".to_owned(), CstInputValue::String(entry.file.clone())),
153 (
154 "exports".to_owned(),
155 CstInputValue::Array(
156 entry
157 .exports
158 .iter()
159 .cloned()
160 .map(CstInputValue::String)
161 .collect(),
162 ),
163 ),
164 ]));
165 }
166 }
167 Ok(root.to_string())
168}
169
170fn append_toml_ignore_exports(
171 content: &str,
172 entries: &[IgnoreExportRule],
173 config_dir: &Path,
174) -> ConfigWriteResult<String> {
175 let mut doc = content
176 .parse::<DocumentMut>()
177 .map_err(ConfigWriteError::TomlParse)?;
178 match doc
179 .as_table_mut()
180 .entry("ignoreExports")
181 .or_insert(Item::None)
182 {
183 Item::None => {
184 let mut tables = ArrayOfTables::new();
185 let mut seen = FxHashSet::default();
186 append_to_array_of_tables(&mut tables, entries, &mut seen);
187 doc.as_table_mut()
188 .insert("ignoreExports", Item::ArrayOfTables(tables));
189 }
190 Item::ArrayOfTables(tables) => {
191 let mut seen = files_from_array_of_tables(tables, config_dir);
192 append_to_array_of_tables(tables, entries, &mut seen);
193 }
194 Item::Value(Value::Array(array)) => {
195 let mut seen = files_from_inline_array(array, config_dir);
196 append_to_inline_array(array, entries, &mut seen);
197 }
198 _ => {
199 return Err(ConfigWriteError::InvalidShape(
200 "ignoreExports must be an array of tables or inline array in fallow config".into(),
201 ));
202 }
203 }
204 Ok(doc.to_string())
205}
206
207fn files_from_array_of_tables(tables: &ArrayOfTables, config_dir: &Path) -> FxHashSet<String> {
208 let mut seen = FxHashSet::default();
209 for table in tables {
210 if let Some(file) = table.get("file").and_then(Item::as_str) {
211 record_existing_file(&mut seen, file, config_dir);
212 }
213 }
214 seen
215}
216
217fn append_to_array_of_tables(
218 tables: &mut ArrayOfTables,
219 entries: &[IgnoreExportRule],
220 seen: &mut FxHashSet<String>,
221) {
222 for entry in entries {
223 if seen.insert(entry.file.clone()) {
224 tables.push(toml_ignore_export_table(entry));
225 }
226 }
227}
228
229fn toml_ignore_export_table(entry: &IgnoreExportRule) -> Table {
230 let mut table = Table::new();
231 table.insert("file", toml_edit::value(entry.file.clone()));
232 table.insert("exports", Item::Value(Value::Array(exports_array(entry))));
233 table
234}
235
236fn files_from_inline_array(array: &Array, config_dir: &Path) -> FxHashSet<String> {
237 let mut seen = FxHashSet::default();
238 for value in array {
239 if let Some(file) = value
240 .as_inline_table()
241 .and_then(|table| table.get("file"))
242 .and_then(Value::as_str)
243 {
244 record_existing_file(&mut seen, file, config_dir);
245 }
246 }
247 seen
248}
249
250fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
257 seen.insert(file.to_owned());
258 let path = Path::new(file);
259 if path.is_absolute()
260 && let Ok(relative) = path.strip_prefix(config_dir)
261 {
262 seen.insert(relative.to_string_lossy().replace('\\', "/"));
263 }
264}
265
266fn append_to_inline_array(
267 array: &mut Array,
268 entries: &[IgnoreExportRule],
269 seen: &mut FxHashSet<String>,
270) {
271 for entry in entries {
272 if seen.insert(entry.file.clone()) {
273 array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
274 }
275 }
276}
277
278fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
279 let mut table = InlineTable::new();
280 table.insert("file", Value::from(entry.file.clone()));
281 table.insert("exports", Value::Array(exports_array(entry)));
282 table
283}
284
285fn exports_array(entry: &IgnoreExportRule) -> Array {
286 let mut exports = Array::new();
287 for export in &entry.exports {
288 exports.push(export.as_str());
289 }
290 exports
291}
292
293fn preserve_line_endings(rendered: &str, original: &str) -> String {
294 if original.contains("\r\n") {
295 rendered.replace("\r\n", "\n").replace('\n', "\r\n")
296 } else {
297 rendered.to_owned()
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 fn rule(file: &str) -> IgnoreExportRule {
306 IgnoreExportRule {
307 file: file.to_owned(),
308 exports: vec!["*".to_owned()],
309 }
310 }
311
312 #[test]
313 fn appends_json_ignore_exports() {
314 let output = add_ignore_exports_rule_to_string(
315 Path::new(".fallowrc.json"),
316 "{\n}\n",
317 &[rule("src/index.ts")],
318 )
319 .unwrap();
320 assert!(output.contains("\"ignoreExports\": ["));
321 assert!(output.contains("\"file\": \"src/index.ts\""));
322 assert!(output.ends_with('\n'));
323 }
324
325 #[test]
326 fn appends_jsonc_preserving_comments() {
327 let input = "{\n // keep this\n \"rules\": {}\n}\n";
328 let output = add_ignore_exports_rule_to_string(
329 Path::new(".fallowrc.jsonc"),
330 input,
331 &[rule("src/a.ts")],
332 )
333 .unwrap();
334 assert!(output.contains("// keep this"));
335 assert!(output.contains("\"rules\": {}"));
336 assert!(output.contains("\"file\": \"src/a.ts\""));
337 }
338
339 #[test]
340 fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
341 let input = "{\n \"ignoreExports\": [\n { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n ],\n \"rules\": {}\n}\n";
342 let output = add_ignore_exports_rule_to_string(
343 Path::new(".fallowrc.json"),
344 input,
345 &[rule("src/a.ts"), rule("src/b.ts")],
346 )
347 .unwrap();
348 assert_eq!(output.matches("\"file\": \"src/a.ts\"").count(), 1);
349 assert!(output.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
350 assert!(output.contains("\"rules\": {}"));
351 }
352
353 #[test]
354 fn appends_toml_ignore_exports() {
355 let output = add_ignore_exports_rule_to_string(
356 Path::new("fallow.toml"),
357 "production = true\n",
358 &[rule("src/index.ts")],
359 )
360 .unwrap();
361 assert!(output.contains("production = true"));
362 assert!(output.contains("[[ignoreExports]]"));
363 assert!(output.contains("file = \"src/index.ts\""));
364 assert!(output.contains("exports = [\"*\"]"));
365 }
366
367 #[test]
368 fn appends_dot_fallow_toml_ignore_exports() {
369 let output = add_ignore_exports_rule_to_string(
370 Path::new(".fallow.toml"),
371 "",
372 &[rule("src/index.ts")],
373 )
374 .unwrap();
375 assert!(output.contains("[[ignoreExports]]"));
376 assert!(output.contains("file = \"src/index.ts\""));
377 }
378
379 #[test]
380 fn merges_existing_toml_ignore_exports() {
381 let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
382 let output = add_ignore_exports_rule_to_string(
383 Path::new("fallow.toml"),
384 input,
385 &[rule("src/a.ts"), rule("src/b.ts")],
386 )
387 .unwrap();
388 assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
389 assert!(output.contains("file = \"src/b.ts\""));
390 }
391
392 #[test]
393 fn preserves_crlf_line_endings() {
394 let input = "{\r\n \"rules\": {}\r\n}\r\n";
395 let output = add_ignore_exports_rule_to_string(
396 Path::new(".fallowrc.json"),
397 input,
398 &[rule("src/a.ts")],
399 )
400 .unwrap();
401 assert!(output.contains("\r\n"));
402 assert!(!output.contains("\r\r"));
403 assert!(!output.replace("\r\n", "").contains('\n'));
404 }
405
406 #[test]
407 fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
408 let input = "production = true\r\n";
409 let output =
410 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
411 .unwrap();
412 assert!(output.contains("\r\n"));
413 assert!(!output.contains("\r\r"));
414 assert!(!output.replace("\r\n", "").contains('\n'));
415 }
416
417 #[test]
418 fn preserves_utf8_bom_on_json_config() {
419 let input = "\u{FEFF}{\n \"rules\": {}\n}\n";
420 let output = add_ignore_exports_rule_to_string(
421 Path::new(".fallowrc.json"),
422 input,
423 &[rule("src/a.ts")],
424 )
425 .unwrap();
426 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
427 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
428 assert!(output.contains("\"file\": \"src/a.ts\""));
429 }
430
431 #[test]
432 fn preserves_utf8_bom_on_toml_config() {
433 let input = "\u{FEFF}production = true\n";
434 let output =
435 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
436 .unwrap();
437 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
438 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
439 assert!(output.contains("[[ignoreExports]]"));
440 }
441
442 #[test]
443 fn no_bom_added_when_input_had_none() {
444 let input = "{\n}\n";
445 let output = add_ignore_exports_rule_to_string(
446 Path::new(".fallowrc.json"),
447 input,
448 &[rule("src/a.ts")],
449 )
450 .unwrap();
451 assert!(!output.starts_with('\u{FEFF}'));
452 }
453
454 #[test]
455 fn dedupes_existing_absolute_paths_against_relative_emissions() {
456 let config_dir = Path::new("/project");
457 let config_path = config_dir.join(".fallowrc.json");
458 let input = "{\n \"ignoreExports\": [\n { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n ]\n}\n";
459 let output =
460 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
461 assert_eq!(
462 output.matches("\"src/a.ts\"").count(),
463 0,
464 "writer must not add a relative duplicate of an existing absolute entry"
465 );
466 assert_eq!(
467 output.matches("\"/project/src/a.ts\"").count(),
468 1,
469 "existing absolute entry must remain"
470 );
471 }
472
473 #[test]
474 fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
475 let config_dir = Path::new("/project");
476 let config_path = config_dir.join("fallow.toml");
477 let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
478 let output =
479 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
480 assert_eq!(
481 output.matches("file = \"src/a.ts\"").count(),
482 0,
483 "writer must not add a relative duplicate of an existing absolute TOML entry"
484 );
485 assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
486 }
487}