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
51pub 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#[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 }
80
81pub 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
92pub fn add_rule_pack_path(path: &Path, pack_path: &str) -> ConfigWriteResult<bool> {
94 let content = std::fs::read_to_string(path)?;
95 let (rendered, changed) = add_rule_pack_path_to_string(path, &content, pack_path)?;
96 if changed {
97 atomic_write(path, rendered.as_bytes())?;
98 }
99 Ok(changed)
100}
101
102pub fn add_rule_pack_path_to_string(
104 path: &Path,
105 content: &str,
106 pack_path: &str,
107) -> ConfigWriteResult<(String, bool)> {
108 let had_bom = content.starts_with(BOM);
109 let body = content.strip_prefix(BOM).unwrap_or(content);
110 let (rendered, changed) = if is_json_config(path) {
111 append_json_rule_pack_path(body, pack_path)?
112 } else {
113 append_toml_rule_pack_path(body, pack_path)?
114 };
115 let with_endings = preserve_line_endings(&rendered, body);
116 let final_content = if had_bom {
117 let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
118 out.push(BOM);
119 out.push_str(&with_endings);
120 out
121 } else {
122 with_endings
123 };
124 Ok((final_content, changed))
125}
126
127pub fn add_ignore_exports_rule_to_string(
129 path: &Path,
130 content: &str,
131 entries: &[IgnoreExportRule],
132) -> ConfigWriteResult<String> {
133 let had_bom = content.starts_with(BOM);
134 let body = content.strip_prefix(BOM).unwrap_or(content);
135 let config_dir = path.parent().unwrap_or_else(|| Path::new(""));
136 let rendered = if is_json_config(path) {
137 append_json_ignore_exports(body, entries, config_dir)?
138 } else {
139 append_toml_ignore_exports(body, entries, config_dir)?
140 };
141 let with_endings = preserve_line_endings(&rendered, body);
142 Ok(if had_bom {
143 let mut out = String::with_capacity(with_endings.len() + BOM.len_utf8());
144 out.push(BOM);
145 out.push_str(&with_endings);
146 out
147 } else {
148 with_endings
149 })
150}
151
152const BOM: char = '\u{FEFF}';
153
154fn is_json_config(path: &Path) -> bool {
155 matches!(
156 path.extension().and_then(|ext| ext.to_str()),
157 Some("json" | "jsonc")
158 )
159}
160
161fn append_json_ignore_exports(
162 content: &str,
163 entries: &[IgnoreExportRule],
164 config_dir: &Path,
165) -> ConfigWriteResult<String> {
166 let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
167 .map_err(ConfigWriteError::JsonParse)?;
168 let object = root.object_value_or_create().ok_or_else(|| {
169 ConfigWriteError::InvalidShape("fallow config root must be an object".into())
170 })?;
171 let array = object
172 .array_value_or_create("ignoreExports")
173 .ok_or_else(|| {
174 ConfigWriteError::InvalidShape("ignoreExports must be an array in fallow config".into())
175 })?;
176
177 let mut seen = FxHashSet::default();
178 for element in array.elements() {
179 if let Some(file) = element.to_serde_value().and_then(|value| {
180 value
181 .get("file")
182 .and_then(serde_json::Value::as_str)
183 .map(str::to_owned)
184 }) {
185 record_existing_file(&mut seen, &file, config_dir);
186 }
187 }
188
189 for entry in entries {
190 if seen.insert(entry.file.clone()) {
191 array.append(CstInputValue::Object(vec![
192 ("file".to_owned(), CstInputValue::String(entry.file.clone())),
193 (
194 "exports".to_owned(),
195 CstInputValue::Array(
196 entry
197 .exports
198 .iter()
199 .cloned()
200 .map(CstInputValue::String)
201 .collect(),
202 ),
203 ),
204 ]));
205 }
206 }
207 Ok(root.to_string())
208}
209
210fn append_json_rule_pack_path(content: &str, pack_path: &str) -> ConfigWriteResult<(String, bool)> {
211 let root = CstRootNode::parse(content, &crate::jsonc::parse_options())
212 .map_err(ConfigWriteError::JsonParse)?;
213 let object = root.object_value_or_create().ok_or_else(|| {
214 ConfigWriteError::InvalidShape("fallow config root must be an object".into())
215 })?;
216 let array = object.array_value_or_create("rulePacks").ok_or_else(|| {
217 ConfigWriteError::InvalidShape("rulePacks must be an array in fallow config".into())
218 })?;
219
220 for element in array.elements() {
221 if element
222 .to_serde_value()
223 .and_then(|value| value.as_str().map(|existing| existing == pack_path))
224 == Some(true)
225 {
226 return Ok((root.to_string(), false));
227 }
228 }
229
230 array.append(CstInputValue::String(pack_path.to_owned()));
231 Ok((root.to_string(), true))
232}
233
234fn append_toml_ignore_exports(
235 content: &str,
236 entries: &[IgnoreExportRule],
237 config_dir: &Path,
238) -> ConfigWriteResult<String> {
239 let mut doc = content
240 .parse::<DocumentMut>()
241 .map_err(ConfigWriteError::TomlParse)?;
242 match doc
243 .as_table_mut()
244 .entry("ignoreExports")
245 .or_insert(Item::None)
246 {
247 Item::None => {
248 let mut tables = ArrayOfTables::new();
249 let mut seen = FxHashSet::default();
250 append_to_array_of_tables(&mut tables, entries, &mut seen);
251 doc.as_table_mut()
252 .insert("ignoreExports", Item::ArrayOfTables(tables));
253 }
254 Item::ArrayOfTables(tables) => {
255 let mut seen = files_from_array_of_tables(tables, config_dir);
256 append_to_array_of_tables(tables, entries, &mut seen);
257 }
258 Item::Value(Value::Array(array)) => {
259 let mut seen = files_from_inline_array(array, config_dir);
260 append_to_inline_array(array, entries, &mut seen);
261 }
262 _ => {
263 return Err(ConfigWriteError::InvalidShape(
264 "ignoreExports must be an array of tables or inline array in fallow config".into(),
265 ));
266 }
267 }
268 Ok(doc.to_string())
269}
270
271fn append_toml_rule_pack_path(content: &str, pack_path: &str) -> ConfigWriteResult<(String, bool)> {
272 let mut doc = content
273 .parse::<DocumentMut>()
274 .map_err(ConfigWriteError::TomlParse)?;
275 match doc.as_table_mut().entry("rulePacks").or_insert(Item::None) {
276 Item::None => {
277 let mut array = Array::new();
278 array.push(pack_path);
279 doc.as_table_mut()
280 .insert("rulePacks", Item::Value(Value::Array(array)));
281 Ok((doc.to_string(), true))
282 }
283 Item::Value(Value::Array(array)) => {
284 if array.iter().any(|value| value.as_str() == Some(pack_path)) {
285 return Ok((doc.to_string(), false));
286 }
287 array.push(pack_path);
288 Ok((doc.to_string(), true))
289 }
290 _ => Err(ConfigWriteError::InvalidShape(
291 "rulePacks must be an array in fallow config".into(),
292 )),
293 }
294}
295
296fn files_from_array_of_tables(tables: &ArrayOfTables, config_dir: &Path) -> FxHashSet<String> {
297 let mut seen = FxHashSet::default();
298 for table in tables {
299 if let Some(file) = table.get("file").and_then(Item::as_str) {
300 record_existing_file(&mut seen, file, config_dir);
301 }
302 }
303 seen
304}
305
306fn append_to_array_of_tables(
307 tables: &mut ArrayOfTables,
308 entries: &[IgnoreExportRule],
309 seen: &mut FxHashSet<String>,
310) {
311 for entry in entries {
312 if seen.insert(entry.file.clone()) {
313 tables.push(toml_ignore_export_table(entry));
314 }
315 }
316}
317
318fn toml_ignore_export_table(entry: &IgnoreExportRule) -> Table {
319 let mut table = Table::new();
320 table.insert("file", toml_edit::value(entry.file.clone()));
321 table.insert("exports", Item::Value(Value::Array(exports_array(entry))));
322 table
323}
324
325fn files_from_inline_array(array: &Array, config_dir: &Path) -> FxHashSet<String> {
326 let mut seen = FxHashSet::default();
327 for value in array {
328 if let Some(file) = value
329 .as_inline_table()
330 .and_then(|table| table.get("file"))
331 .and_then(Value::as_str)
332 {
333 record_existing_file(&mut seen, file, config_dir);
334 }
335 }
336 seen
337}
338
339fn record_existing_file(seen: &mut FxHashSet<String>, file: &str, config_dir: &Path) {
354 seen.insert(file.to_owned());
355 if let Ok(relative) = Path::new(file).strip_prefix(config_dir) {
356 seen.insert(relative.to_string_lossy().replace('\\', "/"));
357 }
358}
359
360fn append_to_inline_array(
361 array: &mut Array,
362 entries: &[IgnoreExportRule],
363 seen: &mut FxHashSet<String>,
364) {
365 for entry in entries {
366 if seen.insert(entry.file.clone()) {
367 array.push(Value::InlineTable(toml_ignore_export_inline_table(entry)));
368 }
369 }
370}
371
372fn toml_ignore_export_inline_table(entry: &IgnoreExportRule) -> InlineTable {
373 let mut table = InlineTable::new();
374 table.insert("file", Value::from(entry.file.clone()));
375 table.insert("exports", Value::Array(exports_array(entry)));
376 table
377}
378
379fn exports_array(entry: &IgnoreExportRule) -> Array {
380 let mut exports = Array::new();
381 for export in &entry.exports {
382 exports.push(export.as_str());
383 }
384 exports
385}
386
387fn preserve_line_endings(rendered: &str, original: &str) -> String {
388 if original.contains("\r\n") {
389 rendered.replace("\r\n", "\n").replace('\n', "\r\n")
390 } else {
391 rendered.to_owned()
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn rule(file: &str) -> IgnoreExportRule {
400 IgnoreExportRule {
401 file: file.to_owned(),
402 exports: vec!["*".to_owned()],
403 }
404 }
405
406 #[test]
407 fn appends_json_ignore_exports() {
408 let output = add_ignore_exports_rule_to_string(
409 Path::new(".fallowrc.json"),
410 "{\n}\n",
411 &[rule("src/index.ts")],
412 )
413 .unwrap();
414 assert!(output.contains("\"ignoreExports\": ["));
415 assert!(output.contains("\"file\": \"src/index.ts\""));
416 assert!(output.ends_with('\n'));
417 }
418
419 #[test]
420 fn appends_json_rule_pack_path() {
421 let (output, changed) = add_rule_pack_path_to_string(
422 Path::new(".fallowrc.json"),
423 "{\n \"rules\": {}\n}\n",
424 "rule-packs/team-policy.jsonc",
425 )
426 .unwrap();
427 assert!(changed);
428 assert!(output.contains("\"rules\": {}"));
429 assert!(output.contains("\"rulePacks\": ["));
430 assert!(output.contains("\"rule-packs/team-policy.jsonc\""));
431 }
432
433 #[test]
434 fn appends_jsonc_rule_pack_path_preserving_comments() {
435 let input = "{\n // keep this\n \"rules\": {}\n}\n";
436 let (output, changed) = add_rule_pack_path_to_string(
437 Path::new(".fallowrc.jsonc"),
438 input,
439 "rule-packs/team-policy.jsonc",
440 )
441 .unwrap();
442 assert!(changed);
443 assert!(output.contains("// keep this"));
444 assert!(output.contains("\"rule-packs/team-policy.jsonc\""));
445 }
446
447 #[test]
448 fn dedupes_existing_rule_pack_path() {
449 let input = "{\n \"rulePacks\": [\"rule-packs/team-policy.jsonc\"]\n}\n";
450 let (output, changed) = add_rule_pack_path_to_string(
451 Path::new(".fallowrc.json"),
452 input,
453 "rule-packs/team-policy.jsonc",
454 )
455 .unwrap();
456 assert!(!changed);
457 assert_eq!(output.matches("rule-packs/team-policy.jsonc").count(), 1);
458 }
459
460 #[test]
461 fn appends_toml_rule_pack_path() {
462 let (output, changed) = add_rule_pack_path_to_string(
463 Path::new("fallow.toml"),
464 "production = true\n",
465 "rule-packs/team-policy.jsonc",
466 )
467 .unwrap();
468 assert!(changed);
469 assert!(output.contains("production = true"));
470 assert!(output.contains("rulePacks = [\"rule-packs/team-policy.jsonc\"]"));
471 }
472
473 #[test]
474 fn appends_jsonc_preserving_comments() {
475 let input = "{\n // keep this\n \"rules\": {}\n}\n";
476 let output = add_ignore_exports_rule_to_string(
477 Path::new(".fallowrc.jsonc"),
478 input,
479 &[rule("src/a.ts")],
480 )
481 .unwrap();
482 assert!(output.contains("// keep this"));
483 assert!(output.contains("\"rules\": {}"));
484 assert!(output.contains("\"file\": \"src/a.ts\""));
485 }
486
487 #[test]
488 fn merges_existing_json_ignore_exports_without_reordering_or_replacing() {
489 let input = "{\n \"ignoreExports\": [\n { \"file\": \"src/a.ts\", \"exports\": [\"*\"] }\n ],\n \"rules\": {}\n}\n";
490 let output = add_ignore_exports_rule_to_string(
491 Path::new(".fallowrc.json"),
492 input,
493 &[rule("src/a.ts"), rule("src/b.ts")],
494 )
495 .unwrap();
496 assert_eq!(output.matches("\"file\": \"src/a.ts\"").count(), 1);
497 assert!(output.find("\"file\": \"src/a.ts\"") < output.find("\"file\": \"src/b.ts\""));
498 assert!(output.contains("\"rules\": {}"));
499 }
500
501 #[test]
502 fn appends_toml_ignore_exports() {
503 let output = add_ignore_exports_rule_to_string(
504 Path::new("fallow.toml"),
505 "production = true\n",
506 &[rule("src/index.ts")],
507 )
508 .unwrap();
509 assert!(output.contains("production = true"));
510 assert!(output.contains("[[ignoreExports]]"));
511 assert!(output.contains("file = \"src/index.ts\""));
512 assert!(output.contains("exports = [\"*\"]"));
513 }
514
515 #[test]
516 fn appends_dot_fallow_toml_ignore_exports() {
517 let output = add_ignore_exports_rule_to_string(
518 Path::new(".fallow.toml"),
519 "",
520 &[rule("src/index.ts")],
521 )
522 .unwrap();
523 assert!(output.contains("[[ignoreExports]]"));
524 assert!(output.contains("file = \"src/index.ts\""));
525 }
526
527 #[test]
528 fn merges_existing_toml_ignore_exports() {
529 let input = "[[ignoreExports]]\nfile = \"src/a.ts\"\nexports = [\"*\"]\n";
530 let output = add_ignore_exports_rule_to_string(
531 Path::new("fallow.toml"),
532 input,
533 &[rule("src/a.ts"), rule("src/b.ts")],
534 )
535 .unwrap();
536 assert_eq!(output.matches("file = \"src/a.ts\"").count(), 1);
537 assert!(output.contains("file = \"src/b.ts\""));
538 }
539
540 #[test]
541 fn preserves_crlf_line_endings() {
542 let input = "{\r\n \"rules\": {}\r\n}\r\n";
543 let output = add_ignore_exports_rule_to_string(
544 Path::new(".fallowrc.json"),
545 input,
546 &[rule("src/a.ts")],
547 )
548 .unwrap();
549 assert!(output.contains("\r\n"));
550 assert!(!output.contains("\r\r"));
551 assert!(!output.replace("\r\n", "").contains('\n'));
552 }
553
554 #[test]
555 fn preserves_toml_crlf_line_endings_without_double_carriage_returns() {
556 let input = "production = true\r\n";
557 let output =
558 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
559 .unwrap();
560 assert!(output.contains("\r\n"));
561 assert!(!output.contains("\r\r"));
562 assert!(!output.replace("\r\n", "").contains('\n'));
563 }
564
565 #[test]
566 fn preserves_utf8_bom_on_json_config() {
567 let input = "\u{FEFF}{\n \"rules\": {}\n}\n";
568 let output = add_ignore_exports_rule_to_string(
569 Path::new(".fallowrc.json"),
570 input,
571 &[rule("src/a.ts")],
572 )
573 .unwrap();
574 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
575 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
576 assert!(output.contains("\"file\": \"src/a.ts\""));
577 }
578
579 #[test]
580 fn preserves_utf8_bom_on_toml_config() {
581 let input = "\u{FEFF}production = true\n";
582 let output =
583 add_ignore_exports_rule_to_string(Path::new("fallow.toml"), input, &[rule("src/a.ts")])
584 .unwrap();
585 assert!(output.starts_with('\u{FEFF}'), "BOM stripped from output");
586 assert!(output.matches('\u{FEFF}').count() == 1, "BOM duplicated");
587 assert!(output.contains("[[ignoreExports]]"));
588 }
589
590 #[test]
591 fn no_bom_added_when_input_had_none() {
592 let input = "{\n}\n";
593 let output = add_ignore_exports_rule_to_string(
594 Path::new(".fallowrc.json"),
595 input,
596 &[rule("src/a.ts")],
597 )
598 .unwrap();
599 assert!(!output.starts_with('\u{FEFF}'));
600 }
601
602 #[test]
603 fn dedupes_existing_absolute_paths_against_relative_emissions() {
604 let config_dir = Path::new("/project");
605 let config_path = config_dir.join(".fallowrc.json");
606 let input = "{\n \"ignoreExports\": [\n { \"file\": \"/project/src/a.ts\", \"exports\": [\"*\"] }\n ]\n}\n";
607 let output =
608 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
609 assert_eq!(
610 output.matches("\"src/a.ts\"").count(),
611 0,
612 "writer must not add a relative duplicate of an existing absolute entry"
613 );
614 assert_eq!(
615 output.matches("\"/project/src/a.ts\"").count(),
616 1,
617 "existing absolute entry must remain"
618 );
619 }
620
621 #[cfg(unix)]
622 #[test]
623 fn atomic_write_preserves_existing_target_mode() {
624 use std::os::unix::fs::PermissionsExt;
625 let dir = tempfile::tempdir().unwrap();
626 let target = dir.path().join("config.json");
627 std::fs::write(&target, "{}").unwrap();
628 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
629
630 atomic_write(&target, b"{\"updated\": true}").unwrap();
631
632 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
633 assert_eq!(
634 mode, 0o644,
635 "atomic_write must preserve the target file mode"
636 );
637 assert_eq!(
638 std::fs::read_to_string(&target).unwrap(),
639 "{\"updated\": true}"
640 );
641 }
642
643 #[cfg(unix)]
644 #[test]
645 fn atomic_write_on_fresh_target_uses_default_mode() {
646 use std::os::unix::fs::PermissionsExt;
647 let dir = tempfile::tempdir().unwrap();
648 let fresh = dir.path().join("brand-new.json");
649 atomic_write(&fresh, b"{}").unwrap();
650 let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o7777;
651 assert!(mode != 0, "fresh file should have a non-zero mode");
652 }
653
654 #[test]
655 fn dedupes_existing_absolute_paths_against_relative_emissions_toml() {
656 let config_dir = Path::new("/project");
657 let config_path = config_dir.join("fallow.toml");
658 let input = "[[ignoreExports]]\nfile = \"/project/src/a.ts\"\nexports = [\"*\"]\n";
659 let output =
660 add_ignore_exports_rule_to_string(&config_path, input, &[rule("src/a.ts")]).unwrap();
661 assert_eq!(
662 output.matches("file = \"src/a.ts\"").count(),
663 0,
664 "writer must not add a relative duplicate of an existing absolute TOML entry"
665 );
666 assert_eq!(output.matches("file = \"/project/src/a.ts\"").count(), 1);
667 }
668}