1use std::error::Error;
2use std::fmt;
3use std::io::Write;
4use std::path::Path;
5
6use jsonc_parser::cst::{CstInputValue, CstRootNode, TrailingCommaMode};
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)]
16pub enum ConfigWriteError {
17 Io(std::io::Error),
19 JsonParse(jsonc_parser::errors::ParseError),
22 TomlParse(toml_edit::TomlError),
24 InvalidShape(String),
28}
29
30impl fmt::Display for ConfigWriteError {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 match self {
33 Self::Io(e) => write!(f, "{e}"),
34 Self::JsonParse(e) => write!(f, "{e}"),
35 Self::TomlParse(e) => write!(f, "{e}"),
36 Self::InvalidShape(msg) => f.write_str(msg),
37 }
38 }
39}
40
41impl Error for ConfigWriteError {
42 fn source(&self) -> Option<&(dyn Error + 'static)> {
43 match self {
44 Self::Io(e) => Some(e),
45 Self::JsonParse(e) => Some(e),
46 Self::TomlParse(e) => Some(e),
47 Self::InvalidShape(_) => None,
48 }
49 }
50}
51
52impl From<std::io::Error> for ConfigWriteError {
53 fn from(value: std::io::Error) -> Self {
54 Self::Io(value)
55 }
56}
57
58pub type ConfigWriteResult<T> = Result<T, ConfigWriteError>;
60
61pub fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
65 let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
66 let dir = resolved.parent().unwrap_or_else(|| Path::new("."));
67 let mut tmp = NamedTempFile::new_in(dir)?;
68 tmp.write_all(content)?;
69 tmp.as_file().sync_all()?;
70 preserve_target_mode(tmp.path(), &resolved);
71 tmp.persist(&resolved).map_err(|e| e.error)?;
72 Ok(())
73}
74
75#[cfg(unix)]
77pub fn preserve_target_mode(temp: &Path, target: &Path) {
78 use std::os::unix::fs::PermissionsExt;
79 let Ok(metadata) = std::fs::metadata(target) else {
80 return;
81 };
82 let mode = metadata.permissions().mode();
83 let _ = std::fs::set_permissions(temp, std::fs::Permissions::from_mode(mode & 0o7777));
84}
85
86#[cfg(not(unix))]
88pub fn preserve_target_mode(_temp: &Path, _target: &Path) {
89 }
91
92pub fn add_ignore_exports_rule(path: &Path, entries: &[IgnoreExportRule]) -> ConfigWriteResult<()> {
94 if entries.is_empty() {
95 return Ok(());
96 }
97 let content = std::fs::read_to_string(path)?;
98 let rendered = add_ignore_exports_rule_to_string(path, &content, entries)?;
99 atomic_write(path, rendered.as_bytes())?;
100 Ok(())
101}
102
103pub fn add_rule_pack_path(path: &Path, pack_path: &str) -> ConfigWriteResult<bool> {
105 let content = std::fs::read_to_string(path)?;
106 let (rendered, changed) = add_rule_pack_path_to_string(path, &content, pack_path)?;
107 if changed {
108 atomic_write(path, rendered.as_bytes())?;
109 }
110 Ok(changed)
111}
112
113pub fn add_rule_pack_path_to_string(
115 path: &Path,
116 content: &str,
117 pack_path: &str,
118) -> ConfigWriteResult<(String, bool)> {
119 let had_bom = content.starts_with(BOM);
120 let body = content.strip_prefix(BOM).unwrap_or(content);
121 let (rendered, changed) = if is_json_config(path) {
122 append_json_rule_pack_path(body, pack_path)?
123 } else {
124 append_toml_rule_pack_path(body, pack_path)?
125 };
126 let with_endings = preserve_line_endings(&rendered, body);
127 let final_content = if had_bom {
128 let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
129 out.push(BOM);
130 out.push_str(&with_endings);
131 out
132 } else {
133 with_endings
134 };
135 Ok((final_content, changed))
136}
137
138pub fn add_ignore_exports_rule_to_string(
140 path: &Path,
141 content: &str,
142 entries: &[IgnoreExportRule],
143) -> ConfigWriteResult<String> {
144 let had_bom = content.starts_with(BOM);
145 let body = content.strip_prefix(BOM).unwrap_or(content);
146 let config_dir = path.parent().unwrap_or_else(|| Path::new(""));
147 let rendered = if is_json_config(path) {
148 append_json_ignore_exports(body, entries, config_dir)?
149 } else {
150 append_toml_ignore_exports(body, entries, config_dir)?
151 };
152 let with_endings = preserve_line_endings(&rendered, body);
153 Ok(if had_bom {
154 let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
155 out.push(BOM);
156 out.push_str(&with_endings);
157 out
158 } else {
159 with_endings
160 })
161}
162
163const BOM: char = '\u{FEFF}';
164
165fn is_json_config(path: &Path) -> bool {
166 matches!(
167 path.extension().and_then(|ext| ext.to_str()),
168 Some("json" | "jsonc")
169 )
170}
171
172fn append_json_ignore_exports(
173 content: &str,
174 entries: &[IgnoreExportRule],
175 config_dir: &Path,
176) -> ConfigWriteResult<String> {
177 let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
178 .map_err(ConfigWriteError::JsonParse)?;
179 let object = root.object_value_or_create().ok_or_else(|| {
180 ConfigWriteError::InvalidShape("fallow config root must be an object".into())
181 })?;
182 let array = object
183 .array_value_or_create("ignoreExports")
184 .ok_or_else(|| {
185 ConfigWriteError::InvalidShape("ignoreExports must be an array in fallow config".into())
186 })?;
187 let inherit_trailing_commas = array.elements().is_empty() && object.uses_trailing_commas();
188
189 let mut seen = FxHashSet::default();
190 for element in array.elements() {
191 if let Some(file) = element.to_serde_value().and_then(|value| {
192 value
193 .get("file")
194 .and_then(serde_json::Value::as_str)
195 .map(str::to_owned)
196 }) {
197 record_existing_file(&mut seen, &file, config_dir);
198 }
199 }
200
201 for entry in entries {
202 if seen.insert(entry.file.clone()) {
203 array.append(CstInputValue::Object(vec![
204 ("file".to_owned(), CstInputValue::String(entry.file.clone())),
205 (
206 "exports".to_owned(),
207 CstInputValue::Array(
208 entry
209 .exports
210 .iter()
211 .cloned()
212 .map(CstInputValue::String)
213 .collect(),
214 ),
215 ),
216 ]));
217 }
218 }
219 if inherit_trailing_commas {
220 array.set_trailing_commas(TrailingCommaMode::IfMultiline);
221 }
222 Ok(root.to_string())
223}
224
225fn append_json_rule_pack_path(content: &str, pack_path: &str) -> ConfigWriteResult<(String, bool)> {
226 let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
227 .map_err(ConfigWriteError::JsonParse)?;
228 let object = root.object_value_or_create().ok_or_else(|| {
229 ConfigWriteError::InvalidShape("fallow config root must be an object".into())
230 })?;
231 let array = object.array_value_or_create("rulePacks").ok_or_else(|| {
232 ConfigWriteError::InvalidShape("rulePacks must be an array in fallow config".into())
233 })?;
234 let inherit_trailing_commas = array.elements().is_empty() && object.uses_trailing_commas();
235
236 for element in array.elements() {
237 if element
238 .to_serde_value()
239 .and_then(|value| value.as_str().map(|existing| existing == pack_path))
240 == Some(true)
241 {
242 return Ok((root.to_string(), false));
243 }
244 }
245
246 array.append(CstInputValue::String(pack_path.to_owned()));
247 if inherit_trailing_commas {
248 array.set_trailing_commas(TrailingCommaMode::IfMultiline);
249 }
250 Ok((root.to_string(), true))
251}
252
253fn append_toml_ignore_exports(
254 content: &str,
255 entries: &[IgnoreExportRule],
256 config_dir: &Path,
257) -> ConfigWriteResult<String> {
258 let mut doc = content
259 .parse::<DocumentMut>()
260 .map_err(ConfigWriteError::TomlParse)?;
261 match doc
262 .as_table_mut()
263 .entry("ignoreExports")
264 .or_insert(Item::None)
265 {
266 Item::None => {
267 let mut tables = ArrayOfTables::new();
268 let mut seen = FxHashSet::default();
269 append_to_array_of_tables(&mut tables, entries, &mut seen);
270 doc.as_table_mut()
271 .insert("ignoreExports", Item::ArrayOfTables(tables));
272 }
273 Item::ArrayOfTables(tables) => {
274 let mut seen = files_from_array_of_tables(tables, config_dir);
275 append_to_array_of_tables(tables, entries, &mut seen);
276 }
277 Item::Value(Value::Array(array)) => {
278 let mut seen = files_from_inline_array(array, config_dir);
279 append_to_inline_array(array, entries, &mut seen);
280 }
281 _ => {
282 return Err(ConfigWriteError::InvalidShape(
283 "ignoreExports must be an array of tables or inline array in fallow config".into(),
284 ));
285 }
286 }
287 Ok(doc.to_string())
288}
289
290fn append_toml_rule_pack_path(content: &str, pack_path: &str) -> ConfigWriteResult<(String, bool)> {
291 let mut doc = content
292 .parse::<DocumentMut>()
293 .map_err(ConfigWriteError::TomlParse)?;
294 match doc.as_table_mut().entry("rulePacks").or_insert(Item::None) {
295 Item::None => {
296 let mut array = Array::new();
297 array.push(pack_path);
298 doc.as_table_mut()
299 .insert("rulePacks", Item::Value(Value::Array(array)));
300 Ok((doc.to_string(), true))
301 }
302 Item::Value(Value::Array(array)) => {
303 if array.iter().any(|value| value.as_str() == Some(pack_path)) {
304 return Ok((doc.to_string(), false));
305 }
306 array.push(pack_path);
307 Ok((doc.to_string(), true))
308 }
309 _ => Err(ConfigWriteError::InvalidShape(
310 "rulePacks must be an array in fallow config".into(),
311 )),
312 }
313}
314
315fn files_from_array_of_tables(tables: &ArrayOfTables, config_dir: &Path) -> FxHashSet<String> {
316 let mut seen = FxHashSet::default();
317 for table in tables {
318 if let Some(file) = table.get("file").and_then(Item::as_str) {
319 record_existing_file(&mut seen, file, config_dir);
320 }
321 }
322 seen
323}
324
325fn append_to_array_of_tables(
326 tables: &mut ArrayOfTables,
327 entries: &[IgnoreExportRule],
328 seen: &mut FxHashSet<String>,
329) {
330 for entry in entries {
331 if seen.insert(entry.file.clone()) {
332 tables.push(toml_ignore_export_table(entry));
333 }
334 }
335}
336
337fn toml_ignore_export_table(entry: &IgnoreExportRule) -> Table {
338 let mut table = Table::new();
339 table.insert("file", toml_edit::value(entry.file.clone()));
340 table.insert("exports", Item::Value(Value::Array(exports_array(entry))));
341 table
342}
343
344fn files_from_inline_array(array: &Array, config_dir: &Path) -> FxHashSet<String> {
345 let mut seen = FxHashSet::default();
346 for value in array {
347 if let Some(file) = value
348 .as_inline_table()
349 .and_then(|table| table.get("file"))
350 .and_then(Value::as_str)
351 {
352 record_existing_file(&mut seen, file, config_dir);
353 }
354 }
355 seen
356}
357
358fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
373 seen.insert(file.to_owned());
374 if let Ok(relative) = Path::new(file).strip_prefix(config_dir) {
375 seen.insert(relative.to_string_lossy().replace('\\', "/"));
376 }
377}
378
379fn append_to_inline_array(
380 array: &mut Array,
381 entries: &[IgnoreExportRule],
382 seen: &mut FxHashSet<String>,
383) {
384 for entry in entries {
385 if seen.insert(entry.file.clone()) {
386 array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
387 }
388 }
389}
390
391fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
392 let mut table = InlineTable::new();
393 table.insert("file", Value::from(entry.file.clone()));
394 table.insert("exports", Value::Array(exports_array(entry)));
395 table
396}
397
398fn exports_array(entry: &IgnoreExportRule) -> Array {
399 let mut exports = Array::new();
400 for export in &entry.exports {
401 exports.push(export.as_str());
402 }
403 exports
404}
405
406fn preserve_line_endings(rendered: &str, original: &str) -> String {
407 if original.contains("\r\n") {
408 rendered.replace("\r\n", "\n").replace('\n', "\r\n")
409 } else {
410 rendered.to_owned()
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 fn rule(file: &str) -> IgnoreExportRule {
419 IgnoreExportRule {
420 file: file.to_owned(),
421 exports: vec!["*".to_owned()],
422 }
423 }
424
425 #[test]
426 fn appends_json_ignore_exports() {
427 let output = add_ignore_exports_rule_to_string(
428 Path::new(".fallowrc.json"),
429 "{\n}\n",
430 &[rule("src/index.ts")],
431 )
432 .unwrap();
433 assert!(output.contains("\"ignoreExports\": ["));
434 assert!(output.contains("\"file\": \"src/index.ts\""));
435 assert!(output.ends_with('\n'));
436 }
437
438 #[test]
439 fn appends_json_rule_pack_path() {
440 let (output, changed) = add_rule_pack_path_to_string(
441 Path::new(".fallowrc.json"),
442 "{\n \"rules\": {}\n}\n",
443 "rule-packs/team-policy.jsonc",
444 )
445 .unwrap();
446 assert!(changed);
447 assert!(output.contains("\"rules\": {}"));
448 assert!(output.contains("\"rulePacks\": ["));
449 assert!(output.contains("\"rule-packs/team-policy.jsonc\""));
450 }
451
452 #[test]
453 fn appends_jsonc_rule_pack_path_preserving_comments() {
454 let input = "{\n // keep this\n \"rules\": {}\n}\n";
455 let (output, changed) = add_rule_pack_path_to_string(
456 Path::new(".fallowrc.jsonc"),
457 input,
458 "rule-packs/team-policy.jsonc",
459 )
460 .unwrap();
461 assert!(changed);
462 assert!(output.contains("// keep this"));
463 assert!(output.contains("\"rule-packs/team-policy.jsonc\""));
464 }
465
466 #[test]
467 fn dedupes_existing_rule_pack_path() {
468 let input = "{\n \"rulePacks\": [\"rule-packs/team-policy.jsonc\"]\n}\n";
469 let (output, changed) = add_rule_pack_path_to_string(
470 Path::new(".fallowrc.json"),
471 input,
472 "rule-packs/team-policy.jsonc",
473 )
474 .unwrap();
475 assert!(!changed);
476 assert_eq!(output.matches("rule-packs/team-policy.jsonc").count(), 1);
477 }
478
479 #[test]
480 fn appends_toml_rule_pack_path() {
481 let (output, changed) = add_rule_pack_path_to_string(
482 Path::new("fallow.toml"),
483 "production = true\n",
484 "rule-packs/team-policy.jsonc",
485 )
486 .unwrap();
487 assert!(changed);
488 assert!(output.contains("production = true"));
489 assert!(output.contains("rulePacks = [\"rule-packs/team-policy.jsonc\"]"));
490 }
491
492 #[test]
493 fn appends_jsonc_preserving_comments() {
494 let input = "{\n // keep this\n \"rules\": {}\n}\n";
495 let output = add_ignore_exports_rule_to_string(
496 Path::new(".fallowrc.jsonc"),
497 input,
498 &[rule("src/a.ts")],
499 )
500 .unwrap();
501 assert!(output.contains("// keep this"));
502 assert!(output.contains("\"rules\": {}"));
503 assert!(output.contains("\"file\": \"src/a.ts\""));
504 }
505
506 #[test]
507 fn new_jsonc_ignore_exports_inherits_parent_trailing_commas() {
508 let input = "{\n\t// keep this\n\t\"rules\": {},\n}\n";
509 let output = add_ignore_exports_rule_to_string(
510 Path::new(".fallowrc.jsonc"),
511 input,
512 &[rule("src/a.ts")],
513 )
514 .unwrap();
515 assert_eq!(
516 output,
517 "{\n\t// keep this\n\t\"rules\": {},\n\t\"ignoreExports\": [\n\t\t{\n\t\t\t\"file\": \"src/a.ts\",\n\t\t\t\"exports\": [\"*\"],\n\t\t},\n\t],\n}\n"
518 );
519 let repeated = add_ignore_exports_rule_to_string(
520 Path::new(".fallowrc.jsonc"),
521 &output,
522 &[rule("src/a.ts")],
523 )
524 .unwrap();
525 assert_eq!(repeated, output);
526 }
527
528 #[test]
529 fn empty_multiline_jsonc_rule_pack_array_inherits_parent_trailing_commas() {
530 let input = "{\n // keep this\n \"rules\": {},\n \"rulePacks\": [\n ],\n}\n";
531 let (output, changed) =
532 add_rule_pack_path_to_string(Path::new(".fallowrc.jsonc"), input, "rules/custom.jsonc")
533 .unwrap();
534 assert!(changed);
535 assert_eq!(
536 output,
537 "{\n // keep this\n \"rules\": {},\n \"rulePacks\": [\n \"rules/custom.jsonc\",\n ],\n}\n"
538 );
539 }
540
541 #[test]
542 fn empty_jsonc_ignore_exports_inherits_parent_without_restyling_other_nodes() {
543 let input = "{\r\n \"entry\": [\r\n \"src/index.ts\"\r\n ],\r\n \"ignoreExports\": [],\r\n}\r\n";
544 let output = add_ignore_exports_rule_to_string(
545 Path::new(".fallowrc.jsonc"),
546 input,
547 &[rule("src/a.ts")],
548 )
549 .unwrap();
550 assert_eq!(
551 output,
552 "{\r\n \"entry\": [\r\n \"src/index.ts\"\r\n ],\r\n \"ignoreExports\": [\r\n {\r\n \"file\": \"src/a.ts\",\r\n \"exports\": [\"*\"],\r\n },\r\n ],\r\n}\r\n"
553 );
554 }
555
556 #[test]
557 fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
558 let input = "{\n \"ignoreExports\": [\n { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n ],\n \"rules\": {}\n}\n";
559 let output = add_ignore_exports_rule_to_string(
560 Path::new(".fallowrc.json"),
561 input,
562 &[rule("src/a.ts"), rule("src/b.ts")],
563 )
564 .unwrap();
565 assert_eq!(output.matches("\"file\": \"src/a.ts\"").count(), 1);
566 assert!(output.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
567 assert!(output.contains("\"rules\": {}"));
568 }
569
570 #[test]
571 fn appends_toml_ignore_exports() {
572 let output = add_ignore_exports_rule_to_string(
573 Path::new("fallow.toml"),
574 "production = true\n",
575 &[rule("src/index.ts")],
576 )
577 .unwrap();
578 assert!(output.contains("production = true"));
579 assert!(output.contains("[[ignoreExports]]"));
580 assert!(output.contains("file = \"src/index.ts\""));
581 assert!(output.contains("exports = [\"*\"]"));
582 }
583
584 #[test]
585 fn appends_dot_fallow_toml_ignore_exports() {
586 let output = add_ignore_exports_rule_to_string(
587 Path::new(".fallow.toml"),
588 "",
589 &[rule("src/index.ts")],
590 )
591 .unwrap();
592 assert!(output.contains("[[ignoreExports]]"));
593 assert!(output.contains("file = \"src/index.ts\""));
594 }
595
596 #[test]
597 fn merges_existing_toml_ignore_exports() {
598 let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
599 let output = add_ignore_exports_rule_to_string(
600 Path::new("fallow.toml"),
601 input,
602 &[rule("src/a.ts"), rule("src/b.ts")],
603 )
604 .unwrap();
605 assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
606 assert!(output.contains("file = \"src/b.ts\""));
607 }
608
609 #[test]
610 fn preserves_crlf_line_endings() {
611 let input = "{\r\n \"rules\": {}\r\n}\r\n";
612 let output = add_ignore_exports_rule_to_string(
613 Path::new(".fallowrc.json"),
614 input,
615 &[rule("src/a.ts")],
616 )
617 .unwrap();
618 assert!(output.contains("\r\n"));
619 assert!(!output.contains("\r\r"));
620 assert!(!output.replace("\r\n", "").contains('\n'));
621 }
622
623 #[test]
624 fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
625 let input = "production = true\r\n";
626 let output =
627 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
628 .unwrap();
629 assert!(output.contains("\r\n"));
630 assert!(!output.contains("\r\r"));
631 assert!(!output.replace("\r\n", "").contains('\n'));
632 }
633
634 #[test]
635 fn preserves_utf8_bom_on_json_config() {
636 let input = "\u{FEFF}{\n \"rules\": {}\n}\n";
637 let output = add_ignore_exports_rule_to_string(
638 Path::new(".fallowrc.json"),
639 input,
640 &[rule("src/a.ts")],
641 )
642 .unwrap();
643 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
644 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
645 assert!(output.contains("\"file\": \"src/a.ts\""));
646 }
647
648 #[test]
649 fn preserves_utf8_bom_on_toml_config() {
650 let input = "\u{FEFF}production = true\n";
651 let output =
652 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
653 .unwrap();
654 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
655 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
656 assert!(output.contains("[[ignoreExports]]"));
657 }
658
659 #[test]
660 fn no_bom_added_when_input_had_none() {
661 let input = "{\n}\n";
662 let output = add_ignore_exports_rule_to_string(
663 Path::new(".fallowrc.json"),
664 input,
665 &[rule("src/a.ts")],
666 )
667 .unwrap();
668 assert!(!output.starts_with('\u{FEFF}'));
669 }
670
671 #[test]
672 fn dedupes_existing_absolute_paths_against_relative_emissions() {
673 let config_dir = Path::new("/project");
674 let config_path = config_dir.join(".fallowrc.json");
675 let input = "{\n \"ignoreExports\": [\n { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n ]\n}\n";
676 let output =
677 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
678 assert_eq!(
679 output.matches("\"src/a.ts\"").count(),
680 0,
681 "writer must not add a relative duplicate of an existing absolute entry"
682 );
683 assert_eq!(
684 output.matches("\"/project/src/a.ts\"").count(),
685 1,
686 "existing absolute entry must remain"
687 );
688 }
689
690 #[cfg(unix)]
691 #[test]
692 fn atomic_write_preserves_existing_target_mode() {
693 use std::os::unix::fs::PermissionsExt;
694 let dir = tempfile::tempdir().unwrap();
695 let target = dir.path().join("config.json");
696 std::fs::write(&target, "{}").unwrap();
697 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
698
699 atomic_write(&target, b"{\"updated\": true}").unwrap();
700
701 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
702 assert_eq!(
703 mode, 0o644,
704 "atomic_write must preserve the target file mode"
705 );
706 assert_eq!(
707 std::fs::read_to_string(&target).unwrap(),
708 "{\"updated\": true}"
709 );
710 }
711
712 #[cfg(unix)]
713 #[test]
714 fn atomic_write_on_fresh_target_uses_default_mode() {
715 use std::os::unix::fs::PermissionsExt;
716 let dir = tempfile::tempdir().unwrap();
717 let fresh = dir.path().join("brand-new.json");
718 atomic_write(&fresh, b"{}").unwrap();
719 let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o7777;
720 assert!(mode != 0, "fresh file should have a non-zero mode");
721 }
722
723 #[test]
724 fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
725 let config_dir = Path::new("/project");
726 let config_path = config_dir.join("fallow.toml");
727 let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
728 let output =
729 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
730 assert_eq!(
731 output.matches("file = \"src/a.ts\"").count(),
732 0,
733 "writer must not add a relative duplicate of an existing absolute TOML entry"
734 );
735 assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
736 }
737}