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
81fn add_ignore_exports_rule_to_string(
82 path: &Path,
83 content: &str,
84 entries: &[IgnoreExportRule],
85) -> ConfigWriteResult<String> {
86 let had_bom = content.starts_with(BOM);
87 let body = content.strip_prefix(BOM).unwrap_or(content);
88 let config_dir = path.parent().unwrap_or_else(|| Path::new(""));
89 let rendered = if is_json_config(path) {
90 append_json_ignore_exports(body, entries, config_dir)?
91 } else {
92 append_toml_ignore_exports(body, entries, config_dir)?
93 };
94 let with_endings = preserve_line_endings(&rendered, body);
95 Ok(if had_bom {
96 let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
97 out.push(BOM);
98 out.push_str(&with_endings);
99 out
100 } else {
101 with_endings
102 })
103}
104
105const BOM: char = '\u{FEFF}';
106
107fn is_json_config(path: &Path) -> bool {
108 matches!(
109 path.extension().and_then(|ext| ext.to_str()),
110 Some("json" | "jsonc")
111 )
112}
113
114fn append_json_ignore_exports(
115 content: &str,
116 entries: &[IgnoreExportRule],
117 config_dir: &Path,
118) -> ConfigWriteResult<String> {
119 let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
120 .map_err(ConfigWriteError::JsonParse)?;
121 let object = root.object_value_or_create().ok_or_else(|| {
122 ConfigWriteError::InvalidShape("fallow config root must be an object".into())
123 })?;
124 let array = object
125 .array_value_or_create("ignoreExports")
126 .ok_or_else(|| {
127 ConfigWriteError::InvalidShape("ignoreExports must be an array in fallow config".into())
128 })?;
129
130 let mut seen = FxHashSet::default();
131 for element in array.elements() {
132 if let Some(file) = element.to_serde_value().and_then(|value| {
133 value
134 .get("file")
135 .and_then(serde_json::Value::as_str)
136 .map(str::to_owned)
137 }) {
138 record_existing_file(&mut seen, &file, config_dir);
139 }
140 }
141
142 for entry in entries {
143 if seen.insert(entry.file.clone()) {
144 array.append(CstInputValue::Object(vec![
145 ("file".to_owned(), CstInputValue::String(entry.file.clone())),
146 (
147 "exports".to_owned(),
148 CstInputValue::Array(
149 entry
150 .exports
151 .iter()
152 .cloned()
153 .map(CstInputValue::String)
154 .collect(),
155 ),
156 ),
157 ]));
158 }
159 }
160 Ok(root.to_string())
161}
162
163fn append_toml_ignore_exports(
164 content: &str,
165 entries: &[IgnoreExportRule],
166 config_dir: &Path,
167) -> ConfigWriteResult<String> {
168 let mut doc = content
169 .parse::<DocumentMut>()
170 .map_err(ConfigWriteError::TomlParse)?;
171 match doc
172 .as_table_mut()
173 .entry("ignoreExports")
174 .or_insert(Item::None)
175 {
176 Item::None => {
177 let mut tables = ArrayOfTables::new();
178 let mut seen = FxHashSet::default();
179 append_to_array_of_tables(&mut tables, entries, &mut seen);
180 doc.as_table_mut()
181 .insert("ignoreExports", Item::ArrayOfTables(tables));
182 }
183 Item::ArrayOfTables(tables) => {
184 let mut seen = files_from_array_of_tables(tables, config_dir);
185 append_to_array_of_tables(tables, entries, &mut seen);
186 }
187 Item::Value(Value::Array(array)) => {
188 let mut seen = files_from_inline_array(array, config_dir);
189 append_to_inline_array(array, entries, &mut seen);
190 }
191 _ => {
192 return Err(ConfigWriteError::InvalidShape(
193 "ignoreExports must be an array of tables or inline array in fallow config".into(),
194 ));
195 }
196 }
197 Ok(doc.to_string())
198}
199
200fn files_from_array_of_tables(tables: &ArrayOfTables, config_dir: &Path) -> FxHashSet<String> {
201 let mut seen = FxHashSet::default();
202 for table in tables {
203 if let Some(file) = table.get("file").and_then(Item::as_str) {
204 record_existing_file(&mut seen, file, config_dir);
205 }
206 }
207 seen
208}
209
210fn append_to_array_of_tables(
211 tables: &mut ArrayOfTables,
212 entries: &[IgnoreExportRule],
213 seen: &mut FxHashSet<String>,
214) {
215 for entry in entries {
216 if seen.insert(entry.file.clone()) {
217 tables.push(toml_ignore_export_table(entry));
218 }
219 }
220}
221
222fn toml_ignore_export_table(entry: &IgnoreExportRule) -> Table {
223 let mut table = Table::new();
224 table.insert("file", toml_edit::value(entry.file.clone()));
225 table.insert("exports", Item::Value(Value::Array(exports_array(entry))));
226 table
227}
228
229fn files_from_inline_array(array: &Array, config_dir: &Path) -> FxHashSet<String> {
230 let mut seen = FxHashSet::default();
231 for value in array {
232 if let Some(file) = value
233 .as_inline_table()
234 .and_then(|table| table.get("file"))
235 .and_then(Value::as_str)
236 {
237 record_existing_file(&mut seen, file, config_dir);
238 }
239 }
240 seen
241}
242
243fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
250 seen.insert(file.to_owned());
251 let path = Path::new(file);
252 if path.is_absolute()
253 && let Ok(relative) = path.strip_prefix(config_dir)
254 {
255 seen.insert(relative.to_string_lossy().replace('\\', "/"));
256 }
257}
258
259fn append_to_inline_array(
260 array: &mut Array,
261 entries: &[IgnoreExportRule],
262 seen: &mut FxHashSet<String>,
263) {
264 for entry in entries {
265 if seen.insert(entry.file.clone()) {
266 array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
267 }
268 }
269}
270
271fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
272 let mut table = InlineTable::new();
273 table.insert("file", Value::from(entry.file.clone()));
274 table.insert("exports", Value::Array(exports_array(entry)));
275 table
276}
277
278fn exports_array(entry: &IgnoreExportRule) -> Array {
279 let mut exports = Array::new();
280 for export in &entry.exports {
281 exports.push(export.as_str());
282 }
283 exports
284}
285
286fn preserve_line_endings(rendered: &str, original: &str) -> String {
287 if original.contains("\r\n") {
288 rendered.replace("\r\n", "\n").replace('\n', "\r\n")
289 } else {
290 rendered.to_owned()
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn rule(file: &str) -> IgnoreExportRule {
299 IgnoreExportRule {
300 file: file.to_owned(),
301 exports: vec!["*".to_owned()],
302 }
303 }
304
305 #[test]
306 fn appends_json_ignore_exports() {
307 let output = add_ignore_exports_rule_to_string(
308 Path::new(".fallowrc.json"),
309 "{\n}\n",
310 &[rule("src/index.ts")],
311 )
312 .unwrap();
313 assert!(output.contains("\"ignoreExports\": ["));
314 assert!(output.contains("\"file\": \"src/index.ts\""));
315 assert!(output.ends_with('\n'));
316 }
317
318 #[test]
319 fn appends_jsonc_preserving_comments() {
320 let input = "{\n // keep this\n \"rules\": {}\n}\n";
321 let output = add_ignore_exports_rule_to_string(
322 Path::new(".fallowrc.jsonc"),
323 input,
324 &[rule("src/a.ts")],
325 )
326 .unwrap();
327 assert!(output.contains("// keep this"));
328 assert!(output.contains("\"rules\": {}"));
329 assert!(output.contains("\"file\": \"src/a.ts\""));
330 }
331
332 #[test]
333 fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
334 let input = "{\n \"ignoreExports\": [\n { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n ],\n \"rules\": {}\n}\n";
335 let output = add_ignore_exports_rule_to_string(
336 Path::new(".fallowrc.json"),
337 input,
338 &[rule("src/a.ts"), rule("src/b.ts")],
339 )
340 .unwrap();
341 assert_eq!(output.matches("\"file\": \"src/a.ts\"").count(), 1);
342 assert!(output.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
343 assert!(output.contains("\"rules\": {}"));
344 }
345
346 #[test]
347 fn appends_toml_ignore_exports() {
348 let output = add_ignore_exports_rule_to_string(
349 Path::new("fallow.toml"),
350 "production = true\n",
351 &[rule("src/index.ts")],
352 )
353 .unwrap();
354 assert!(output.contains("production = true"));
355 assert!(output.contains("[[ignoreExports]]"));
356 assert!(output.contains("file = \"src/index.ts\""));
357 assert!(output.contains("exports = [\"*\"]"));
358 }
359
360 #[test]
361 fn appends_dot_fallow_toml_ignore_exports() {
362 let output = add_ignore_exports_rule_to_string(
363 Path::new(".fallow.toml"),
364 "",
365 &[rule("src/index.ts")],
366 )
367 .unwrap();
368 assert!(output.contains("[[ignoreExports]]"));
369 assert!(output.contains("file = \"src/index.ts\""));
370 }
371
372 #[test]
373 fn merges_existing_toml_ignore_exports() {
374 let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
375 let output = add_ignore_exports_rule_to_string(
376 Path::new("fallow.toml"),
377 input,
378 &[rule("src/a.ts"), rule("src/b.ts")],
379 )
380 .unwrap();
381 assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
382 assert!(output.contains("file = \"src/b.ts\""));
383 }
384
385 #[test]
386 fn preserves_crlf_line_endings() {
387 let input = "{\r\n \"rules\": {}\r\n}\r\n";
388 let output = add_ignore_exports_rule_to_string(
389 Path::new(".fallowrc.json"),
390 input,
391 &[rule("src/a.ts")],
392 )
393 .unwrap();
394 assert!(output.contains("\r\n"));
395 assert!(!output.contains("\r\r"));
396 assert!(!output.replace("\r\n", "").contains('\n'));
397 }
398
399 #[test]
400 fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
401 let input = "production = true\r\n";
402 let output =
403 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
404 .unwrap();
405 assert!(output.contains("\r\n"));
406 assert!(!output.contains("\r\r"));
407 assert!(!output.replace("\r\n", "").contains('\n'));
408 }
409
410 #[test]
411 fn preserves_utf8_bom_on_json_config() {
412 let input = "\u{FEFF}{\n \"rules\": {}\n}\n";
413 let output = add_ignore_exports_rule_to_string(
414 Path::new(".fallowrc.json"),
415 input,
416 &[rule("src/a.ts")],
417 )
418 .unwrap();
419 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
420 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
421 assert!(output.contains("\"file\": \"src/a.ts\""));
422 }
423
424 #[test]
425 fn preserves_utf8_bom_on_toml_config() {
426 let input = "\u{FEFF}production = true\n";
427 let output =
428 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
429 .unwrap();
430 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
431 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
432 assert!(output.contains("[[ignoreExports]]"));
433 }
434
435 #[test]
436 fn no_bom_added_when_input_had_none() {
437 let input = "{\n}\n";
438 let output = add_ignore_exports_rule_to_string(
439 Path::new(".fallowrc.json"),
440 input,
441 &[rule("src/a.ts")],
442 )
443 .unwrap();
444 assert!(!output.starts_with('\u{FEFF}'));
445 }
446
447 #[test]
448 fn dedupes_existing_absolute_paths_against_relative_emissions() {
449 let config_dir = Path::new("/project");
450 let config_path = config_dir.join(".fallowrc.json");
451 let input = "{\n \"ignoreExports\": [\n { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n ]\n}\n";
452 let output =
453 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
454 assert_eq!(
455 output.matches("\"src/a.ts\"").count(),
456 0,
457 "writer must not add a relative duplicate of an existing absolute entry"
458 );
459 assert_eq!(
460 output.matches("\"/project/src/a.ts\"").count(),
461 1,
462 "existing absolute entry must remain"
463 );
464 }
465
466 #[test]
467 fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
468 let config_dir = Path::new("/project");
469 let config_path = config_dir.join("fallow.toml");
470 let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
471 let output =
472 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
473 assert_eq!(
474 output.matches("file = \"src/a.ts\"").count(),
475 0,
476 "writer must not add a relative duplicate of an existing absolute TOML entry"
477 );
478 assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
479 }
480}