Skip to main content

qcode/store/
containers.rs

1//! `containers.toml`: the containers QCode itself started, so that once no QCode is open the ones
2//! still running can be stopped.
3//!
4//! The file is state, not a setting: it lives in the application's data folder beside the
5//! session, and QCode writes a name into it the moment it starts that container. Only QCode's own
6//! containers are ever written or acted on — a name without QCode's prefix is refused on the way
7//! in and reported on the way out — so a container the person or another program runs is never
8//! touched. A file that cannot be read cleanly means "stop nothing": guessing which containers a
9//! damaged list meant is how the wrong one gets stopped.
10//!
11//! ```toml
12//! [[container]]
13//! name = "qcode-firefly-base"
14//! engine = "podman"
15//!
16//! [[container]]
17//! name = "qcode-firefly-claude-sub"
18//! engine = "docker"
19//! ```
20
21use std::fmt::Write as _;
22use std::io;
23use std::path::{Path, PathBuf};
24use std::sync::Mutex;
25
26use qframe::diagnostics::Diagnostic;
27use qframe::document::{Document, Shape, Table, ValueKind};
28use qframe::storage::{atomic_write, data_dir};
29
30use super::Loaded;
31use super::workspace::quoted;
32use crate::engine::EngineKind;
33
34/// The folder of the application's own data, the same one the session is kept in.
35const APP: &str = "quvyta/code";
36
37/// The name of the file in that folder.
38const FILE: &str = "containers.toml";
39
40/// What every container QCode creates is called by, and the one thing that lets a name into the
41/// list: [`crate::engine::names`] builds every container name on it.
42pub const PREFIX: &str = "qcode-";
43
44/// One process may start several containers at once, each on its own thread; each of them reads
45/// the list, adds its name and writes it back. Taking turns inside the process keeps one of those
46/// writes from undoing another.
47static WRITING: Mutex<()> = Mutex::new(());
48
49/// A container QCode started, and the engine it started it with.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Registered {
52    /// The container's name, always with [`PREFIX`].
53    pub name: String,
54    /// The engine it lives in.
55    pub engine: EngineKind,
56}
57
58/// The containers QCode started, in the order it first started them.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct Registry {
61    /// Every container, each name once.
62    pub containers: Vec<Registered>,
63}
64
65impl Registry {
66    /// Where the list of this user is kept: `containers.toml` in QCode's data folder, or `None`
67    /// on a machine that names no home, where nothing is recorded and so nothing is stopped.
68    #[must_use]
69    pub fn file() -> Option<PathBuf> {
70        data_dir(APP).map(|dir| dir.join(FILE))
71    }
72
73    /// Whether the list holds nothing.
74    #[must_use]
75    pub fn is_empty(&self) -> bool {
76        self.containers.is_empty()
77    }
78
79    /// Adds `name` in `engine`, unless it is there already or is not one of QCode's names.
80    /// Answers whether the list changed.
81    pub fn add(&mut self, name: &str, engine: EngineKind) -> bool {
82        if !name.starts_with(PREFIX) || self.containers.iter().any(|known| known.name == name && known.engine == engine)
83        {
84            return false;
85        }
86        self.containers.push(Registered { name: name.to_owned(), engine });
87        true
88    }
89
90    /// Reads the list at `path`.
91    ///
92    /// A file that is not there is an empty list: nothing was started yet, or everything was
93    /// stopped. A file that cannot be read is an empty list with a diagnostic that says why.
94    #[must_use]
95    pub fn load(path: &Path) -> Loaded<Self> {
96        match std::fs::read_to_string(path) {
97            Ok(text) => {
98                let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(FILE);
99                Self::parse(name, &text)
100            }
101            Err(error) if error.kind() == io::ErrorKind::NotFound => {
102                Loaded { value: Self::default(), diagnostics: Vec::new() }
103            }
104            Err(error) => Loaded {
105                value: Self::default(),
106                diagnostics: vec![Diagnostic::error(None, format!("{} could not be read: {error}", path.display()))],
107            },
108        }
109    }
110
111    /// Reads a list from `text`, reporting problems against `file`.
112    ///
113    /// An entry that is not one of QCode's containers, or names no engine QCode drives, is left
114    /// out with a diagnostic; a name listed twice is kept once.
115    #[must_use]
116    pub fn parse(file: &str, text: &str) -> Loaded<Self> {
117        let document = Document::parse(file, text, &shape());
118        let mut diagnostics = document.diagnostics().to_vec();
119        let mut registry = Self::default();
120        for entry in document.root().entries("container") {
121            if let Some((name, engine)) = registered(entry, &mut diagnostics) {
122                registry.add(&name, engine);
123            }
124        }
125        Loaded { value: registry, diagnostics }
126    }
127
128    /// The file as it is written to disk.
129    #[must_use]
130    pub fn to_toml(&self) -> String {
131        let mut out = String::new();
132        for (index, container) in self.containers.iter().enumerate() {
133            if index > 0 {
134                out.push('\n');
135            }
136            let _ = write!(
137                out,
138                "[[container]]\nname = {}\nengine = {}\n",
139                quoted(&container.name),
140                quoted(container.engine.name())
141            );
142        }
143        out
144    }
145
146    /// Writes the list to `path` atomically, making its folder first when it is not there.
147    ///
148    /// # Errors
149    ///
150    /// Returns the I/O error when the folder or the file cannot be written.
151    pub fn save(&self, path: &Path) -> io::Result<()> {
152        if let Some(dir) = path.parent() {
153            std::fs::create_dir_all(dir)?;
154        }
155        atomic_write(path, self.to_toml().as_bytes())
156    }
157
158    /// Notes in the list at `path` that QCode started the container `name` in `engine`, and
159    /// answers what was wrong with the file it found there.
160    ///
161    /// The file is only written when the name is new, so the tab opened for the hundredth time
162    /// costs a read and nothing more, and a watcher of the file is not woken for nothing. A file
163    /// that was damaged is written again with what could be read of it and the new name: the
164    /// container that just started would otherwise never be stopped, and the problems are
165    /// answered so the person hears of them. A name that is not QCode's is not written at all.
166    ///
167    /// This reads and writes the disk, so it belongs on a background thread.
168    ///
169    /// # Errors
170    ///
171    /// Returns the I/O error when the file cannot be written.
172    pub fn record(path: &Path, name: &str, engine: EngineKind) -> io::Result<Vec<Diagnostic>> {
173        let _turn = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
174        let Loaded { value: mut registry, diagnostics } = Self::load(path);
175        if registry.add(name, engine) || !diagnostics.is_empty() {
176            registry.save(path)?;
177        }
178        Ok(diagnostics)
179    }
180
181    /// Writes `remaining` to the list at `path` in place of what it held, which is what stopping
182    /// the containers does once it is through: only what could not be stopped stays listed.
183    ///
184    /// # Errors
185    ///
186    /// Returns the I/O error when the file cannot be written.
187    pub fn replace(path: &Path, remaining: &Self) -> io::Result<()> {
188        let _turn = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
189        remaining.save(path)
190    }
191}
192
193/// What a `containers.toml` holds.
194fn shape() -> Shape {
195    let names: Vec<&str> = [EngineKind::Podman, EngineKind::Docker].map(EngineKind::name).to_vec();
196    let container = Shape::new().required("name", ValueKind::text()).required("engine", ValueKind::choice(names));
197    Shape::new().entries("container", container)
198}
199
200/// One `[[container]]` entry, or `None` when it is not a container QCode may act on.
201fn registered(entry: &Table, diagnostics: &mut Vec<Diagnostic>) -> Option<(String, EngineKind)> {
202    // A missing name or engine, or an engine word nobody knows, was reported by the document.
203    let name = entry.text("name")?;
204    let engine = EngineKind::from_name(entry.text("engine")?)?;
205    if !name.starts_with(PREFIX) {
206        let at = entry.value_location("name").cloned();
207        diagnostics.push(Diagnostic::warning(at, format!("`{name}` is not a QCode container; it is left alone")));
208        return None;
209    }
210    Some((name.to_owned(), engine))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    const NAME: &str = "containers.toml";
218
219    fn located(diagnostic: &Diagnostic) -> String {
220        diagnostic.location.as_ref().map_or_else(|| "nowhere".to_owned(), ToString::to_string)
221    }
222
223    /// A folder of this test's own, removed when the test ends.
224    struct Scratch(PathBuf);
225
226    impl Scratch {
227        fn new(name: &str) -> Self {
228            let path = std::env::temp_dir().join(format!("qcode-registry-{name}-{}", std::process::id()));
229            let _ = std::fs::remove_dir_all(&path);
230            Self(path)
231        }
232
233        fn file(&self) -> PathBuf {
234            self.0.join("deeper").join(NAME)
235        }
236    }
237
238    impl Drop for Scratch {
239        fn drop(&mut self) {
240            let _ = std::fs::remove_dir_all(&self.0);
241        }
242    }
243
244    fn sample() -> Registry {
245        let mut registry = Registry::default();
246        registry.add("qcode-firefly-base", EngineKind::Podman);
247        registry.add("qcode-firefly-claude \"sub\"", EngineKind::Docker);
248        registry
249    }
250
251    #[test]
252    fn a_written_list_reads_back_the_same() {
253        let text = sample().to_toml();
254        assert_eq!(
255            text,
256            "[[container]]\nname = \"qcode-firefly-base\"\nengine = \"podman\"\n\n\
257             [[container]]\nname = \"qcode-firefly-claude \\\"sub\\\"\"\nengine = \"docker\"\n"
258        );
259        let read = Registry::parse(NAME, &text);
260        assert!(read.is_clean(), "{:?}\n{text}", read.diagnostics);
261        assert_eq!(read.value, sample());
262        assert_eq!(Registry::default().to_toml(), "", "an empty list is an empty file");
263    }
264
265    #[test]
266    fn recording_makes_the_folder_and_keeps_each_name_once() {
267        let scratch = Scratch::new("record");
268        let path = scratch.file();
269        assert_eq!(Registry::load(&path), Loaded { value: Registry::default(), diagnostics: Vec::new() });
270        for _ in 0..3 {
271            let problems = Registry::record(&path, "qcode-moth-base", EngineKind::Podman).expect("written");
272            assert!(problems.is_empty(), "{problems:?}");
273        }
274        Registry::record(&path, "qcode-moth-codex", EngineKind::Docker).expect("written");
275        let read = Registry::load(&path);
276        assert!(read.is_clean(), "{:?}", read.diagnostics);
277        let names: Vec<&str> = read.value.containers.iter().map(|known| known.name.as_str()).collect();
278        assert_eq!(names, ["qcode-moth-base", "qcode-moth-codex"]);
279        assert_eq!(read.value.containers[1].engine, EngineKind::Docker);
280    }
281
282    #[test]
283    fn a_name_already_listed_is_not_written_again() {
284        // A watcher of the file wakes on every write; opening a tab again is not news.
285        let scratch = Scratch::new("unchanged");
286        let path = scratch.file();
287        Registry::record(&path, "qcode-moth-base", EngineKind::Podman).expect("written");
288        let before = std::fs::metadata(&path).and_then(|meta| meta.modified()).expect("a time");
289        std::thread::sleep(std::time::Duration::from_millis(20));
290        Registry::record(&path, "qcode-moth-base", EngineKind::Podman).expect("read");
291        let after = std::fs::metadata(&path).and_then(|meta| meta.modified()).expect("a time");
292        assert_eq!(before, after);
293    }
294
295    #[test]
296    fn only_qcode_names_are_ever_written() {
297        let scratch = Scratch::new("foreign");
298        let path = scratch.file();
299        Registry::record(&path, "postgres", EngineKind::Podman).expect("nothing to write");
300        assert!(!path.exists(), "a name that is not QCode's does not even make the file");
301        let mut registry = Registry::default();
302        assert!(!registry.add("my-qcode-thing", EngineKind::Docker));
303        assert!(registry.is_empty());
304    }
305
306    #[test]
307    fn many_threads_recording_at_once_lose_no_name() {
308        let scratch = Scratch::new("threads");
309        let path = scratch.file();
310        let handles: Vec<_> = (0..8)
311            .map(|number| {
312                let path = path.clone();
313                std::thread::spawn(move || {
314                    Registry::record(&path, &format!("qcode-p-{number}"), EngineKind::Podman).expect("written");
315                })
316            })
317            .collect();
318        for handle in handles {
319            handle.join().expect("the thread finishes");
320        }
321        assert_eq!(Registry::load(&path).value.containers.len(), 8);
322    }
323
324    #[test]
325    fn a_foreign_name_or_an_unknown_engine_in_the_file_is_reported_with_its_place() {
326        let text = "[[container]]\nname = \"postgres\"\nengine = \"podman\"\n\n\
327                    [[container]]\nname = \"qcode-a-base\"\nengine = \"lxc\"\n\n\
328                    [[container]]\nname = \"qcode-b-base\"\nengine = \"docker\"\n\n\
329                    [[container]]\nname = \"qcode-b-base\"\nengine = \"docker\"\n";
330        let read = Registry::parse(NAME, text);
331        let places: Vec<String> = read.diagnostics.iter().map(located).collect();
332        for place in ["containers.toml:2:8", "containers.toml:7:10"] {
333            assert!(places.iter().any(|at| at == place), "{place} in {places:?}");
334        }
335        assert_eq!(read.value.containers, [Registered { name: "qcode-b-base".to_owned(), engine: EngineKind::Docker }]);
336    }
337
338    #[test]
339    fn a_broken_file_says_where_and_recording_writes_back_what_could_be_read() {
340        let scratch = Scratch::new("broken");
341        let path = scratch.file();
342        std::fs::create_dir_all(path.parent().expect("a folder")).expect("the folder");
343        std::fs::write(
344            &path,
345            "[[container]]\nname = \"qcode-a-base\"\nengine = \"podman\"\n\n[[container]]\nname = \n",
346        )
347        .expect("a broken file");
348        let read = Registry::load(&path);
349        assert!(
350            read.diagnostics.iter().any(|d| located(d).starts_with("containers.toml:6:")),
351            "{:?}",
352            read.diagnostics
353        );
354
355        let problems = Registry::record(&path, "qcode-b-base", EngineKind::Podman).expect("written");
356        assert!(!problems.is_empty(), "the damage is answered so it can be said");
357        let read = Registry::load(&path);
358        assert!(read.is_clean(), "the file is whole again: {:?}", read.diagnostics);
359        assert_eq!(read.value.containers.len(), 2);
360    }
361
362    #[test]
363    fn an_unreadable_file_is_an_empty_list_that_says_why() {
364        let scratch = Scratch::new("folder");
365        std::fs::create_dir_all(&scratch.0).expect("a folder");
366        let read = Registry::load(&scratch.0);
367        assert!(read.value.is_empty());
368        assert_eq!(read.diagnostics.len(), 1, "{:?}", read.diagnostics);
369    }
370
371    #[test]
372    fn garbage_never_panics() {
373        for text in ["", "\u{0}", "[[container]]\n", "container = 3\n", "[[container]]]]\nname=\n", "name = 7"] {
374            let read = Registry::parse(NAME, text);
375            assert!(read.value.containers.iter().all(|known| known.name.starts_with(PREFIX)));
376        }
377    }
378}