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