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).
57///
58/// Preserves the target file's existing permissions on Unix. `NamedTempFile`
59/// creates the temp with `0600` by default; persisting it directly would
60/// downgrade a target previously at `0644` (or the user's local default) to
61/// owner-only, breaking shared workspaces and CI runners that rely on the
62/// pre-existing read bit. When the target does not yet exist, leave the
63/// temp's mode as the OS default (the umask-respecting permissions the
64/// process would have produced via `std::fs::write`).
65pub fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
66    let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
67    let dir = resolved.parent().unwrap_or_else(|| Path::new("."));
68    let mut tmp = NamedTempFile::new_in(dir)?;
69    tmp.write_all(content)?;
70    tmp.as_file().sync_all()?;
71    preserve_target_mode(tmp.path(), &resolved);
72    tmp.persist(&resolved).map_err(|e| e.error)?;
73    Ok(())
74}
75
76/// Copy the target file's existing permissions onto the temp file so the
77/// rename does not downgrade them. No-op when the target does not yet exist
78/// (fresh creation) or when the platform does not expose Unix file modes.
79#[cfg(unix)]
80pub fn preserve_target_mode(temp: &Path, target: &Path) {
81    use std::os::unix::fs::PermissionsExt;
82    let Ok(metadata) = std::fs::metadata(target) else {
83        return; // Target does not exist yet (fresh creation); use OS default.
84    };
85    let mode = metadata.permissions().mode();
86    let _ = std::fs::set_permissions(temp, std::fs::Permissions::from_mode(mode & 0o7777));
87}
88
89#[cfg(not(unix))]
90pub fn preserve_target_mode(_temp: &Path, _target: &Path) {
91    // File-mode bits are a Unix concept; Windows ACLs persist with the
92    // existing file when `persist` swaps in place.
93}
94
95/// Append `ignoreExports` rules to an existing fallow config file.
96///
97/// Existing entries keep their order and exact formatting. New entries are
98/// appended only when no existing entry has the same `file` value.
99pub fn add_ignore_exports_rule(path: &Path, entries: &[IgnoreExportRule]) -> ConfigWriteResult<()> {
100    if entries.is_empty() {
101        return Ok(());
102    }
103    let content = std::fs::read_to_string(path)?;
104    let rendered = add_ignore_exports_rule_to_string(path, &content, entries)?;
105    atomic_write(path, rendered.as_bytes())?;
106    Ok(())
107}
108
109/// Render the proposed content of a fallow config after appending
110/// `ignoreExports` rules, without touching the filesystem.
111///
112/// Used by [`add_ignore_exports_rule`] for the apply path and by
113/// `fallow fix --dry-run` to render a diff preview against the current
114/// on-disk content. Pass an empty string as `content` to render the
115/// create-from-scratch case.
116pub fn add_ignore_exports_rule_to_string(
117    path: &Path,
118    content: &str,
119    entries: &[IgnoreExportRule],
120) -> ConfigWriteResult<String> {
121    let had_bom = content.starts_with(BOM);
122    let body = content.strip_prefix(BOM).unwrap_or(content);
123    let config_dir = path.parent().unwrap_or_else(|| Path::new(""));
124    let rendered = if is_json_config(path) {
125        append_json_ignore_exports(body, entries, config_dir)?
126    } else {
127        append_toml_ignore_exports(body, entries, config_dir)?
128    };
129    let with_endings = preserve_line_endings(&rendered, body);
130    Ok(if had_bom {
131        let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
132        out.push(BOM);
133        out.push_str(&with_endings);
134        out
135    } else {
136        with_endings
137    })
138}
139
140const BOM: char = '\u{FEFF}';
141
142fn is_json_config(path: &Path) -> bool {
143    matches!(
144        path.extension().and_then(|ext| ext.to_str()),
145        Some("json" | "jsonc")
146    )
147}
148
149fn append_json_ignore_exports(
150    content: &str,
151    entries: &[IgnoreExportRule],
152    config_dir: &Path,
153) -> ConfigWriteResult<String> {
154    let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
155        .map_err(ConfigWriteError::JsonParse)?;
156    let object = root.object_value_or_create().ok_or_else(|| {
157        ConfigWriteError::InvalidShape("fallow config root must be an object".into())
158    })?;
159    let array = object
160        .array_value_or_create("ignoreExports")
161        .ok_or_else(|| {
162            ConfigWriteError::InvalidShape("ignoreExports must be an array in fallow config".into())
163        })?;
164
165    let mut seen = FxHashSet::default();
166    for element in array.elements() {
167        if let Some(file) = element.to_serde_value().and_then(|value| {
168            value
169                .get("file")
170                .and_then(serde_json::Value::as_str)
171                .map(str::to_owned)
172        }) {
173            record_existing_file(&mut seen, &file, config_dir);
174        }
175    }
176
177    for entry in entries {
178        if seen.insert(entry.file.clone()) {
179            array.append(CstInputValue::Object(vec![
180                ("file".to_owned(), CstInputValue::String(entry.file.clone())),
181                (
182                    "exports".to_owned(),
183                    CstInputValue::Array(
184                        entry
185                            .exports
186                            .iter()
187                            .cloned()
188                            .map(CstInputValue::String)
189                            .collect(),
190                    ),
191                ),
192            ]));
193        }
194    }
195    Ok(root.to_string())
196}
197
198fn append_toml_ignore_exports(
199    content: &str,
200    entries: &[IgnoreExportRule],
201    config_dir: &Path,
202) -> ConfigWriteResult<String> {
203    let mut doc = content
204        .parse::<DocumentMut>()
205        .map_err(ConfigWriteError::TomlParse)?;
206    match doc
207        .as_table_mut()
208        .entry("ignoreExports")
209        .or_insert(Item::None)
210    {
211        Item::None => {
212            let mut tables = ArrayOfTables::new();
213            let mut seen = FxHashSet::default();
214            append_to_array_of_tables(&mut tables, entries, &mut seen);
215            doc.as_table_mut()
216                .insert("ignoreExports", Item::ArrayOfTables(tables));
217        }
218        Item::ArrayOfTables(tables) => {
219            let mut seen = files_from_array_of_tables(tables, config_dir);
220            append_to_array_of_tables(tables, entries, &mut seen);
221        }
222        Item::Value(Value::Array(array)) => {
223            let mut seen = files_from_inline_array(array, config_dir);
224            append_to_inline_array(array, entries, &mut seen);
225        }
226        _ => {
227            return Err(ConfigWriteError::InvalidShape(
228                "ignoreExports must be an array of tables or inline array in fallow config".into(),
229            ));
230        }
231    }
232    Ok(doc.to_string())
233}
234
235fn files_from_array_of_tables(tables: &ArrayOfTables, config_dir: &Path) -> FxHashSet<String> {
236    let mut seen = FxHashSet::default();
237    for table in tables {
238        if let Some(file) = table.get("file").and_then(Item::as_str) {
239            record_existing_file(&mut seen, file, config_dir);
240        }
241    }
242    seen
243}
244
245fn append_to_array_of_tables(
246    tables: &mut ArrayOfTables,
247    entries: &[IgnoreExportRule],
248    seen: &mut FxHashSet<String>,
249) {
250    for entry in entries {
251        if seen.insert(entry.file.clone()) {
252            tables.push(toml_ignore_export_table(entry));
253        }
254    }
255}
256
257fn toml_ignore_export_table(entry: &IgnoreExportRule) -> Table {
258    let mut table = Table::new();
259    table.insert("file", toml_edit::value(entry.file.clone()));
260    table.insert("exports", Item::Value(Value::Array(exports_array(entry))));
261    table
262}
263
264fn files_from_inline_array(array: &Array, config_dir: &Path) -> FxHashSet<String> {
265    let mut seen = FxHashSet::default();
266    for value in array {
267        if let Some(file) = value
268            .as_inline_table()
269            .and_then(|table| table.get("file"))
270            .and_then(Value::as_str)
271        {
272            record_existing_file(&mut seen, file, config_dir);
273        }
274    }
275    seen
276}
277
278/// Insert an existing-entry path into the dedupe set under its canonical key.
279///
280/// The canonical key is the entry as written. When the existing entry is an
281/// absolute path that resolves under the config dir, also insert the
282/// dir-relative form so a new entry emitted by the action builder (which is
283/// always config-dir-relative) is recognised as a duplicate.
284fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
285    seen.insert(file.to_owned());
286    let path = Path::new(file);
287    if path.is_absolute()
288        && let Ok(relative) = path.strip_prefix(config_dir)
289    {
290        seen.insert(relative.to_string_lossy().replace('\\', "/"));
291    }
292}
293
294fn append_to_inline_array(
295    array: &mut Array,
296    entries: &[IgnoreExportRule],
297    seen: &mut FxHashSet<String>,
298) {
299    for entry in entries {
300        if seen.insert(entry.file.clone()) {
301            array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
302        }
303    }
304}
305
306fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
307    let mut table = InlineTable::new();
308    table.insert("file", Value::from(entry.file.clone()));
309    table.insert("exports", Value::Array(exports_array(entry)));
310    table
311}
312
313fn exports_array(entry: &IgnoreExportRule) -> Array {
314    let mut exports = Array::new();
315    for export in &entry.exports {
316        exports.push(export.as_str());
317    }
318    exports
319}
320
321fn preserve_line_endings(rendered: &str, original: &str) -> String {
322    if original.contains("\r\n") {
323        rendered.replace("\r\n", "\n").replace('\n', "\r\n")
324    } else {
325        rendered.to_owned()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn rule(file: &str) -> IgnoreExportRule {
334        IgnoreExportRule {
335            file: file.to_owned(),
336            exports: vec!["*".to_owned()],
337        }
338    }
339
340    #[test]
341    fn appends_json_ignore_exports() {
342        let output = add_ignore_exports_rule_to_string(
343            Path::new(".fallowrc.json"),
344            "{\n}\n",
345            &[rule("src/index.ts")],
346        )
347        .unwrap();
348        assert!(output.contains("\"ignoreExports\": ["));
349        assert!(output.contains("\"file\": \"src/index.ts\""));
350        assert!(output.ends_with('\n'));
351    }
352
353    #[test]
354    fn appends_jsonc_preserving_comments() {
355        let input = "{\n  // keep this\n  \"rules\": {}\n}\n";
356        let output = add_ignore_exports_rule_to_string(
357            Path::new(".fallowrc.jsonc"),
358            input,
359            &[rule("src/a.ts")],
360        )
361        .unwrap();
362        assert!(output.contains("// keep this"));
363        assert!(output.contains("\"rules\": {}"));
364        assert!(output.contains("\"file\": \"src/a.ts\""));
365    }
366
367    #[test]
368    fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
369        let input = "{\n  \"ignoreExports\": [\n    { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n  ],\n  \"rules\": {}\n}\n";
370        let output = add_ignore_exports_rule_to_string(
371            Path::new(".fallowrc.json"),
372            input,
373            &[rule("src/a.ts"), rule("src/b.ts")],
374        )
375        .unwrap();
376        assert_eq!(output.matches("\"file\": \"src/a.ts\"").count(), 1);
377        assert!(output.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
378        assert!(output.contains("\"rules\": {}"));
379    }
380
381    #[test]
382    fn appends_toml_ignore_exports() {
383        let output = add_ignore_exports_rule_to_string(
384            Path::new("fallow.toml"),
385            "production = true\n",
386            &[rule("src/index.ts")],
387        )
388        .unwrap();
389        assert!(output.contains("production = true"));
390        assert!(output.contains("[[ignoreExports]]"));
391        assert!(output.contains("file = \"src/index.ts\""));
392        assert!(output.contains("exports = [\"*\"]"));
393    }
394
395    #[test]
396    fn appends_dot_fallow_toml_ignore_exports() {
397        let output = add_ignore_exports_rule_to_string(
398            Path::new(".fallow.toml"),
399            "",
400            &[rule("src/index.ts")],
401        )
402        .unwrap();
403        assert!(output.contains("[[ignoreExports]]"));
404        assert!(output.contains("file = \"src/index.ts\""));
405    }
406
407    #[test]
408    fn merges_existing_toml_ignore_exports() {
409        let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
410        let output = add_ignore_exports_rule_to_string(
411            Path::new("fallow.toml"),
412            input,
413            &[rule("src/a.ts"), rule("src/b.ts")],
414        )
415        .unwrap();
416        assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
417        assert!(output.contains("file = \"src/b.ts\""));
418    }
419
420    #[test]
421    fn preserves_crlf_line_endings() {
422        let input = "{\r\n  \"rules\": {}\r\n}\r\n";
423        let output = add_ignore_exports_rule_to_string(
424            Path::new(".fallowrc.json"),
425            input,
426            &[rule("src/a.ts")],
427        )
428        .unwrap();
429        assert!(output.contains("\r\n"));
430        assert!(!output.contains("\r\r"));
431        assert!(!output.replace("\r\n", "").contains('\n'));
432    }
433
434    #[test]
435    fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
436        let input = "production = true\r\n";
437        let output =
438            add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
439                .unwrap();
440        assert!(output.contains("\r\n"));
441        assert!(!output.contains("\r\r"));
442        assert!(!output.replace("\r\n", "").contains('\n'));
443    }
444
445    #[test]
446    fn preserves_utf8_bom_on_json_config() {
447        let input = "\u{FEFF}{\n  \"rules\": {}\n}\n";
448        let output = add_ignore_exports_rule_to_string(
449            Path::new(".fallowrc.json"),
450            input,
451            &[rule("src/a.ts")],
452        )
453        .unwrap();
454        assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
455        assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
456        assert!(output.contains("\"file\": \"src/a.ts\""));
457    }
458
459    #[test]
460    fn preserves_utf8_bom_on_toml_config() {
461        let input = "\u{FEFF}production = true\n";
462        let output =
463            add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
464                .unwrap();
465        assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
466        assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
467        assert!(output.contains("[[ignoreExports]]"));
468    }
469
470    #[test]
471    fn no_bom_added_when_input_had_none() {
472        let input = "{\n}\n";
473        let output = add_ignore_exports_rule_to_string(
474            Path::new(".fallowrc.json"),
475            input,
476            &[rule("src/a.ts")],
477        )
478        .unwrap();
479        assert!(!output.starts_with('\u{FEFF}'));
480    }
481
482    #[test]
483    fn dedupes_existing_absolute_paths_against_relative_emissions() {
484        let config_dir = Path::new("/project");
485        let config_path = config_dir.join(".fallowrc.json");
486        let input = "{\n  \"ignoreExports\": [\n    { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n  ]\n}\n";
487        let output =
488            add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
489        assert_eq!(
490            output.matches("\"src/a.ts\"").count(),
491            0,
492            "writer must not add a relative duplicate of an existing absolute entry"
493        );
494        assert_eq!(
495            output.matches("\"/project/src/a.ts\"").count(),
496            1,
497            "existing absolute entry must remain"
498        );
499    }
500
501    #[cfg(unix)]
502    #[test]
503    fn atomic_write_preserves_existing_target_mode() {
504        // Regression: NamedTempFile defaults to 0600; without preserving
505        // the target's mode, atomic_write would silently downgrade a
506        // 0644 config file to owner-only.
507        use std::os::unix::fs::PermissionsExt;
508        let dir = tempfile::tempdir().unwrap();
509        let target = dir.path().join("config.json");
510        std::fs::write(&target, "{}").unwrap();
511        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
512
513        atomic_write(&target, b"{\"updated\": true}").unwrap();
514
515        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
516        assert_eq!(
517            mode, 0o644,
518            "atomic_write must preserve the target file mode"
519        );
520        assert_eq!(
521            std::fs::read_to_string(&target).unwrap(),
522            "{\"updated\": true}"
523        );
524    }
525
526    #[cfg(unix)]
527    #[test]
528    fn atomic_write_on_fresh_target_uses_default_mode() {
529        // When the target does not yet exist, atomic_write leaves the
530        // temp's mode as-is (the OS default for NamedTempFile is 0600).
531        // The behavior is unsurprising because the user did not have a
532        // prior mode to preserve, but the test pins the contract.
533        use std::os::unix::fs::PermissionsExt;
534        let dir = tempfile::tempdir().unwrap();
535        let fresh = dir.path().join("brand-new.json");
536        atomic_write(&fresh, b"{}").unwrap();
537        let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o7777;
538        // The mode is whatever NamedTempFile produces (currently 0o600);
539        // we assert non-zero, not a specific value, to avoid coupling the
540        // test to the tempfile crate's internal default.
541        assert!(mode != 0, "fresh file should have a non-zero mode");
542    }
543
544    #[test]
545    fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
546        let config_dir = Path::new("/project");
547        let config_path = config_dir.join("fallow.toml");
548        let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
549        let output =
550            add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
551        assert_eq!(
552            output.matches("file = \"src/a.ts\"").count(),
553            0,
554            "writer must not add a relative duplicate of an existing absolute TOML entry"
555        );
556        assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
557    }
558}