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<()> {
66 let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
67 let dir = resolved.parent().unwrap_or_else(|| Path::new("."));
68 let mut tmp = NamedTempFile::new_in(dir)?;
69 tmp.write_all(content)?;
70 tmp.as_file().sync_all()?;
71 preserve_target_mode(tmp.path(), &resolved);
72 tmp.persist(&resolved).map_err(|e| e.error)?;
73 Ok(())
74}
75
76#[cfg(unix)]
80pub fn preserve_target_mode(temp: &Path, target: &Path) {
81 use std::os::unix::fs::PermissionsExt;
82 let Ok(metadata) = std::fs::metadata(target) else {
83 return; };
85 let mode = metadata.permissions().mode();
86 let _ = std::fs::set_permissions(temp, std::fs::Permissions::from_mode(mode & 0o7777));
87}
88
89#[cfg(not(unix))]
90pub fn preserve_target_mode(_temp: &Path, _target: &Path) {
91 }
94
95pub fn add_ignore_exports_rule(path: &Path, entries: &[IgnoreExportRule]) -> ConfigWriteResult<()> {
100 if entries.is_empty() {
101 return Ok(());
102 }
103 let content = std::fs::read_to_string(path)?;
104 let rendered = add_ignore_exports_rule_to_string(path, &content, entries)?;
105 atomic_write(path, rendered.as_bytes())?;
106 Ok(())
107}
108
109pub fn add_ignore_exports_rule_to_string(
117 path: &Path,
118 content: &str,
119 entries: &[IgnoreExportRule],
120) -> ConfigWriteResult<String> {
121 let had_bom = content.starts_with(BOM);
122 let body = content.strip_prefix(BOM).unwrap_or(content);
123 let config_dir = path.parent().unwrap_or_else(|| Path::new(""));
124 let rendered = if is_json_config(path) {
125 append_json_ignore_exports(body, entries, config_dir)?
126 } else {
127 append_toml_ignore_exports(body, entries, config_dir)?
128 };
129 let with_endings = preserve_line_endings(&rendered, body);
130 Ok(if had_bom {
131 let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
132 out.push(BOM);
133 out.push_str(&with_endings);
134 out
135 } else {
136 with_endings
137 })
138}
139
140const BOM: char = '\u{FEFF}';
141
142fn is_json_config(path: &Path) -> bool {
143 matches!(
144 path.extension().and_then(|ext| ext.to_str()),
145 Some("json" | "jsonc")
146 )
147}
148
149fn append_json_ignore_exports(
150 content: &str,
151 entries: &[IgnoreExportRule],
152 config_dir: &Path,
153) -> ConfigWriteResult<String> {
154 let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
155 .map_err(ConfigWriteError::JsonParse)?;
156 let object = root.object_value_or_create().ok_or_else(|| {
157 ConfigWriteError::InvalidShape("fallow config root must be an object".into())
158 })?;
159 let array = object
160 .array_value_or_create("ignoreExports")
161 .ok_or_else(|| {
162 ConfigWriteError::InvalidShape("ignoreExports must be an array in fallow config".into())
163 })?;
164
165 let mut seen = FxHashSet::default();
166 for element in array.elements() {
167 if let Some(file) = element.to_serde_value().and_then(|value| {
168 value
169 .get("file")
170 .and_then(serde_json::Value::as_str)
171 .map(str::to_owned)
172 }) {
173 record_existing_file(&mut seen, &file, config_dir);
174 }
175 }
176
177 for entry in entries {
178 if seen.insert(entry.file.clone()) {
179 array.append(CstInputValue::Object(vec![
180 ("file".to_owned(), CstInputValue::String(entry.file.clone())),
181 (
182 "exports".to_owned(),
183 CstInputValue::Array(
184 entry
185 .exports
186 .iter()
187 .cloned()
188 .map(CstInputValue::String)
189 .collect(),
190 ),
191 ),
192 ]));
193 }
194 }
195 Ok(root.to_string())
196}
197
198fn append_toml_ignore_exports(
199 content: &str,
200 entries: &[IgnoreExportRule],
201 config_dir: &Path,
202) -> ConfigWriteResult<String> {
203 let mut doc = content
204 .parse::<DocumentMut>()
205 .map_err(ConfigWriteError::TomlParse)?;
206 match doc
207 .as_table_mut()
208 .entry("ignoreExports")
209 .or_insert(Item::None)
210 {
211 Item::None => {
212 let mut tables = ArrayOfTables::new();
213 let mut seen = FxHashSet::default();
214 append_to_array_of_tables(&mut tables, entries, &mut seen);
215 doc.as_table_mut()
216 .insert("ignoreExports", Item::ArrayOfTables(tables));
217 }
218 Item::ArrayOfTables(tables) => {
219 let mut seen = files_from_array_of_tables(tables, config_dir);
220 append_to_array_of_tables(tables, entries, &mut seen);
221 }
222 Item::Value(Value::Array(array)) => {
223 let mut seen = files_from_inline_array(array, config_dir);
224 append_to_inline_array(array, entries, &mut seen);
225 }
226 _ => {
227 return Err(ConfigWriteError::InvalidShape(
228 "ignoreExports must be an array of tables or inline array in fallow config".into(),
229 ));
230 }
231 }
232 Ok(doc.to_string())
233}
234
235fn files_from_array_of_tables(tables: &ArrayOfTables, config_dir: &Path) -> FxHashSet<String> {
236 let mut seen = FxHashSet::default();
237 for table in tables {
238 if let Some(file) = table.get("file").and_then(Item::as_str) {
239 record_existing_file(&mut seen, file, config_dir);
240 }
241 }
242 seen
243}
244
245fn append_to_array_of_tables(
246 tables: &mut ArrayOfTables,
247 entries: &[IgnoreExportRule],
248 seen: &mut FxHashSet<String>,
249) {
250 for entry in entries {
251 if seen.insert(entry.file.clone()) {
252 tables.push(toml_ignore_export_table(entry));
253 }
254 }
255}
256
257fn toml_ignore_export_table(entry: &IgnoreExportRule) -> Table {
258 let mut table = Table::new();
259 table.insert("file", toml_edit::value(entry.file.clone()));
260 table.insert("exports", Item::Value(Value::Array(exports_array(entry))));
261 table
262}
263
264fn files_from_inline_array(array: &Array, config_dir: &Path) -> FxHashSet<String> {
265 let mut seen = FxHashSet::default();
266 for value in array {
267 if let Some(file) = value
268 .as_inline_table()
269 .and_then(|table| table.get("file"))
270 .and_then(Value::as_str)
271 {
272 record_existing_file(&mut seen, file, config_dir);
273 }
274 }
275 seen
276}
277
278fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
285 seen.insert(file.to_owned());
286 let path = Path::new(file);
287 if path.is_absolute()
288 && let Ok(relative) = path.strip_prefix(config_dir)
289 {
290 seen.insert(relative.to_string_lossy().replace('\\', "/"));
291 }
292}
293
294fn append_to_inline_array(
295 array: &mut Array,
296 entries: &[IgnoreExportRule],
297 seen: &mut FxHashSet<String>,
298) {
299 for entry in entries {
300 if seen.insert(entry.file.clone()) {
301 array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
302 }
303 }
304}
305
306fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
307 let mut table = InlineTable::new();
308 table.insert("file", Value::from(entry.file.clone()));
309 table.insert("exports", Value::Array(exports_array(entry)));
310 table
311}
312
313fn exports_array(entry: &IgnoreExportRule) -> Array {
314 let mut exports = Array::new();
315 for export in &entry.exports {
316 exports.push(export.as_str());
317 }
318 exports
319}
320
321fn preserve_line_endings(rendered: &str, original: &str) -> String {
322 if original.contains("\r\n") {
323 rendered.replace("\r\n", "\n").replace('\n', "\r\n")
324 } else {
325 rendered.to_owned()
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn rule(file: &str) -> IgnoreExportRule {
334 IgnoreExportRule {
335 file: file.to_owned(),
336 exports: vec!["*".to_owned()],
337 }
338 }
339
340 #[test]
341 fn appends_json_ignore_exports() {
342 let output = add_ignore_exports_rule_to_string(
343 Path::new(".fallowrc.json"),
344 "{\n}\n",
345 &[rule("src/index.ts")],
346 )
347 .unwrap();
348 assert!(output.contains("\"ignoreExports\": ["));
349 assert!(output.contains("\"file\": \"src/index.ts\""));
350 assert!(output.ends_with('\n'));
351 }
352
353 #[test]
354 fn appends_jsonc_preserving_comments() {
355 let input = "{\n // keep this\n \"rules\": {}\n}\n";
356 let output = add_ignore_exports_rule_to_string(
357 Path::new(".fallowrc.jsonc"),
358 input,
359 &[rule("src/a.ts")],
360 )
361 .unwrap();
362 assert!(output.contains("// keep this"));
363 assert!(output.contains("\"rules\": {}"));
364 assert!(output.contains("\"file\": \"src/a.ts\""));
365 }
366
367 #[test]
368 fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
369 let input = "{\n \"ignoreExports\": [\n { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n ],\n \"rules\": {}\n}\n";
370 let output = add_ignore_exports_rule_to_string(
371 Path::new(".fallowrc.json"),
372 input,
373 &[rule("src/a.ts"), rule("src/b.ts")],
374 )
375 .unwrap();
376 assert_eq!(output.matches("\"file\": \"src/a.ts\"").count(), 1);
377 assert!(output.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
378 assert!(output.contains("\"rules\": {}"));
379 }
380
381 #[test]
382 fn appends_toml_ignore_exports() {
383 let output = add_ignore_exports_rule_to_string(
384 Path::new("fallow.toml"),
385 "production = true\n",
386 &[rule("src/index.ts")],
387 )
388 .unwrap();
389 assert!(output.contains("production = true"));
390 assert!(output.contains("[[ignoreExports]]"));
391 assert!(output.contains("file = \"src/index.ts\""));
392 assert!(output.contains("exports = [\"*\"]"));
393 }
394
395 #[test]
396 fn appends_dot_fallow_toml_ignore_exports() {
397 let output = add_ignore_exports_rule_to_string(
398 Path::new(".fallow.toml"),
399 "",
400 &[rule("src/index.ts")],
401 )
402 .unwrap();
403 assert!(output.contains("[[ignoreExports]]"));
404 assert!(output.contains("file = \"src/index.ts\""));
405 }
406
407 #[test]
408 fn merges_existing_toml_ignore_exports() {
409 let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
410 let output = add_ignore_exports_rule_to_string(
411 Path::new("fallow.toml"),
412 input,
413 &[rule("src/a.ts"), rule("src/b.ts")],
414 )
415 .unwrap();
416 assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
417 assert!(output.contains("file = \"src/b.ts\""));
418 }
419
420 #[test]
421 fn preserves_crlf_line_endings() {
422 let input = "{\r\n \"rules\": {}\r\n}\r\n";
423 let output = add_ignore_exports_rule_to_string(
424 Path::new(".fallowrc.json"),
425 input,
426 &[rule("src/a.ts")],
427 )
428 .unwrap();
429 assert!(output.contains("\r\n"));
430 assert!(!output.contains("\r\r"));
431 assert!(!output.replace("\r\n", "").contains('\n'));
432 }
433
434 #[test]
435 fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
436 let input = "production = true\r\n";
437 let output =
438 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
439 .unwrap();
440 assert!(output.contains("\r\n"));
441 assert!(!output.contains("\r\r"));
442 assert!(!output.replace("\r\n", "").contains('\n'));
443 }
444
445 #[test]
446 fn preserves_utf8_bom_on_json_config() {
447 let input = "\u{FEFF}{\n \"rules\": {}\n}\n";
448 let output = add_ignore_exports_rule_to_string(
449 Path::new(".fallowrc.json"),
450 input,
451 &[rule("src/a.ts")],
452 )
453 .unwrap();
454 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
455 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
456 assert!(output.contains("\"file\": \"src/a.ts\""));
457 }
458
459 #[test]
460 fn preserves_utf8_bom_on_toml_config() {
461 let input = "\u{FEFF}production = true\n";
462 let output =
463 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
464 .unwrap();
465 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
466 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
467 assert!(output.contains("[[ignoreExports]]"));
468 }
469
470 #[test]
471 fn no_bom_added_when_input_had_none() {
472 let input = "{\n}\n";
473 let output = add_ignore_exports_rule_to_string(
474 Path::new(".fallowrc.json"),
475 input,
476 &[rule("src/a.ts")],
477 )
478 .unwrap();
479 assert!(!output.starts_with('\u{FEFF}'));
480 }
481
482 #[test]
483 fn dedupes_existing_absolute_paths_against_relative_emissions() {
484 let config_dir = Path::new("/project");
485 let config_path = config_dir.join(".fallowrc.json");
486 let input = "{\n \"ignoreExports\": [\n { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n ]\n}\n";
487 let output =
488 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
489 assert_eq!(
490 output.matches("\"src/a.ts\"").count(),
491 0,
492 "writer must not add a relative duplicate of an existing absolute entry"
493 );
494 assert_eq!(
495 output.matches("\"/project/src/a.ts\"").count(),
496 1,
497 "existing absolute entry must remain"
498 );
499 }
500
501 #[cfg(unix)]
502 #[test]
503 fn atomic_write_preserves_existing_target_mode() {
504 use std::os::unix::fs::PermissionsExt;
508 let dir = tempfile::tempdir().unwrap();
509 let target = dir.path().join("config.json");
510 std::fs::write(&target, "{}").unwrap();
511 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
512
513 atomic_write(&target, b"{\"updated\": true}").unwrap();
514
515 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
516 assert_eq!(
517 mode, 0o644,
518 "atomic_write must preserve the target file mode"
519 );
520 assert_eq!(
521 std::fs::read_to_string(&target).unwrap(),
522 "{\"updated\": true}"
523 );
524 }
525
526 #[cfg(unix)]
527 #[test]
528 fn atomic_write_on_fresh_target_uses_default_mode() {
529 use std::os::unix::fs::PermissionsExt;
534 let dir = tempfile::tempdir().unwrap();
535 let fresh = dir.path().join("brand-new.json");
536 atomic_write(&fresh, b"{}").unwrap();
537 let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o7777;
538 assert!(mode != 0, "fresh file should have a non-zero mode");
542 }
543
544 #[test]
545 fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
546 let config_dir = Path::new("/project");
547 let config_path = config_dir.join("fallow.toml");
548 let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
549 let output =
550 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
551 assert_eq!(
552 output.matches("file = \"src/a.ts\"").count(),
553 0,
554 "writer must not add a relative duplicate of an existing absolute TOML entry"
555 );
556 assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
557 }
558}