Skip to main content

cordis_include/
file.rs

1//! Config files: format detection, ordered round-trips, atomic writes.
2
3use crate::error::{IncludeError, Result};
4use crate::lock;
5use crate::node::Node;
6use crate::options::EntryOptions;
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Condvar, Mutex, Weak};
13use std::time::{Duration, Instant};
14
15/// The serialization format of a [`LoaderFile`], picked from its extension.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FileFormat {
18    /// `.yml` / `.yaml`
19    Yaml,
20    /// `.json`
21    Json,
22}
23
24/// The parsed content of one config file: the entry list plus any unknown
25/// top-level keys, which are preserved on write-back.
26#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
27pub struct Document {
28    /// The entry tree serialized as a list, in file order.
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub entries: Vec<EntryOptions>,
31    /// Unknown top-level keys, round-tripped untouched.
32    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
33    pub extra: IndexMap<String, Node>,
34}
35
36impl Document {
37    /// A document holding just the given entries.
38    pub fn with_entries(entries: Vec<EntryOptions>) -> Self {
39        Self {
40            entries,
41            extra: IndexMap::new(),
42        }
43    }
44}
45
46/// Shared state behind a [`LoaderFile`] handle.
47struct FileInner {
48    path: PathBuf,
49    format: FileFormat,
50    suspend: Mutex<usize>,
51    deferred: Mutex<DeferredState>,
52    deferred_signal: Condvar,
53    flusher: Mutex<Option<std::thread::JoinHandle<()>>>,
54}
55
56/// State of the coalescing writer behind [`LoaderFile::write_deferred`].
57#[derive(Default)]
58struct DeferredState {
59    /// Latest queued document and its flush deadline.
60    pending: Option<(Document, Instant)>,
61    /// Monotonic counters so [`LoaderFile::flush_deferred`] can await a
62    /// specific queue state.
63    queued: u64,
64    flushed: u64,
65    /// A flush is currently running outside the lock.
66    writing: bool,
67    /// The owning file is gone; the flusher exits.
68    closed: bool,
69    /// The last flush error, surfaced through
70    /// [`LoaderFile::last_deferred_error`].
71    last_error: Option<String>,
72}
73
74/// A handle to one config file on disk.
75///
76/// Handles are cheap to clone and share path, format, and suspend state, so
77/// several trees (or the loader and the watcher) can coordinate writes
78/// through the same file. While any [`FileSuspendGuard`] is held,
79/// [`LoaderFile::write`] is a silent no-op — the file-level half of breaking
80/// the write → watch → write feedback loop.
81#[derive(Clone)]
82pub struct LoaderFile {
83    inner: Arc<FileInner>,
84}
85
86impl std::fmt::Debug for LoaderFile {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("LoaderFile")
89            .field("path", &self.inner.path)
90            .field("format", &self.inner.format)
91            .finish_non_exhaustive()
92    }
93}
94
95impl LoaderFile {
96    /// Open a `.yml`, `.yaml`, or `.json` config file. The file does not
97    /// have to exist yet; [`LoaderFile::read`] returns an empty document
98    /// until the first write creates it.
99    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
100        let path = path.into();
101        let format = match path.extension().and_then(|ext| ext.to_str()) {
102            Some("yml" | "yaml") => FileFormat::Yaml,
103            Some("json") => FileFormat::Json,
104            _ => return Err(IncludeError::UnknownFormat { path }),
105        };
106        Ok(Self {
107            inner: Arc::new(FileInner {
108                path,
109                format,
110                suspend: Mutex::new(0),
111                deferred: Mutex::new(DeferredState::default()),
112                deferred_signal: Condvar::new(),
113                flusher: Mutex::new(None),
114            }),
115        })
116    }
117
118    /// The file path.
119    pub fn path(&self) -> &Path {
120        &self.inner.path
121    }
122
123    /// The detected format.
124    pub fn format(&self) -> FileFormat {
125        self.inner.format
126    }
127
128    /// Read and parse the file. Missing and empty files yield an empty
129    /// document; `${{ ... }}` templates are *not* expanded here — entries
130    /// keep their raw config.
131    pub fn read(&self) -> Result<Document> {
132        let content = match fs::read_to_string(&self.inner.path) {
133            Ok(content) => content,
134            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
135                return Ok(Document::default());
136            }
137            Err(error) => return Err(error.into()),
138        };
139        if content.trim().is_empty() {
140            return Ok(Document::default());
141        }
142        match self.inner.format {
143            FileFormat::Yaml => {
144                let value =
145                    serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content).map_err(|error| {
146                        IncludeError::Parse {
147                            format: "yaml",
148                            source: Box::new(error),
149                        }
150                    })?;
151                if value.is_null() {
152                    return Ok(Document::default());
153                }
154                serde_yaml_ng::from_value(value).map_err(|error| IncludeError::Parse {
155                    format: "yaml",
156                    source: Box::new(error),
157                })
158            }
159            FileFormat::Json => {
160                let value =
161                    serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
162                        IncludeError::Parse {
163                            format: "json",
164                            source: Box::new(error),
165                        }
166                    })?;
167                if value.is_null() {
168                    return Ok(Document::default());
169                }
170                serde_json::from_value(value).map_err(|error| IncludeError::Parse {
171                    format: "json",
172                    source: Box::new(error),
173                })
174            }
175        }
176    }
177
178    /// Serialize the document and replace the file atomically (write to a
179    /// sibling `.tmp` file, fsync, rename). A no-op while suspended.
180    pub fn write(&self, document: &Document) -> Result<()> {
181        write_document(&self.inner, document)
182    }
183
184    /// Schedule a coalesced write: rapid calls replace the pending document
185    /// (latest wins), and the physical write happens once `delay` has passed
186    /// without a newer call. A suspension active at flush time postpones the
187    /// write until it lifts. Errors surface through
188    /// [`LoaderFile::last_deferred_error`] and never propagate to callers.
189    ///
190    /// If the flusher thread cannot be spawned, the write happens
191    /// synchronously instead.
192    pub fn write_deferred(&self, document: Document, delay: Duration) {
193        {
194            let mut flusher = crate::lock(&self.inner.flusher);
195            if flusher.is_none() {
196                let weak = Arc::downgrade(&self.inner);
197                match std::thread::Builder::new()
198                    .name(format!("cordis-flush-{}", self.inner.path.display()))
199                    .spawn(move || flusher_loop(weak))
200                {
201                    Ok(thread) => *flusher = Some(thread),
202                    Err(_) => {
203                        drop(flusher);
204                        let _ = self.write(&document);
205                        return;
206                    }
207                }
208            }
209        }
210        {
211            let mut state = crate::lock(&self.inner.deferred);
212            state.pending = Some((document, Instant::now() + delay));
213            state.queued += 1;
214        }
215        self.inner.deferred_signal.notify_all();
216    }
217
218    /// Block until every [`LoaderFile::write_deferred`] call made before
219    /// this one has been flushed (or skipped by suspension lifting and a
220    /// later flush).
221    pub fn flush_deferred(&self) {
222        let mut state = crate::lock(&self.inner.deferred);
223        let target = state.queued;
224        while state.flushed < target || state.writing || state.pending.is_some() {
225            let (guard, _) = self
226                .inner
227                .deferred_signal
228                .wait_timeout(state, Duration::from_millis(100))
229                .unwrap_or_else(|error| error.into_inner());
230            state = guard;
231            if state.flushed >= target && !state.writing && state.pending.is_none() {
232                return;
233            }
234        }
235    }
236
237    /// The last deferred-write error, if the flusher failed.
238    pub fn last_deferred_error(&self) -> Option<String> {
239        crate::lock(&self.inner.deferred).last_error.clone()
240    }
241
242    /// Increment the suspend counter, returning a guard whose drop resumes
243    /// writes. Hold this while reloading a file so the resulting tree
244    /// patches are not written back.
245    pub fn suspend(&self) -> FileSuspendGuard {
246        {
247            let mut suspend = lock(&self.inner.suspend);
248            *suspend += 1;
249        }
250        FileSuspendGuard { file: self.clone() }
251    }
252
253    /// Whether any suspend guard is currently held for this file.
254    pub fn is_suspended(&self) -> bool {
255        *lock(&self.inner.suspend) > 0
256    }
257}
258
259/// Serialize `document` and atomically replace the file behind `inner`.
260/// A no-op while the file is suspended.
261fn write_document(inner: &FileInner, document: &Document) -> Result<()> {
262    if *crate::lock(&inner.suspend) > 0 {
263        return Ok(());
264    }
265    if let Ok(metadata) = fs::metadata(&inner.path) {
266        if metadata.permissions().readonly() {
267            return Err(IncludeError::ReadOnly {
268                path: inner.path.clone(),
269            });
270        }
271    }
272    let content = match inner.format {
273        FileFormat::Yaml => {
274            serde_yaml_ng::to_string(document).map_err(|error| IncludeError::Parse {
275                format: "yaml",
276                source: Box::new(error),
277            })?
278        }
279        FileFormat::Json => {
280            let mut text =
281                serde_json::to_string_pretty(document).map_err(|error| IncludeError::Parse {
282                    format: "json",
283                    source: Box::new(error),
284                })?;
285            text.push('\n');
286            text
287        }
288    };
289    if let Some(parent) = inner.path.parent() {
290        if !parent.as_os_str().is_empty() {
291            fs::create_dir_all(parent)?;
292        }
293    }
294    let file_name = inner
295        .path
296        .file_name()
297        .map(|name| name.to_string_lossy().into_owned())
298        .unwrap_or_default();
299    let tmp = inner.path.with_file_name(format!("{file_name}.tmp"));
300    {
301        let mut file = fs::File::create(&tmp)?;
302        file.write_all(content.as_bytes())?;
303        file.sync_all()?;
304    }
305    fs::rename(&tmp, &inner.path)?;
306    Ok(())
307}
308
309/// The flusher thread: holds only a weak reference so it dies with the last
310/// handle, and loops until the state is closed.
311fn flusher_loop(weak: Weak<FileInner>) {
312    const SUSPEND_RETRY: Duration = Duration::from_millis(50);
313    while let Some(inner) = weak.upgrade() {
314        let state = crate::lock(&inner.deferred);
315        if state.closed {
316            return;
317        }
318        let deadline = match state.pending.as_ref() {
319            Some((_, deadline)) => *deadline,
320            None => {
321                let waited = inner
322                    .deferred_signal
323                    .wait(state)
324                    .unwrap_or_else(|error| error.into_inner());
325                drop(waited);
326                continue;
327            }
328        };
329        drop(state);
330        let now = Instant::now();
331        if now < deadline {
332            // Park until the deadline (or a newer document/close) and
333            // re-decide with fresh state.
334            let state = crate::lock(&inner.deferred);
335            let (guard, _) = inner
336                .deferred_signal
337                .wait_timeout(state, deadline - now)
338                .unwrap_or_else(|error| error.into_inner());
339            drop(guard);
340            continue;
341        }
342        let mut state = crate::lock(&inner.deferred);
343        if state.closed {
344            return;
345        }
346        let Some((document, deadline)) = state.pending.take() else {
347            continue;
348        };
349        if Instant::now() < deadline {
350            state.pending = Some((document, deadline));
351            drop(state);
352            continue;
353        }
354        let queued_at_take = state.queued;
355        state.writing = true;
356        drop(state);
357
358        let suspended = *crate::lock(&inner.suspend) > 0;
359        if suspended {
360            let mut state = crate::lock(&inner.deferred);
361            state.pending = Some((document, Instant::now() + SUSPEND_RETRY));
362            state.writing = false;
363            drop(state);
364            inner.deferred_signal.notify_all();
365            continue;
366        }
367        let result = write_document(&inner, &document);
368        let mut state = crate::lock(&inner.deferred);
369        state.writing = false;
370        state.flushed = state.flushed.max(queued_at_take);
371        if let Err(error) = result {
372            state.last_error = Some(error.to_string());
373        }
374        drop(state);
375        inner.deferred_signal.notify_all();
376    }
377}
378
379impl Drop for FileInner {
380    fn drop(&mut self) {
381        {
382            let mut state = crate::lock(&self.deferred);
383            state.closed = true;
384        }
385        self.deferred_signal.notify_all();
386        if let Some(thread) = self
387            .flusher
388            .lock()
389            .unwrap_or_else(|error| error.into_inner())
390            .take()
391        {
392            let _ = thread.join();
393        }
394        // Anything still pending (e.g. suspended at close time) is written
395        // synchronously so the last state is not lost.
396        if let Some((document, _)) = self
397            .deferred
398            .lock()
399            .unwrap_or_else(|error| error.into_inner())
400            .pending
401            .take()
402        {
403            let _ = write_document(self, &document);
404        }
405    }
406}
407
408/// RAII guard for the file-level suspend counter.
409#[derive(Debug)]
410pub struct FileSuspendGuard {
411    file: LoaderFile,
412}
413
414impl Drop for FileSuspendGuard {
415    fn drop(&mut self) {
416        let mut suspend = lock(&self.file.inner.suspend);
417        *suspend = suspend.saturating_sub(1);
418    }
419}