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};
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
188    let mut seen = FxHashSet::default();
189    for element in array.elements() {
190        if let Some(file) = element.to_serde_value().and_then(|value| {
191            value
192                .get("file")
193                .and_then(serde_json::Value::as_str)
194                .map(str::to_owned)
195        }) {
196            record_existing_file(&mut seen, &file, config_dir);
197        }
198    }
199
200    for entry in entries {
201        if seen.insert(entry.file.clone()) {
202            array.append(CstInputValue::Object(vec![
203                ("file".to_owned(), CstInputValue::String(entry.file.clone())),
204                (
205                    "exports".to_owned(),
206                    CstInputValue::Array(
207                        entry
208                            .exports
209                            .iter()
210                            .cloned()
211                            .map(CstInputValue::String)
212                            .collect(),
213                    ),
214                ),
215            ]));
216        }
217    }
218    Ok(root.to_string())
219}
220
221fn append_json_rule_pack_path(content: &str, pack_path: &str) -> ConfigWriteResult<(String, bool)> {
222    let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
223        .map_err(ConfigWriteError::JsonParse)?;
224    let object = root.object_value_or_create().ok_or_else(|| {
225        ConfigWriteError::InvalidShape("fallow config root must be an object".into())
226    })?;
227    let array = object.array_value_or_create("rulePacks").ok_or_else(|| {
228        ConfigWriteError::InvalidShape("rulePacks must be an array in fallow config".into())
229    })?;
230
231    for element in array.elements() {
232        if element
233            .to_serde_value()
234            .and_then(|value| value.as_str().map(|existing| existing == pack_path))
235            == Some(true)
236        {
237            return Ok((root.to_string(), false));
238        }
239    }
240
241    array.append(CstInputValue::String(pack_path.to_owned()));
242    Ok((root.to_string(), true))
243}
244
245fn append_toml_ignore_exports(
246    content: &str,
247    entries: &[IgnoreExportRule],
248    config_dir: &Path,
249) -> ConfigWriteResult<String> {
250    let mut doc = content
251        .parse::<DocumentMut>()
252        .map_err(ConfigWriteError::TomlParse)?;
253    match doc
254        .as_table_mut()
255        .entry("ignoreExports")
256        .or_insert(Item::None)
257    {
258        Item::None => {
259            let mut tables = ArrayOfTables::new();
260            let mut seen = FxHashSet::default();
261            append_to_array_of_tables(&mut tables, entries, &mut seen);
262            doc.as_table_mut()
263                .insert("ignoreExports", Item::ArrayOfTables(tables));
264        }
265        Item::ArrayOfTables(tables) => {
266            let mut seen = files_from_array_of_tables(tables, config_dir);
267            append_to_array_of_tables(tables, entries, &mut seen);
268        }
269        Item::Value(Value::Array(array)) => {
270            let mut seen = files_from_inline_array(array, config_dir);
271            append_to_inline_array(array, entries, &mut seen);
272        }
273        _ => {
274            return Err(ConfigWriteError::InvalidShape(
275                "ignoreExports must be an array of tables or inline array in fallow config".into(),
276            ));
277        }
278    }
279    Ok(doc.to_string())
280}
281
282fn append_toml_rule_pack_path(content: &str, pack_path: &str) -> ConfigWriteResult<(String, bool)> {
283    let mut doc = content
284        .parse::<DocumentMut>()
285        .map_err(ConfigWriteError::TomlParse)?;
286    match doc.as_table_mut().entry("rulePacks").or_insert(Item::None) {
287        Item::None => {
288            let mut array = Array::new();
289            array.push(pack_path);
290            doc.as_table_mut()
291                .insert("rulePacks", Item::Value(Value::Array(array)));
292            Ok((doc.to_string(), true))
293        }
294        Item::Value(Value::Array(array)) => {
295            if array.iter().any(|value| value.as_str() == Some(pack_path)) {
296                return Ok((doc.to_string(), false));
297            }
298            array.push(pack_path);
299            Ok((doc.to_string(), true))
300        }
301        _ => Err(ConfigWriteError::InvalidShape(
302            "rulePacks must be an array in fallow config".into(),
303        )),
304    }
305}
306
307fn files_from_array_of_tables(tables: &ArrayOfTables, config_dir: &Path) -> FxHashSet<String> {
308    let mut seen = FxHashSet::default();
309    for table in tables {
310        if let Some(file) = table.get("file").and_then(Item::as_str) {
311            record_existing_file(&mut seen, file, config_dir);
312        }
313    }
314    seen
315}
316
317fn append_to_array_of_tables(
318    tables: &mut ArrayOfTables,
319    entries: &[IgnoreExportRule],
320    seen: &mut FxHashSet<String>,
321) {
322    for entry in entries {
323        if seen.insert(entry.file.clone()) {
324            tables.push(toml_ignore_export_table(entry));
325        }
326    }
327}
328
329fn toml_ignore_export_table(entry: &IgnoreExportRule) -> Table {
330    let mut table = Table::new();
331    table.insert("file", toml_edit::value(entry.file.clone()));
332    table.insert("exports", Item::Value(Value::Array(exports_array(entry))));
333    table
334}
335
336fn files_from_inline_array(array: &Array, config_dir: &Path) -> FxHashSet<String> {
337    let mut seen = FxHashSet::default();
338    for value in array {
339        if let Some(file) = value
340            .as_inline_table()
341            .and_then(|table| table.get("file"))
342            .and_then(Value::as_str)
343        {
344            record_existing_file(&mut seen, file, config_dir);
345        }
346    }
347    seen
348}
349
350/// Insert an existing-entry path into the dedupe set under its canonical key.
351///
352/// The canonical key is the entry as written. When the existing entry resolves
353/// under the config dir, also insert the dir-relative form so a new entry
354/// emitted by the action builder (which is always config-dir-relative) is
355/// recognised as a duplicate.
356///
357/// `strip_prefix` is called unconditionally: it naturally returns `Err` for
358/// values that do not start with `config_dir` (already-relative entries,
359/// entries pointing outside the project), so a `Path::is_absolute` /
360/// `Path::has_root` pre-gate is redundant. The pre-gate was actively wrong
361/// on Windows because `Path::is_absolute` requires a drive letter (`C:\`),
362/// so a POSIX-rooted entry like `/project/src/a.ts` written from Linux CI
363/// silently skipped the dir-relative dedup key.
364fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
365    seen.insert(file.to_owned());
366    if let Ok(relative) = Path::new(file).strip_prefix(config_dir) {
367        seen.insert(relative.to_string_lossy().replace('\\', "/"));
368    }
369}
370
371fn append_to_inline_array(
372    array: &mut Array,
373    entries: &[IgnoreExportRule],
374    seen: &mut FxHashSet<String>,
375) {
376    for entry in entries {
377        if seen.insert(entry.file.clone()) {
378            array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
379        }
380    }
381}
382
383fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
384    let mut table = InlineTable::new();
385    table.insert("file", Value::from(entry.file.clone()));
386    table.insert("exports", Value::Array(exports_array(entry)));
387    table
388}
389
390fn exports_array(entry: &IgnoreExportRule) -> Array {
391    let mut exports = Array::new();
392    for export in &entry.exports {
393        exports.push(export.as_str());
394    }
395    exports
396}
397
398fn preserve_line_endings(rendered: &str, original: &str) -> String {
399    if original.contains("\r\n") {
400        rendered.replace("\r\n", "\n").replace('\n', "\r\n")
401    } else {
402        rendered.to_owned()
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn rule(file: &str) -> IgnoreExportRule {
411        IgnoreExportRule {
412            file: file.to_owned(),
413            exports: vec!["*".to_owned()],
414        }
415    }
416
417    #[test]
418    fn appends_json_ignore_exports() {
419        let output = add_ignore_exports_rule_to_string(
420            Path::new(".fallowrc.json"),
421            "{\n}\n",
422            &[rule("src/index.ts")],
423        )
424        .unwrap();
425        assert!(output.contains("\"ignoreExports\": ["));
426        assert!(output.contains("\"file\": \"src/index.ts\""));
427        assert!(output.ends_with('\n'));
428    }
429
430    #[test]
431    fn appends_json_rule_pack_path() {
432        let (output, changed) = add_rule_pack_path_to_string(
433            Path::new(".fallowrc.json"),
434            "{\n  \"rules\": {}\n}\n",
435            "rule-packs/team-policy.jsonc",
436        )
437        .unwrap();
438        assert!(changed);
439        assert!(output.contains("\"rules\": {}"));
440        assert!(output.contains("\"rulePacks\": ["));
441        assert!(output.contains("\"rule-packs/team-policy.jsonc\""));
442    }
443
444    #[test]
445    fn appends_jsonc_rule_pack_path_preserving_comments() {
446        let input = "{\n  // keep this\n  \"rules\": {}\n}\n";
447        let (output, changed) = add_rule_pack_path_to_string(
448            Path::new(".fallowrc.jsonc"),
449            input,
450            "rule-packs/team-policy.jsonc",
451        )
452        .unwrap();
453        assert!(changed);
454        assert!(output.contains("// keep this"));
455        assert!(output.contains("\"rule-packs/team-policy.jsonc\""));
456    }
457
458    #[test]
459    fn dedupes_existing_rule_pack_path() {
460        let input = "{\n  \"rulePacks\": [\"rule-packs/team-policy.jsonc\"]\n}\n";
461        let (output, changed) = add_rule_pack_path_to_string(
462            Path::new(".fallowrc.json"),
463            input,
464            "rule-packs/team-policy.jsonc",
465        )
466        .unwrap();
467        assert!(!changed);
468        assert_eq!(output.matches("rule-packs/team-policy.jsonc").count(), 1);
469    }
470
471    #[test]
472    fn appends_toml_rule_pack_path() {
473        let (output, changed) = add_rule_pack_path_to_string(
474            Path::new("fallow.toml"),
475            "production = true\n",
476            "rule-packs/team-policy.jsonc",
477        )
478        .unwrap();
479        assert!(changed);
480        assert!(output.contains("production = true"));
481        assert!(output.contains("rulePacks = [\"rule-packs/team-policy.jsonc\"]"));
482    }
483
484    #[test]
485    fn appends_jsonc_preserving_comments() {
486        let input = "{\n  // keep this\n  \"rules\": {}\n}\n";
487        let output = add_ignore_exports_rule_to_string(
488            Path::new(".fallowrc.jsonc"),
489            input,
490            &[rule("src/a.ts")],
491        )
492        .unwrap();
493        assert!(output.contains("// keep this"));
494        assert!(output.contains("\"rules\": {}"));
495        assert!(output.contains("\"file\": \"src/a.ts\""));
496    }
497
498    #[test]
499    fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
500        let input = "{\n  \"ignoreExports\": [\n    { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n  ],\n  \"rules\": {}\n}\n";
501        let output = add_ignore_exports_rule_to_string(
502            Path::new(".fallowrc.json"),
503            input,
504            &[rule("src/a.ts"), rule("src/b.ts")],
505        )
506        .unwrap();
507        assert_eq!(output.matches("\"file\": \"src/a.ts\"").count(), 1);
508        assert!(output.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
509        assert!(output.contains("\"rules\": {}"));
510    }
511
512    #[test]
513    fn appends_toml_ignore_exports() {
514        let output = add_ignore_exports_rule_to_string(
515            Path::new("fallow.toml"),
516            "production = true\n",
517            &[rule("src/index.ts")],
518        )
519        .unwrap();
520        assert!(output.contains("production = true"));
521        assert!(output.contains("[[ignoreExports]]"));
522        assert!(output.contains("file = \"src/index.ts\""));
523        assert!(output.contains("exports = [\"*\"]"));
524    }
525
526    #[test]
527    fn appends_dot_fallow_toml_ignore_exports() {
528        let output = add_ignore_exports_rule_to_string(
529            Path::new(".fallow.toml"),
530            "",
531            &[rule("src/index.ts")],
532        )
533        .unwrap();
534        assert!(output.contains("[[ignoreExports]]"));
535        assert!(output.contains("file = \"src/index.ts\""));
536    }
537
538    #[test]
539    fn merges_existing_toml_ignore_exports() {
540        let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
541        let output = add_ignore_exports_rule_to_string(
542            Path::new("fallow.toml"),
543            input,
544            &[rule("src/a.ts"), rule("src/b.ts")],
545        )
546        .unwrap();
547        assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
548        assert!(output.contains("file = \"src/b.ts\""));
549    }
550
551    #[test]
552    fn preserves_crlf_line_endings() {
553        let input = "{\r\n  \"rules\": {}\r\n}\r\n";
554        let output = add_ignore_exports_rule_to_string(
555            Path::new(".fallowrc.json"),
556            input,
557            &[rule("src/a.ts")],
558        )
559        .unwrap();
560        assert!(output.contains("\r\n"));
561        assert!(!output.contains("\r\r"));
562        assert!(!output.replace("\r\n", "").contains('\n'));
563    }
564
565    #[test]
566    fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
567        let input = "production = true\r\n";
568        let output =
569            add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
570                .unwrap();
571        assert!(output.contains("\r\n"));
572        assert!(!output.contains("\r\r"));
573        assert!(!output.replace("\r\n", "").contains('\n'));
574    }
575
576    #[test]
577    fn preserves_utf8_bom_on_json_config() {
578        let input = "\u{FEFF}{\n  \"rules\": {}\n}\n";
579        let output = add_ignore_exports_rule_to_string(
580            Path::new(".fallowrc.json"),
581            input,
582            &[rule("src/a.ts")],
583        )
584        .unwrap();
585        assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
586        assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
587        assert!(output.contains("\"file\": \"src/a.ts\""));
588    }
589
590    #[test]
591    fn preserves_utf8_bom_on_toml_config() {
592        let input = "\u{FEFF}production = true\n";
593        let output =
594            add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
595                .unwrap();
596        assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
597        assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
598        assert!(output.contains("[[ignoreExports]]"));
599    }
600
601    #[test]
602    fn no_bom_added_when_input_had_none() {
603        let input = "{\n}\n";
604        let output = add_ignore_exports_rule_to_string(
605            Path::new(".fallowrc.json"),
606            input,
607            &[rule("src/a.ts")],
608        )
609        .unwrap();
610        assert!(!output.starts_with('\u{FEFF}'));
611    }
612
613    #[test]
614    fn dedupes_existing_absolute_paths_against_relative_emissions() {
615        let config_dir = Path::new("/project");
616        let config_path = config_dir.join(".fallowrc.json");
617        let input = "{\n  \"ignoreExports\": [\n    { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n  ]\n}\n";
618        let output =
619            add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
620        assert_eq!(
621            output.matches("\"src/a.ts\"").count(),
622            0,
623            "writer must not add a relative duplicate of an existing absolute entry"
624        );
625        assert_eq!(
626            output.matches("\"/project/src/a.ts\"").count(),
627            1,
628            "existing absolute entry must remain"
629        );
630    }
631
632    #[cfg(unix)]
633    #[test]
634    fn atomic_write_preserves_existing_target_mode() {
635        use std::os::unix::fs::PermissionsExt;
636        let dir = tempfile::tempdir().unwrap();
637        let target = dir.path().join("config.json");
638        std::fs::write(&target, "{}").unwrap();
639        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
640
641        atomic_write(&target, b"{\"updated\": true}").unwrap();
642
643        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
644        assert_eq!(
645            mode, 0o644,
646            "atomic_write must preserve the target file mode"
647        );
648        assert_eq!(
649            std::fs::read_to_string(&target).unwrap(),
650            "{\"updated\": true}"
651        );
652    }
653
654    #[cfg(unix)]
655    #[test]
656    fn atomic_write_on_fresh_target_uses_default_mode() {
657        use std::os::unix::fs::PermissionsExt;
658        let dir = tempfile::tempdir().unwrap();
659        let fresh = dir.path().join("brand-new.json");
660        atomic_write(&fresh, b"{}").unwrap();
661        let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o7777;
662        assert!(mode != 0, "fresh file should have a non-zero mode");
663    }
664
665    #[test]
666    fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
667        let config_dir = Path::new("/project");
668        let config_path = config_dir.join("fallow.toml");
669        let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
670        let output =
671            add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
672        assert_eq!(
673            output.matches("file = \"src/a.ts\"").count(),
674            0,
675            "writer must not add a relative duplicate of an existing absolute TOML entry"
676        );
677        assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
678    }
679}