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