Skip to main content

oino_settings/
storage.rs

1//! Settings storage: files (locked, atomic) or memory (`FileSettingsStorage`,
2//! `InMemorySettingsStorage`).
3
4use std::fs::{self, File, OpenOptions};
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7
8use crate::error::{SettingsErrorKind, SettingsScope};
9use crate::paths::LOCK_SUFFIX;
10
11/// Where settings layers live.
12#[derive(Debug, Clone)]
13pub(crate) enum Storage {
14    /// `<agent>/settings.json` and `<cwd>/.oino/settings.json`.
15    Files { global: PathBuf, project: PathBuf },
16    /// In-process strings (tests, SDK `inMemory`).
17    Memory {
18        global: Option<String>,
19        project: Option<String>,
20    },
21}
22
23impl Storage {
24    pub(crate) fn path(&self, scope: SettingsScope) -> Option<&Path> {
25        match (self, scope) {
26            (Self::Files { global, .. }, SettingsScope::Global) => Some(global),
27            (Self::Files { project, .. }, SettingsScope::Project) => Some(project),
28            (Self::Memory { .. }, _) => None,
29        }
30    }
31
32    /// Current content of a layer; `None` when the file does not exist.
33    pub(crate) fn read(&self, scope: SettingsScope) -> Result<Option<String>, SettingsErrorKind> {
34        match self {
35            Self::Files { .. } => {
36                let Some(path) = self.path(scope) else {
37                    return Ok(None);
38                };
39                read_optional(path).map_err(|e| SettingsErrorKind::Read(e.to_string()))
40            }
41            Self::Memory { global, project } => Ok(match scope {
42                SettingsScope::Global => global.clone(),
43                SettingsScope::Project => project.clone(),
44            }),
45        }
46    }
47
48    /// Locked read-modify-write: `next` maps the current content to the new content.
49    /// Files are locked with an exclusive advisory lock on `<file>.lock` and replaced
50    /// atomically (temp file + rename).
51    pub(crate) fn update(
52        &mut self,
53        scope: SettingsScope,
54        next: impl FnOnce(Option<&str>) -> Result<String, SettingsErrorKind>,
55    ) -> Result<(), SettingsErrorKind> {
56        match self {
57            Self::Memory { global, project } => {
58                let slot = match scope {
59                    SettingsScope::Global => global,
60                    SettingsScope::Project => project,
61                };
62                *slot = Some(next(slot.as_deref())?);
63                Ok(())
64            }
65            Self::Files { global, project } => {
66                let path = match scope {
67                    SettingsScope::Global => global.as_path(),
68                    SettingsScope::Project => project.as_path(),
69                };
70                let _lock =
71                    FileLock::acquire(path).map_err(|e| SettingsErrorKind::Lock(e.to_string()))?;
72                let current =
73                    read_optional(path).map_err(|e| SettingsErrorKind::Read(e.to_string()))?;
74                let content = next(current.as_deref())?;
75                write_atomic(path, content.as_bytes())
76                    .map_err(|e| SettingsErrorKind::Write(e.to_string()))
77            }
78        }
79    }
80}
81
82/// Read a UTF-8 file, `None` when it does not exist.
83pub(crate) fn read_optional(path: &Path) -> io::Result<Option<String>> {
84    match fs::read_to_string(path) {
85        Ok(content) => Ok(Some(content)),
86        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
87        Err(error) => Err(error),
88    }
89}
90
91/// An exclusive advisory lock on `<path>.lock`, released on drop (the lock file lives
92/// beside the locked file).
93#[derive(Debug)]
94pub struct FileLock {
95    file: File,
96}
97
98impl FileLock {
99    /// Block until the exclusive lock for `path` is held (parent dirs are created).
100    ///
101    /// # Errors
102    ///
103    /// The lock file cannot be created or locked.
104    pub fn acquire(path: &Path) -> io::Result<Self> {
105        let file = open_lock(path)?;
106        file.lock()?;
107        Ok(Self { file })
108    }
109}
110
111impl Drop for FileLock {
112    fn drop(&mut self) {
113        // Closing the descriptor releases the lock even if unlock fails.
114        let _ = self.file.unlock();
115    }
116}
117
118fn open_lock(path: &Path) -> io::Result<File> {
119    if let Some(parent) = path.parent() {
120        fs::create_dir_all(parent)?;
121    }
122    let mut lock_path = path.as_os_str().to_owned();
123    lock_path.push(LOCK_SUFFIX);
124    OpenOptions::new()
125        .create(true)
126        .truncate(false)
127        .write(true)
128        .open(lock_path)
129}
130
131/// Write `bytes` to a sibling temp file, fsync, then rename over `path`.
132///
133/// # Errors
134///
135/// Any I/O error; the temp file is removed on failure.
136pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
137    let parent = path.parent().unwrap_or_else(|| Path::new("."));
138    fs::create_dir_all(parent)?;
139    let file_name = path
140        .file_name()
141        .map(|name| name.to_string_lossy().into_owned())
142        .unwrap_or_default();
143    let tmp = parent.join(format!(".{file_name}.{}.tmp", std::process::id()));
144    let result = (|| {
145        let mut file = File::create(&tmp)?;
146        file.write_all(bytes)?;
147        file.sync_all()?;
148        fs::rename(&tmp, path)
149    })();
150    if result.is_err() {
151        let _ = fs::remove_file(&tmp);
152    }
153    result
154}