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