Skip to main content

fallow_config/
config_writer.rs

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/// Failure while editing a fallow config file in place (`fallow fix` config
14/// actions such as appending `ignoreExports` entries).
15#[derive(Debug)]
16pub enum ConfigWriteError {
17    /// Reading or writing the config file failed.
18    Io(std::io::Error),
19    /// The existing JSON/JSONC config could not be parsed, so an edit cannot
20    /// be applied without risking data loss.
21    JsonParse(jsonc_parser::errors::ParseError),
22    /// The existing TOML config could not be parsed.
23    TomlParse(toml_edit::TomlError),
24    /// The config parsed but a key has a shape the writer cannot edit (e.g.
25    /// a non-object root, or `ignoreExports` that is not an array). The
26    /// message describes the expected shape.
27    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
58/// Result alias for config-file editing operations.
59pub type ConfigWriteResult<T> = Result<T, ConfigWriteError>;
60
61/// Atomically write content to a file via a temporary file and rename.
62///
63/// Resolves symlinks first and preserves the target file's existing permissions on Unix.
64pub 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/// Copy the target file's existing permissions onto the temp file.
76#[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/// Copy the target file's existing permissions onto the temp file.
87#[cfg(not(unix))]
88pub fn preserve_target_mode(_temp: &Path, _target: &Path) {
89    // File-mode bits are a Unix concept; Windows ACLs persist with the existing file.
90}
91
92/// Append `ignoreExports` rules to an existing fallow config file.
93pub 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
103/// Append a rule-pack path to an existing fallow config file.
104pub 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
113/// Render the proposed content of a fallow config after appending a `rulePacks` entry.
114pub 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
138/// Render the proposed content of a fallow config after appending `ignoreExports` rules.
139pub 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
358/// Insert an existing-entry path into the dedupe set under its canonical key.
359///
360/// The canonical key is the entry as written. When the existing entry resolves
361/// under the config dir, also insert the dir-relative form so a new entry
362/// emitted by the action builder (which is always config-dir-relative) is
363/// recognised as a duplicate.
364///
365/// `strip_prefix` is called unconditionally: it naturally returns `Err` for
366/// values that do not start with `config_dir` (already-relative entries,
367/// entries pointing outside the project), so a `Path::is_absolute` /
368/// `Path::has_root` pre-gate is redundant. The pre-gate was actively wrong
369/// on Windows because `Path::is_absolute` requires a drive letter (`C:\`),
370/// so a POSIX-rooted entry like `/project/src/a.ts` written from Linux CI
371/// silently skipped the dir-relative dedup key.
372fn 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}