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