1use 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
34const APP: &str = "quvyta/code";
36
37const FILE: &str = "containers.toml";
39
40pub const PREFIX: &str = "qcode-";
43
44static WRITING: Mutex<()> = Mutex::new(());
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Registered {
52 pub name: String,
54 pub engine: EngineKind,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct Registry {
61 pub containers: Vec<Registered>,
63}
64
65impl Registry {
66 #[must_use]
69 pub fn file() -> Option<PathBuf> {
70 data_dir(APP).map(|dir| dir.join(FILE))
71 }
72
73 #[must_use]
75 pub fn is_empty(&self) -> bool {
76 self.containers.is_empty()
77 }
78
79 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 #[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 #[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 #[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 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 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 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
193fn 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
200fn registered(entry: &Table, diagnostics: &mut Vec<Diagnostic>) -> Option<(String, EngineKind)> {
202 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 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 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}