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