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 resolves
281/// under the config dir, also insert the dir-relative form so a new entry
282/// emitted by the action builder (which is always config-dir-relative) is
283/// recognised as a duplicate.
284///
285/// `strip_prefix` is called unconditionally: it naturally returns `Err` for
286/// values that do not start with `config_dir` (already-relative entries,
287/// entries pointing outside the project), so a `Path::is_absolute` /
288/// `Path::has_root` pre-gate is redundant. The pre-gate was actively wrong
289/// on Windows because `Path::is_absolute` requires a drive letter (`C:\`),
290/// so a POSIX-rooted entry like `/project/src/a.ts` written from Linux CI
291/// silently skipped the dir-relative dedup key.
292fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
293    seen.insert(file.to_owned());
294    if let Ok(relative) = Path::new(file).strip_prefix(config_dir) {
295        seen.insert(relative.to_string_lossy().replace('\\', "/"));
296    }
297}
298
299fn append_to_inline_array(
300    array: &mut Array,
301    entries: &[IgnoreExportRule],
302    seen: &mut FxHashSet<String>,
303) {
304    for entry in entries {
305        if seen.insert(entry.file.clone()) {
306            array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
307        }
308    }
309}
310
311fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
312    let mut table = InlineTable::new();
313    table.insert("file", Value::from(entry.file.clone()));
314    table.insert("exports", Value::Array(exports_array(entry)));
315    table
316}
317
318fn exports_array(entry: &IgnoreExportRule) -> Array {
319    let mut exports = Array::new();
320    for export in &entry.exports {
321        exports.push(export.as_str());
322    }
323    exports
324}
325
326fn preserve_line_endings(rendered: &str, original: &str) -> String {
327    if original.contains("\r\n") {
328        rendered.replace("\r\n", "\n").replace('\n', "\r\n")
329    } else {
330        rendered.to_owned()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn rule(file: &str) -> IgnoreExportRule {
339        IgnoreExportRule {
340            file: file.to_owned(),
341            exports: vec!["*".to_owned()],
342        }
343    }
344
345    #[test]
346    fn appends_json_ignore_exports() {
347        let output = add_ignore_exports_rule_to_string(
348            Path::new(".fallowrc.json"),
349            "{\n}\n",
350            &[rule("src/index.ts")],
351        )
352        .unwrap();
353        assert!(output.contains("\"ignoreExports\": ["));
354        assert!(output.contains("\"file\": \"src/index.ts\""));
355        assert!(output.ends_with('\n'));
356    }
357
358    #[test]
359    fn appends_jsonc_preserving_comments() {
360        let input = "{\n  // keep this\n  \"rules\": {}\n}\n";
361        let output = add_ignore_exports_rule_to_string(
362            Path::new(".fallowrc.jsonc"),
363            input,
364            &[rule("src/a.ts")],
365        )
366        .unwrap();
367        assert!(output.contains("// keep this"));
368        assert!(output.contains("\"rules\": {}"));
369        assert!(output.contains("\"file\": \"src/a.ts\""));
370    }
371
372    #[test]
373    fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
374        let input = "{\n  \"ignoreExports\": [\n    { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n  ],\n  \"rules\": {}\n}\n";
375        let output = add_ignore_exports_rule_to_string(
376            Path::new(".fallowrc.json"),
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.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
383        assert!(output.contains("\"rules\": {}"));
384    }
385
386    #[test]
387    fn appends_toml_ignore_exports() {
388        let output = add_ignore_exports_rule_to_string(
389            Path::new("fallow.toml"),
390            "production = true\n",
391            &[rule("src/index.ts")],
392        )
393        .unwrap();
394        assert!(output.contains("production = true"));
395        assert!(output.contains("[[ignoreExports]]"));
396        assert!(output.contains("file = \"src/index.ts\""));
397        assert!(output.contains("exports = [\"*\"]"));
398    }
399
400    #[test]
401    fn appends_dot_fallow_toml_ignore_exports() {
402        let output = add_ignore_exports_rule_to_string(
403            Path::new(".fallow.toml"),
404            "",
405            &[rule("src/index.ts")],
406        )
407        .unwrap();
408        assert!(output.contains("[[ignoreExports]]"));
409        assert!(output.contains("file = \"src/index.ts\""));
410    }
411
412    #[test]
413    fn merges_existing_toml_ignore_exports() {
414        let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
415        let output = add_ignore_exports_rule_to_string(
416            Path::new("fallow.toml"),
417            input,
418            &[rule("src/a.ts"), rule("src/b.ts")],
419        )
420        .unwrap();
421        assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
422        assert!(output.contains("file = \"src/b.ts\""));
423    }
424
425    #[test]
426    fn preserves_crlf_line_endings() {
427        let input = "{\r\n  \"rules\": {}\r\n}\r\n";
428        let output = add_ignore_exports_rule_to_string(
429            Path::new(".fallowrc.json"),
430            input,
431            &[rule("src/a.ts")],
432        )
433        .unwrap();
434        assert!(output.contains("\r\n"));
435        assert!(!output.contains("\r\r"));
436        assert!(!output.replace("\r\n", "").contains('\n'));
437    }
438
439    #[test]
440    fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
441        let input = "production = true\r\n";
442        let output =
443            add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
444                .unwrap();
445        assert!(output.contains("\r\n"));
446        assert!(!output.contains("\r\r"));
447        assert!(!output.replace("\r\n", "").contains('\n'));
448    }
449
450    #[test]
451    fn preserves_utf8_bom_on_json_config() {
452        let input = "\u{FEFF}{\n  \"rules\": {}\n}\n";
453        let output = add_ignore_exports_rule_to_string(
454            Path::new(".fallowrc.json"),
455            input,
456            &[rule("src/a.ts")],
457        )
458        .unwrap();
459        assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
460        assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
461        assert!(output.contains("\"file\": \"src/a.ts\""));
462    }
463
464    #[test]
465    fn preserves_utf8_bom_on_toml_config() {
466        let input = "\u{FEFF}production = true\n";
467        let output =
468            add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
469                .unwrap();
470        assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
471        assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
472        assert!(output.contains("[[ignoreExports]]"));
473    }
474
475    #[test]
476    fn no_bom_added_when_input_had_none() {
477        let input = "{\n}\n";
478        let output = add_ignore_exports_rule_to_string(
479            Path::new(".fallowrc.json"),
480            input,
481            &[rule("src/a.ts")],
482        )
483        .unwrap();
484        assert!(!output.starts_with('\u{FEFF}'));
485    }
486
487    #[test]
488    fn dedupes_existing_absolute_paths_against_relative_emissions() {
489        let config_dir = Path::new("/project");
490        let config_path = config_dir.join(".fallowrc.json");
491        let input = "{\n  \"ignoreExports\": [\n    { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n  ]\n}\n";
492        let output =
493            add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
494        assert_eq!(
495            output.matches("\"src/a.ts\"").count(),
496            0,
497            "writer must not add a relative duplicate of an existing absolute entry"
498        );
499        assert_eq!(
500            output.matches("\"/project/src/a.ts\"").count(),
501            1,
502            "existing absolute entry must remain"
503        );
504    }
505
506    #[cfg(unix)]
507    #[test]
508    fn atomic_write_preserves_existing_target_mode() {
509        // Regression: NamedTempFile defaults to 0600; without preserving
510        // the target's mode, atomic_write would silently downgrade a
511        // 0644 config file to owner-only.
512        use std::os::unix::fs::PermissionsExt;
513        let dir = tempfile::tempdir().unwrap();
514        let target = dir.path().join("config.json");
515        std::fs::write(&target, "{}").unwrap();
516        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
517
518        atomic_write(&target, b"{\"updated\": true}").unwrap();
519
520        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
521        assert_eq!(
522            mode, 0o644,
523            "atomic_write must preserve the target file mode"
524        );
525        assert_eq!(
526            std::fs::read_to_string(&target).unwrap(),
527            "{\"updated\": true}"
528        );
529    }
530
531    #[cfg(unix)]
532    #[test]
533    fn atomic_write_on_fresh_target_uses_default_mode() {
534        // When the target does not yet exist, atomic_write leaves the
535        // temp's mode as-is (the OS default for NamedTempFile is 0600).
536        // The behavior is unsurprising because the user did not have a
537        // prior mode to preserve, but the test pins the contract.
538        use std::os::unix::fs::PermissionsExt;
539        let dir = tempfile::tempdir().unwrap();
540        let fresh = dir.path().join("brand-new.json");
541        atomic_write(&fresh, b"{}").unwrap();
542        let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o7777;
543        // The mode is whatever NamedTempFile produces (currently 0o600);
544        // we assert non-zero, not a specific value, to avoid coupling the
545        // test to the tempfile crate's internal default.
546        assert!(mode != 0, "fresh file should have a non-zero mode");
547    }
548
549    #[test]
550    fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
551        let config_dir = Path::new("/project");
552        let config_path = config_dir.join("fallow.toml");
553        let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
554        let output =
555            add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
556        assert_eq!(
557            output.matches("file = \"src/a.ts\"").count(),
558            0,
559            "writer must not add a relative duplicate of an existing absolute TOML entry"
560        );
561        assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
562    }
563}