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) {
293 seen.insert(file.to_owned());
294 if let Ok(relative) = Path::new(file).strip_prefix(config_dir) {
295 seen.insert(relative.to_string_lossy().replace('\\', "/"));
296 }
297}
298
299fn append_to_inline_array(
300 array: &mut Array,
301 entries: &[IgnoreExportRule],
302 seen: &mut FxHashSet<String>,
303) {
304 for entry in entries {
305 if seen.insert(entry.file.clone()) {
306 array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
307 }
308 }
309}
310
311fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
312 let mut table = InlineTable::new();
313 table.insert("file", Value::from(entry.file.clone()));
314 table.insert("exports", Value::Array(exports_array(entry)));
315 table
316}
317
318fn exports_array(entry: &IgnoreExportRule) -> Array {
319 let mut exports = Array::new();
320 for export in &entry.exports {
321 exports.push(export.as_str());
322 }
323 exports
324}
325
326fn preserve_line_endings(rendered: &str, original: &str) -> String {
327 if original.contains("\r\n") {
328 rendered.replace("\r\n", "\n").replace('\n', "\r\n")
329 } else {
330 rendered.to_owned()
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn rule(file: &str) -> IgnoreExportRule {
339 IgnoreExportRule {
340 file: file.to_owned(),
341 exports: vec!["*".to_owned()],
342 }
343 }
344
345 #[test]
346 fn appends_json_ignore_exports() {
347 let output = add_ignore_exports_rule_to_string(
348 Path::new(".fallowrc.json"),
349 "{\n}\n",
350 &[rule("src/index.ts")],
351 )
352 .unwrap();
353 assert!(output.contains("\"ignoreExports\": ["));
354 assert!(output.contains("\"file\": \"src/index.ts\""));
355 assert!(output.ends_with('\n'));
356 }
357
358 #[test]
359 fn appends_jsonc_preserving_comments() {
360 let input = "{\n // keep this\n \"rules\": {}\n}\n";
361 let output = add_ignore_exports_rule_to_string(
362 Path::new(".fallowrc.jsonc"),
363 input,
364 &[rule("src/a.ts")],
365 )
366 .unwrap();
367 assert!(output.contains("// keep this"));
368 assert!(output.contains("\"rules\": {}"));
369 assert!(output.contains("\"file\": \"src/a.ts\""));
370 }
371
372 #[test]
373 fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
374 let input = "{\n \"ignoreExports\": [\n { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n ],\n \"rules\": {}\n}\n";
375 let output = add_ignore_exports_rule_to_string(
376 Path::new(".fallowrc.json"),
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.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
383 assert!(output.contains("\"rules\": {}"));
384 }
385
386 #[test]
387 fn appends_toml_ignore_exports() {
388 let output = add_ignore_exports_rule_to_string(
389 Path::new("fallow.toml"),
390 "production = true\n",
391 &[rule("src/index.ts")],
392 )
393 .unwrap();
394 assert!(output.contains("production = true"));
395 assert!(output.contains("[[ignoreExports]]"));
396 assert!(output.contains("file = \"src/index.ts\""));
397 assert!(output.contains("exports = [\"*\"]"));
398 }
399
400 #[test]
401 fn appends_dot_fallow_toml_ignore_exports() {
402 let output = add_ignore_exports_rule_to_string(
403 Path::new(".fallow.toml"),
404 "",
405 &[rule("src/index.ts")],
406 )
407 .unwrap();
408 assert!(output.contains("[[ignoreExports]]"));
409 assert!(output.contains("file = \"src/index.ts\""));
410 }
411
412 #[test]
413 fn merges_existing_toml_ignore_exports() {
414 let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
415 let output = add_ignore_exports_rule_to_string(
416 Path::new("fallow.toml"),
417 input,
418 &[rule("src/a.ts"), rule("src/b.ts")],
419 )
420 .unwrap();
421 assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
422 assert!(output.contains("file = \"src/b.ts\""));
423 }
424
425 #[test]
426 fn preserves_crlf_line_endings() {
427 let input = "{\r\n \"rules\": {}\r\n}\r\n";
428 let output = add_ignore_exports_rule_to_string(
429 Path::new(".fallowrc.json"),
430 input,
431 &[rule("src/a.ts")],
432 )
433 .unwrap();
434 assert!(output.contains("\r\n"));
435 assert!(!output.contains("\r\r"));
436 assert!(!output.replace("\r\n", "").contains('\n'));
437 }
438
439 #[test]
440 fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
441 let input = "production = true\r\n";
442 let output =
443 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
444 .unwrap();
445 assert!(output.contains("\r\n"));
446 assert!(!output.contains("\r\r"));
447 assert!(!output.replace("\r\n", "").contains('\n'));
448 }
449
450 #[test]
451 fn preserves_utf8_bom_on_json_config() {
452 let input = "\u{FEFF}{\n \"rules\": {}\n}\n";
453 let output = add_ignore_exports_rule_to_string(
454 Path::new(".fallowrc.json"),
455 input,
456 &[rule("src/a.ts")],
457 )
458 .unwrap();
459 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
460 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
461 assert!(output.contains("\"file\": \"src/a.ts\""));
462 }
463
464 #[test]
465 fn preserves_utf8_bom_on_toml_config() {
466 let input = "\u{FEFF}production = true\n";
467 let output =
468 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
469 .unwrap();
470 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
471 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
472 assert!(output.contains("[[ignoreExports]]"));
473 }
474
475 #[test]
476 fn no_bom_added_when_input_had_none() {
477 let input = "{\n}\n";
478 let output = add_ignore_exports_rule_to_string(
479 Path::new(".fallowrc.json"),
480 input,
481 &[rule("src/a.ts")],
482 )
483 .unwrap();
484 assert!(!output.starts_with('\u{FEFF}'));
485 }
486
487 #[test]
488 fn dedupes_existing_absolute_paths_against_relative_emissions() {
489 let config_dir = Path::new("/project");
490 let config_path = config_dir.join(".fallowrc.json");
491 let input = "{\n \"ignoreExports\": [\n { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n ]\n}\n";
492 let output =
493 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
494 assert_eq!(
495 output.matches("\"src/a.ts\"").count(),
496 0,
497 "writer must not add a relative duplicate of an existing absolute entry"
498 );
499 assert_eq!(
500 output.matches("\"/project/src/a.ts\"").count(),
501 1,
502 "existing absolute entry must remain"
503 );
504 }
505
506 #[cfg(unix)]
507 #[test]
508 fn atomic_write_preserves_existing_target_mode() {
509 use std::os::unix::fs::PermissionsExt;
513 let dir = tempfile::tempdir().unwrap();
514 let target = dir.path().join("config.json");
515 std::fs::write(&target, "{}").unwrap();
516 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
517
518 atomic_write(&target, b"{\"updated\": true}").unwrap();
519
520 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
521 assert_eq!(
522 mode, 0o644,
523 "atomic_write must preserve the target file mode"
524 );
525 assert_eq!(
526 std::fs::read_to_string(&target).unwrap(),
527 "{\"updated\": true}"
528 );
529 }
530
531 #[cfg(unix)]
532 #[test]
533 fn atomic_write_on_fresh_target_uses_default_mode() {
534 use std::os::unix::fs::PermissionsExt;
539 let dir = tempfile::tempdir().unwrap();
540 let fresh = dir.path().join("brand-new.json");
541 atomic_write(&fresh, b"{}").unwrap();
542 let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o7777;
543 assert!(mode != 0, "fresh file should have a non-zero mode");
547 }
548
549 #[test]
550 fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
551 let config_dir = Path::new("/project");
552 let config_path = config_dir.join("fallow.toml");
553 let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
554 let output =
555 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
556 assert_eq!(
557 output.matches("file = \"src/a.ts\"").count(),
558 0,
559 "writer must not add a relative duplicate of an existing absolute TOML entry"
560 );
561 assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
562 }
563}