⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/desktop/
file_assoc.rs

1//! File Associations
2//!
3//! Manages the mapping from file extensions and MIME types to default
4//! applications. Supports registering multiple alternative applications
5//! per type and selecting a default.
6
7#![allow(dead_code)]
8
9use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
10
11// ---------------------------------------------------------------------------
12// Association entry
13// ---------------------------------------------------------------------------
14
15/// A single file association entry.
16#[derive(Debug, Clone)]
17pub struct FileAssociation {
18    /// File extension (without leading dot), e.g. "txt".
19    pub extension: String,
20    /// MIME type string, e.g. "text/plain".
21    pub mime_type: String,
22    /// Default application command.
23    pub default_app: String,
24    /// Alternative application commands.
25    pub alternatives: Vec<String>,
26}
27
28impl FileAssociation {
29    /// Create a new association.
30    pub fn new(extension: &str, mime_type: &str, default_app: &str) -> Self {
31        Self {
32            extension: String::from(extension),
33            mime_type: String::from(mime_type),
34            default_app: String::from(default_app),
35            alternatives: Vec::new(),
36        }
37    }
38
39    /// Add an alternative application.
40    pub fn add_alternative(&mut self, app: &str) {
41        let s = String::from(app);
42        if !self.alternatives.contains(&s) && s != self.default_app {
43            self.alternatives.push(s);
44        }
45    }
46
47    /// All available applications (default first, then alternatives).
48    pub fn all_apps(&self) -> Vec<String> {
49        let mut apps = vec![self.default_app.clone()];
50        for alt in &self.alternatives {
51            apps.push(alt.clone());
52        }
53        apps
54    }
55}
56
57// ---------------------------------------------------------------------------
58// Association registry
59// ---------------------------------------------------------------------------
60
61/// Registry of file associations, keyed by extension and MIME type.
62#[derive(Debug)]
63pub struct AssociationRegistry {
64    /// Associations indexed by file extension (lowercase).
65    by_extension: BTreeMap<String, FileAssociation>,
66    /// Associations indexed by MIME type.
67    by_mime: BTreeMap<String, FileAssociation>,
68}
69
70impl Default for AssociationRegistry {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl AssociationRegistry {
77    /// Create a new empty registry.
78    pub fn new() -> Self {
79        Self {
80            by_extension: BTreeMap::new(),
81            by_mime: BTreeMap::new(),
82        }
83    }
84
85    /// Create a registry pre-populated with built-in defaults.
86    pub fn with_defaults() -> Self {
87        let mut reg = Self::new();
88
89        // Text files
90        reg.register(FileAssociation::new("txt", "text/plain", "text_editor"));
91        reg.register(FileAssociation::new("md", "text/markdown", "text_editor"));
92        reg.register(FileAssociation::new("rs", "text/x-rust", "text_editor"));
93        reg.register(FileAssociation::new("c", "text/x-c", "text_editor"));
94        reg.register(FileAssociation::new("h", "text/x-c", "text_editor"));
95        reg.register(FileAssociation::new("cpp", "text/x-c++", "text_editor"));
96        reg.register(FileAssociation::new("py", "text/x-python", "text_editor"));
97        reg.register(FileAssociation::new(
98            "sh",
99            "text/x-shellscript",
100            "text_editor",
101        ));
102        reg.register(FileAssociation::new("toml", "text/x-toml", "text_editor"));
103        reg.register(FileAssociation::new(
104            "json",
105            "application/json",
106            "text_editor",
107        ));
108        reg.register(FileAssociation::new(
109            "xml",
110            "application/xml",
111            "text_editor",
112        ));
113        reg.register(FileAssociation::new("html", "text/html", "text_editor"));
114        reg.register(FileAssociation::new("css", "text/css", "text_editor"));
115
116        // Images
117        reg.register(FileAssociation::new("png", "image/png", "image_viewer"));
118        reg.register(FileAssociation::new("jpg", "image/jpeg", "image_viewer"));
119        reg.register(FileAssociation::new("jpeg", "image/jpeg", "image_viewer"));
120        reg.register(FileAssociation::new("bmp", "image/bmp", "image_viewer"));
121        reg.register(FileAssociation::new("tga", "image/x-tga", "image_viewer"));
122        reg.register(FileAssociation::new("qoi", "image/x-qoi", "image_viewer"));
123        reg.register(FileAssociation::new(
124            "ppm",
125            "image/x-portable-pixmap",
126            "image_viewer",
127        ));
128
129        // Documents
130        reg.register(FileAssociation::new("pdf", "application/pdf", "pdf_viewer"));
131
132        // Audio
133        reg.register(FileAssociation::new("wav", "audio/wav", "media_player"));
134        reg.register(FileAssociation::new("mp3", "audio/mpeg", "media_player"));
135        reg.register(FileAssociation::new("ogg", "audio/ogg", "media_player"));
136
137        // Video
138        reg.register(FileAssociation::new(
139            "avi",
140            "video/x-msvideo",
141            "media_player",
142        ));
143        reg.register(FileAssociation::new("mp4", "video/mp4", "media_player"));
144
145        reg
146    }
147
148    /// Register a file association.
149    ///
150    /// If an association for the same extension or MIME type already exists,
151    /// the new one replaces it.
152    pub fn register(&mut self, assoc: FileAssociation) {
153        self.by_extension
154            .insert(assoc.extension.clone(), assoc.clone());
155        self.by_mime.insert(assoc.mime_type.clone(), assoc);
156    }
157
158    /// Look up an association by file extension (case-insensitive).
159    pub fn lookup_by_ext(&self, ext: &str) -> Option<&FileAssociation> {
160        // Convert to lowercase for lookup
161        let mut lower = String::new();
162        for c in ext.chars() {
163            for lc in c.to_lowercase() {
164                lower.push(lc);
165            }
166        }
167        self.by_extension.get(&lower)
168    }
169
170    /// Look up an association by MIME type.
171    pub fn lookup_by_mime(&self, mime: &str) -> Option<&FileAssociation> {
172        self.by_mime.get(mime)
173    }
174
175    /// Set the default application for a given extension.
176    pub fn set_default(&mut self, ext: &str, app: &str) -> bool {
177        let mut lower = String::new();
178        for c in ext.chars() {
179            for lc in c.to_lowercase() {
180                lower.push(lc);
181            }
182        }
183
184        if let Some(assoc) = self.by_extension.get_mut(&lower) {
185            let old = assoc.default_app.clone();
186            assoc.default_app = String::from(app);
187            // Move old default to alternatives if not already there
188            if !old.is_empty() && old != app {
189                assoc.add_alternative(&old);
190            }
191            // Update MIME entry too
192            let mime = assoc.mime_type.clone();
193            if let Some(mime_assoc) = self.by_mime.get_mut(&mime) {
194                mime_assoc.default_app = String::from(app);
195            }
196            true
197        } else {
198            false
199        }
200    }
201
202    /// Get the default application for a given extension.
203    pub fn get_default(&self, ext: &str) -> Option<&str> {
204        self.lookup_by_ext(ext).map(|a| a.default_app.as_str())
205    }
206
207    /// Number of registered extensions.
208    pub fn extension_count(&self) -> usize {
209        self.by_extension.len()
210    }
211
212    /// Number of registered MIME types.
213    pub fn mime_count(&self) -> usize {
214        self.by_mime.len()
215    }
216
217    /// Get the default app for a filename (extracts extension).
218    pub fn get_app_for_file(&self, filename: &str) -> Option<&str> {
219        let ext = filename.rsplit('.').next()?;
220        self.get_default(ext)
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Open-with dialog model
226// ---------------------------------------------------------------------------
227
228/// Model for an "Open With" dialog, presenting available apps for a file type.
229#[derive(Debug)]
230pub struct OpenWithDialog {
231    /// File extension being opened.
232    pub extension: String,
233    /// MIME type of the file.
234    pub mime_type: String,
235    /// Available applications.
236    pub apps: Vec<String>,
237    /// Currently highlighted index.
238    pub selected_index: usize,
239    /// Whether the dialog is visible.
240    pub visible: bool,
241}
242
243impl OpenWithDialog {
244    /// Create a dialog from an association registry and file extension.
245    pub fn from_registry(registry: &AssociationRegistry, ext: &str) -> Self {
246        let (mime, apps) = if let Some(assoc) = registry.lookup_by_ext(ext) {
247            (assoc.mime_type.clone(), assoc.all_apps())
248        } else {
249            (String::from("application/octet-stream"), Vec::new())
250        };
251
252        Self {
253            extension: String::from(ext),
254            mime_type: mime,
255            apps,
256            selected_index: 0,
257            visible: true,
258        }
259    }
260
261    /// Move selection up.
262    pub fn select_prev(&mut self) {
263        if self.selected_index > 0 {
264            self.selected_index -= 1;
265        }
266    }
267
268    /// Move selection down.
269    pub fn select_next(&mut self) {
270        if !self.apps.is_empty() && self.selected_index + 1 < self.apps.len() {
271            self.selected_index += 1;
272        }
273    }
274
275    /// Get the currently selected application.
276    pub fn selected_app(&self) -> Option<&str> {
277        self.apps.get(self.selected_index).map(|s| s.as_str())
278    }
279
280    /// Dismiss the dialog.
281    pub fn dismiss(&mut self) {
282        self.visible = false;
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Tests
288// ---------------------------------------------------------------------------
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_new_association() {
296        let assoc = FileAssociation::new("txt", "text/plain", "editor");
297        assert_eq!(assoc.extension, "txt");
298        assert_eq!(assoc.default_app, "editor");
299        assert!(assoc.alternatives.is_empty());
300    }
301
302    #[test]
303    fn test_add_alternative() {
304        let mut assoc = FileAssociation::new("txt", "text/plain", "editor");
305        assoc.add_alternative("notepad");
306        assoc.add_alternative("notepad"); // duplicate
307        assert_eq!(assoc.alternatives.len(), 1);
308        assoc.add_alternative("editor"); // same as default
309        assert_eq!(assoc.alternatives.len(), 1);
310    }
311
312    #[test]
313    fn test_registry_defaults() {
314        let reg = AssociationRegistry::with_defaults();
315        assert!(reg.extension_count() > 10);
316        let txt = reg.lookup_by_ext("txt").unwrap();
317        assert_eq!(txt.default_app, "text_editor");
318    }
319
320    #[test]
321    fn test_lookup_by_mime() {
322        let reg = AssociationRegistry::with_defaults();
323        let assoc = reg.lookup_by_mime("image/png").unwrap();
324        assert_eq!(assoc.default_app, "image_viewer");
325    }
326
327    #[test]
328    fn test_set_default() {
329        let mut reg = AssociationRegistry::with_defaults();
330        assert!(reg.set_default("txt", "vscode"));
331        let assoc = reg.lookup_by_ext("txt").unwrap();
332        assert_eq!(assoc.default_app, "vscode");
333        assert!(assoc.alternatives.contains(&String::from("text_editor")));
334    }
335
336    #[test]
337    fn test_get_app_for_file() {
338        let reg = AssociationRegistry::with_defaults();
339        assert_eq!(reg.get_app_for_file("hello.rs"), Some("text_editor"));
340        assert_eq!(reg.get_app_for_file("photo.png"), Some("image_viewer"));
341    }
342
343    #[test]
344    fn test_open_with_dialog() {
345        let reg = AssociationRegistry::with_defaults();
346        let dialog = OpenWithDialog::from_registry(&reg, "txt");
347        assert!(dialog.visible);
348        assert!(!dialog.apps.is_empty());
349        assert_eq!(dialog.selected_app(), Some("text_editor"));
350    }
351
352    #[test]
353    fn test_open_with_navigation() {
354        let mut reg = AssociationRegistry::with_defaults();
355        let mut assoc = FileAssociation::new("txt", "text/plain", "editor");
356        assoc.add_alternative("notepad");
357        reg.register(assoc);
358        let mut dialog = OpenWithDialog::from_registry(&reg, "txt");
359        assert_eq!(dialog.selected_index, 0);
360        dialog.select_next();
361        assert_eq!(dialog.selected_index, 1);
362        dialog.select_prev();
363        assert_eq!(dialog.selected_index, 0);
364    }
365}