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, Mutex};
13
14/// The serialization format of a [`LoaderFile`], picked from its extension.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FileFormat {
17    /// `.yml` / `.yaml`
18    Yaml,
19    /// `.json`
20    Json,
21}
22
23/// The parsed content of one config file: the entry list plus any unknown
24/// top-level keys, which are preserved on write-back.
25#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
26pub struct Document {
27    /// The entry tree serialized as a list, in file order.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub entries: Vec<EntryOptions>,
30    /// Unknown top-level keys, round-tripped untouched.
31    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
32    pub extra: IndexMap<String, Node>,
33}
34
35impl Document {
36    /// A document holding just the given entries.
37    pub fn with_entries(entries: Vec<EntryOptions>) -> Self {
38        Self {
39            entries,
40            extra: IndexMap::new(),
41        }
42    }
43}
44
45/// Shared state behind a [`LoaderFile`] handle.
46struct FileInner {
47    path: PathBuf,
48    format: FileFormat,
49    suspend: Mutex<usize>,
50}
51
52/// A handle to one config file on disk.
53///
54/// Handles are cheap to clone and share path, format, and suspend state, so
55/// several trees (or the loader and the watcher) can coordinate writes
56/// through the same file. While any [`FileSuspendGuard`] is held,
57/// [`LoaderFile::write`] is a silent no-op — the file-level half of breaking
58/// the write → watch → write feedback loop.
59#[derive(Clone)]
60pub struct LoaderFile {
61    inner: Arc<FileInner>,
62}
63
64impl std::fmt::Debug for LoaderFile {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("LoaderFile")
67            .field("path", &self.inner.path)
68            .field("format", &self.inner.format)
69            .finish_non_exhaustive()
70    }
71}
72
73impl LoaderFile {
74    /// Open a `.yml`, `.yaml`, or `.json` config file. The file does not
75    /// have to exist yet; [`LoaderFile::read`] returns an empty document
76    /// until the first write creates it.
77    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
78        let path = path.into();
79        let format = match path.extension().and_then(|ext| ext.to_str()) {
80            Some("yml" | "yaml") => FileFormat::Yaml,
81            Some("json") => FileFormat::Json,
82            _ => return Err(IncludeError::UnknownFormat { path }),
83        };
84        Ok(Self {
85            inner: Arc::new(FileInner {
86                path,
87                format,
88                suspend: Mutex::new(0),
89            }),
90        })
91    }
92
93    /// The file path.
94    pub fn path(&self) -> &Path {
95        &self.inner.path
96    }
97
98    /// The detected format.
99    pub fn format(&self) -> FileFormat {
100        self.inner.format
101    }
102
103    /// Read and parse the file. Missing and empty files yield an empty
104    /// document; `${{ ... }}` templates are *not* expanded here — entries
105    /// keep their raw config.
106    pub fn read(&self) -> Result<Document> {
107        let content = match fs::read_to_string(&self.inner.path) {
108            Ok(content) => content,
109            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
110                return Ok(Document::default());
111            }
112            Err(error) => return Err(error.into()),
113        };
114        if content.trim().is_empty() {
115            return Ok(Document::default());
116        }
117        match self.inner.format {
118            FileFormat::Yaml => {
119                let value =
120                    serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content).map_err(|error| {
121                        IncludeError::Parse {
122                            format: "yaml",
123                            source: Box::new(error),
124                        }
125                    })?;
126                if value.is_null() {
127                    return Ok(Document::default());
128                }
129                serde_yaml_ng::from_value(value).map_err(|error| IncludeError::Parse {
130                    format: "yaml",
131                    source: Box::new(error),
132                })
133            }
134            FileFormat::Json => {
135                let value =
136                    serde_json::from_str::<serde_json::Value>(&content).map_err(|error| {
137                        IncludeError::Parse {
138                            format: "json",
139                            source: Box::new(error),
140                        }
141                    })?;
142                if value.is_null() {
143                    return Ok(Document::default());
144                }
145                serde_json::from_value(value).map_err(|error| IncludeError::Parse {
146                    format: "json",
147                    source: Box::new(error),
148                })
149            }
150        }
151    }
152
153    /// Serialize the document and replace the file atomically (write to a
154    /// sibling `.tmp` file, fsync, rename). A no-op while suspended.
155    pub fn write(&self, document: &Document) -> Result<()> {
156        if self.is_suspended() {
157            return Ok(());
158        }
159        if let Ok(metadata) = fs::metadata(&self.inner.path) {
160            if metadata.permissions().readonly() {
161                return Err(IncludeError::ReadOnly {
162                    path: self.inner.path.clone(),
163                });
164            }
165        }
166        let content = match self.inner.format {
167            FileFormat::Yaml => {
168                serde_yaml_ng::to_string(document).map_err(|error| IncludeError::Parse {
169                    format: "yaml",
170                    source: Box::new(error),
171                })?
172            }
173            FileFormat::Json => {
174                let mut text = serde_json::to_string_pretty(document).map_err(|error| {
175                    IncludeError::Parse {
176                        format: "json",
177                        source: Box::new(error),
178                    }
179                })?;
180                text.push('\n');
181                text
182            }
183        };
184        if let Some(parent) = self.inner.path.parent() {
185            if !parent.as_os_str().is_empty() {
186                fs::create_dir_all(parent)?;
187            }
188        }
189        let tmp = self.tmp_path();
190        {
191            let mut file = fs::File::create(&tmp)?;
192            file.write_all(content.as_bytes())?;
193            file.sync_all()?;
194        }
195        fs::rename(&tmp, &self.inner.path)?;
196        Ok(())
197    }
198
199    /// Increment the suspend counter, returning a guard whose drop resumes
200    /// writes. Hold this while reloading a file so the resulting tree
201    /// patches are not written back.
202    pub fn suspend(&self) -> FileSuspendGuard {
203        {
204            let mut suspend = lock(&self.inner.suspend);
205            *suspend += 1;
206        }
207        FileSuspendGuard { file: self.clone() }
208    }
209
210    /// Whether any suspend guard is currently held for this file.
211    pub fn is_suspended(&self) -> bool {
212        *lock(&self.inner.suspend) > 0
213    }
214
215    fn tmp_path(&self) -> PathBuf {
216        let file_name = self
217            .inner
218            .path
219            .file_name()
220            .map(|name| name.to_string_lossy().into_owned())
221            .unwrap_or_default();
222        self.inner.path.with_file_name(format!("{file_name}.tmp"))
223    }
224}
225
226/// RAII guard for the file-level suspend counter.
227#[derive(Debug)]
228pub struct FileSuspendGuard {
229    file: LoaderFile,
230}
231
232impl Drop for FileSuspendGuard {
233    fn drop(&mut self) {
234        let mut suspend = lock(&self.file.inner.suspend);
235        *suspend = suspend.saturating_sub(1);
236    }
237}