Skip to main content

bids_io/
writer.rs

1//! File writing utilities with conflict resolution for BIDS datasets.
2//!
3//! Supports writing file contents, creating symlinks, or copying from source
4//! files, with configurable behavior when the target path already exists.
5
6use bids_core::error::{BidsError, Result};
7use std::path::{Path, PathBuf};
8
9/// Conflict resolution strategy when the output path already exists.
10///
11/// Used by [`write_to_file()`] to determine behavior when the target file
12/// is already present on disk.
13///
14/// | Strategy | Behavior |
15/// |----------|----------|
16/// | `Fail` | Return an error (default — safest) |
17/// | `Skip` | Silently do nothing |
18/// | `Overwrite` | Delete existing file and write new one |
19/// | `Append` | Write to `name_1.ext`, `name_2.ext`, etc. |
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum ConflictStrategy {
22    /// Return an error if the file exists.
23    #[default]
24    Fail,
25    /// Silently skip writing if the file exists.
26    Skip,
27    /// Delete and replace the existing file.
28    Overwrite,
29    /// Write to a numbered variant (`file_1.ext`, `file_2.ext`, …).
30    Append,
31}
32
33impl std::fmt::Display for ConflictStrategy {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Fail => write!(f, "fail"),
37            Self::Skip => write!(f, "skip"),
38            Self::Overwrite => write!(f, "overwrite"),
39            Self::Append => write!(f, "append"),
40        }
41    }
42}
43
44/// Write contents to a file, optionally creating a symlink or copying from another file.
45///
46/// Corresponds to PyBIDS' `write_to_file()`.
47///
48/// # Errors
49///
50/// Returns an error if the file already exists (with `ConflictStrategy::Fail`),
51/// the source file doesn't exist (for `copy_from`), no data source is
52/// provided, or any I/O operation fails.
53pub fn write_to_file(
54    path: &Path,
55    contents: Option<&[u8]>,
56    link_to: Option<&Path>,
57    copy_from: Option<&Path>,
58    root: Option<&Path>,
59    conflicts: ConflictStrategy,
60) -> Result<()> {
61    let mut full_path = match root {
62        Some(r) if !path.is_absolute() => r.join(path),
63        _ => path.to_path_buf(),
64    };
65
66    if full_path.exists() || full_path.is_symlink() {
67        match conflicts {
68            ConflictStrategy::Fail => {
69                return Err(BidsError::Io(std::io::Error::new(
70                    std::io::ErrorKind::AlreadyExists,
71                    format!("A file at path {} already exists", full_path.display()),
72                )));
73            }
74            ConflictStrategy::Skip => return Ok(()),
75            ConflictStrategy::Overwrite => {
76                if full_path.is_dir() {
77                    return Ok(()); // Don't overwrite directories
78                }
79                std::fs::remove_file(&full_path)?;
80            }
81            ConflictStrategy::Append => {
82                full_path = find_append_path(&full_path);
83            }
84        }
85    }
86
87    // Create parent dirs
88    if let Some(parent) = full_path.parent() {
89        std::fs::create_dir_all(parent)?;
90    }
91
92    if let Some(link_target) = link_to {
93        #[cfg(unix)]
94        std::os::unix::fs::symlink(link_target, &full_path)?;
95        #[cfg(not(unix))]
96        std::fs::copy(link_target, &full_path)?;
97    } else if let Some(src) = copy_from {
98        if !src.exists() {
99            return Err(BidsError::Io(std::io::Error::new(
100                std::io::ErrorKind::NotFound,
101                format!("Source file '{}' does not exist", src.display()),
102            )));
103        }
104        std::fs::copy(src, &full_path)?;
105    } else if let Some(data) = contents {
106        std::fs::write(&full_path, data)?;
107    } else {
108        return Err(BidsError::Io(std::io::Error::new(
109            std::io::ErrorKind::InvalidInput,
110            "One of contents, copy_from or link_to must be provided",
111        )));
112    }
113
114    Ok(())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::fs;
121
122    #[test]
123    fn test_write_contents() {
124        let dir = std::env::temp_dir().join("bids_writer_test_contents");
125        fs::create_dir_all(&dir).unwrap();
126        let path = dir.join("test.txt");
127
128        write_to_file(
129            &path,
130            Some(b"hello"),
131            None,
132            None,
133            None,
134            ConflictStrategy::Fail,
135        )
136        .unwrap();
137        assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
138
139        fs::remove_dir_all(&dir).unwrap();
140    }
141
142    #[test]
143    fn test_conflict_fail() {
144        let dir = std::env::temp_dir().join("bids_writer_test_fail");
145        fs::create_dir_all(&dir).unwrap();
146        let path = dir.join("test.txt");
147
148        write_to_file(
149            &path,
150            Some(b"first"),
151            None,
152            None,
153            None,
154            ConflictStrategy::Fail,
155        )
156        .unwrap();
157        let result = write_to_file(
158            &path,
159            Some(b"second"),
160            None,
161            None,
162            None,
163            ConflictStrategy::Fail,
164        );
165        assert!(result.is_err());
166
167        fs::remove_dir_all(&dir).unwrap();
168    }
169
170    #[test]
171    fn test_conflict_skip() {
172        let dir = std::env::temp_dir().join("bids_writer_test_skip");
173        fs::create_dir_all(&dir).unwrap();
174        let path = dir.join("test.txt");
175
176        write_to_file(
177            &path,
178            Some(b"first"),
179            None,
180            None,
181            None,
182            ConflictStrategy::Fail,
183        )
184        .unwrap();
185        write_to_file(
186            &path,
187            Some(b"second"),
188            None,
189            None,
190            None,
191            ConflictStrategy::Skip,
192        )
193        .unwrap();
194        assert_eq!(fs::read_to_string(&path).unwrap(), "first"); // unchanged
195
196        fs::remove_dir_all(&dir).unwrap();
197    }
198
199    #[test]
200    fn test_conflict_overwrite() {
201        let dir = std::env::temp_dir().join("bids_writer_test_overwrite");
202        fs::create_dir_all(&dir).unwrap();
203        let path = dir.join("test.txt");
204
205        write_to_file(
206            &path,
207            Some(b"first"),
208            None,
209            None,
210            None,
211            ConflictStrategy::Fail,
212        )
213        .unwrap();
214        write_to_file(
215            &path,
216            Some(b"second"),
217            None,
218            None,
219            None,
220            ConflictStrategy::Overwrite,
221        )
222        .unwrap();
223        assert_eq!(fs::read_to_string(&path).unwrap(), "second");
224
225        fs::remove_dir_all(&dir).unwrap();
226    }
227
228    #[test]
229    fn test_conflict_append() {
230        let dir = std::env::temp_dir().join("bids_writer_test_append");
231        fs::create_dir_all(&dir).unwrap();
232        let path = dir.join("test.txt");
233
234        write_to_file(
235            &path,
236            Some(b"first"),
237            None,
238            None,
239            None,
240            ConflictStrategy::Fail,
241        )
242        .unwrap();
243        write_to_file(
244            &path,
245            Some(b"second"),
246            None,
247            None,
248            None,
249            ConflictStrategy::Append,
250        )
251        .unwrap();
252
253        // Original should be unchanged, new file should exist as test_1.txt
254        assert_eq!(fs::read_to_string(&path).unwrap(), "first");
255        assert_eq!(
256            fs::read_to_string(dir.join("test_1.txt")).unwrap(),
257            "second"
258        );
259
260        fs::remove_dir_all(&dir).unwrap();
261    }
262
263    #[test]
264    fn test_creates_parent_dirs() {
265        let dir = std::env::temp_dir().join("bids_writer_test_parents");
266        let path = dir.join("a").join("b").join("c").join("test.txt");
267
268        write_to_file(
269            &path,
270            Some(b"deep"),
271            None,
272            None,
273            None,
274            ConflictStrategy::Fail,
275        )
276        .unwrap();
277        assert_eq!(fs::read_to_string(&path).unwrap(), "deep");
278
279        fs::remove_dir_all(&dir).unwrap();
280    }
281
282    #[test]
283    fn test_copy_from() {
284        let dir = std::env::temp_dir().join("bids_writer_test_copy");
285        fs::create_dir_all(&dir).unwrap();
286        let src = dir.join("source.txt");
287        let dst = dir.join("dest.txt");
288
289        fs::write(&src, b"source content").unwrap();
290        write_to_file(&dst, None, None, Some(&src), None, ConflictStrategy::Fail).unwrap();
291        assert_eq!(fs::read_to_string(&dst).unwrap(), "source content");
292
293        fs::remove_dir_all(&dir).unwrap();
294    }
295
296    #[test]
297    fn test_no_source_errors() {
298        let dir = std::env::temp_dir().join("bids_writer_test_nosrc");
299        fs::create_dir_all(&dir).unwrap();
300        let path = dir.join("test.txt");
301
302        let result = write_to_file(&path, None, None, None, None, ConflictStrategy::Fail);
303        assert!(result.is_err());
304
305        fs::remove_dir_all(&dir).unwrap();
306    }
307}
308
309fn find_append_path(path: &Path) -> PathBuf {
310    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
311    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
312    let parent = path.parent().unwrap_or(Path::new("."));
313
314    for i in 1..i32::MAX {
315        let new_name = if ext.is_empty() {
316            format!("{stem}_{i}")
317        } else {
318            format!("{stem}_{i}.{ext}")
319        };
320        let candidate = parent.join(new_name);
321        if !candidate.exists() {
322            return candidate;
323        }
324    }
325    path.to_path_buf() // Fallback
326}