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