Skip to main content

dev_prune/commands/
icon.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// File-manager icon registration for `.devprune.json`.
5//
6// The target here is the OS file manager, not the editor. What is actually achievable
7// differs per platform, and this module is deliberate about saying so rather than
8// implying more than it does:
9//
10// - **Linux** — a real, complete registration. A `shared-mime-info` package declares
11//   the glob `*.devprune.json` as `application/x-devprune`, and matching icons go into
12//   the hicolor theme. Nautilus, Dolphin, Thunar, Nemo and PCManFM all honour this.
13// - **Windows** — Explorer resolves a file's icon from the last extension only, so
14//   `*.devprune.json` is indistinguishable from any other `.json` to it. Claiming the
15//   icon would mean claiming *every* JSON file on the machine, which is not ours to
16//   take. The config folder gets its own icon via `desktop.ini`; individual files keep
17//   the system JSON icon.
18// - **macOS** — a UTI has to be exported by an application bundle's `Info.plist`, and
19//   a single CLI binary is not a bundle. Not supported.
20//
21// Editors are handled by printing a snippet the user can paste; nothing here edits an
22// editor's settings file.
23//
24// Everything written lands in either dev-prune's own config directory or the user's XDG
25// data directory. PATH, shell startup files and the binary are untouched.
26
27use anyhow::Result;
28use std::fs;
29use std::path::Path;
30// Only the XDG registration and its tests build paths; on Windows and macOS there is
31// nothing here that needs an owned one.
32#[cfg(any(target_os = "linux", test))]
33use std::path::PathBuf;
34
35use crate::config::Registry;
36use crate::output;
37
38pub const EMBEDDED_ICO_BYTES: &[u8] = include_bytes!("../../assets/icon.ico");
39pub const EMBEDDED_SCHEMA_BYTES: &[u8] = include_bytes!("../../schemas/devprune.schema.json");
40
41/// Mimetype icons, one per hicolor size directory. Downscaled from `assets/icon.png` and
42/// shipped under the same Apache-2.0 licence as the rest of the repository — see
43/// `assets/README.md`.
44pub const EMBEDDED_MIME_ICONS: &[(u32, &[u8])] = &[
45    (
46        48,
47        include_bytes!("../../assets/mimetype/application-x-devprune-48.png"),
48    ),
49    (
50        128,
51        include_bytes!("../../assets/mimetype/application-x-devprune-128.png"),
52    ),
53    (
54        256,
55        include_bytes!("../../assets/mimetype/application-x-devprune-256.png"),
56    ),
57];
58
59/// The 256px icon doubles as the config-folder icon on Linux.
60pub const EMBEDDED_PNG_BYTES: &[u8] =
61    include_bytes!("../../assets/mimetype/application-x-devprune-256.png");
62
63/// The MIME type `*.devprune.json` is registered as.
64pub const MIME_TYPE: &str = "application/x-devprune";
65
66/// Icon name derived from the MIME type, as the icon-naming spec requires: the type with
67/// its `/` replaced by `-`. A file manager looks up exactly this name and no other.
68pub const MIME_ICON_NAME: &str = "application-x-devprune";
69
70/// Icon association snippet for editors using the Material Icon Theme.
71///
72/// The value has to be the *name* of an icon the theme already ships — it is resolved to
73/// `<extension>/icons/<name>.svg`, so a filesystem path can never match. `tune` is a
74/// sliders glyph that reads as "settings file" and is distinct from plain `json`.
75const EDITOR_SNIPPET: &str = r#"    "material-icon-theme.files.associations": {
76      "*.devprune.json": "tune"
77    }"#;
78
79/// Whether everything [`sync_app_directory`] writes is already on disk.
80///
81/// The setup pass needs a "is this missing?" question it can answer without doing any
82/// work, because it runs on every install, upgrade and `devp init`. Rewriting a few
83/// hundred kilobytes of PNG each time would be harmless but wasteful, and on Linux it
84/// would also re-run `update-mime-database`, which is not free.
85///
86/// Content is not compared, only presence — the assets are compiled into the binary, so
87/// the upgrade stamp already forces a rewrite when the version changes.
88pub fn is_registered() -> bool {
89    let Ok(config_dir) = Registry::config_dir() else {
90        return false;
91    };
92    let present = config_dir.join("icon.ico").exists()
93        && config_dir.join("icon.png").exists()
94        && config_dir.join("bin").join("devprune.schema.json").exists();
95
96    #[cfg(target_os = "linux")]
97    let present = present
98        && xdg_data_home()
99            .is_some_and(|home| xdg_owned_paths(&home).iter().all(|path| path.exists()));
100
101    present
102}
103
104/// Write the icon assets and JSON Schema into the config directory, then register the
105/// file type with the OS file manager as far as the platform allows.
106pub fn sync_app_directory() -> Result<()> {
107    let Ok(config_dir) = Registry::config_dir() else {
108        return Ok(());
109    };
110
111    if !config_dir.exists() {
112        let _ = fs::create_dir_all(&config_dir);
113    }
114
115    let bin_dir = config_dir.join("bin");
116    if !bin_dir.exists() {
117        let _ = fs::create_dir_all(&bin_dir);
118    }
119
120    let ico_path = config_dir.join("icon.ico");
121    let _ = fs::write(&ico_path, EMBEDDED_ICO_BYTES);
122
123    let png_path = config_dir.join("icon.png");
124    let _ = fs::write(&png_path, EMBEDDED_PNG_BYTES);
125
126    let schema_path = bin_dir.join("devprune.schema.json");
127    let _ = fs::write(&schema_path, EMBEDDED_SCHEMA_BYTES);
128
129    apply_folder_icon(&config_dir, &ico_path, &png_path);
130    register_file_type();
131
132    // Scope note: this command used to also copy the running binary into `bin/`, append
133    // `dev-prune` to the User PATH, and write a `devp` function into `$PROFILE` /
134    // `.zshrc` / `.bashrc` / `config.fish`. Editing a user's shell startup is not what
135    // "register file icons" means, `devp uninstall` had no way to undo any of it, and
136    // it was redundant three times over: the installers already set PATH, and
137    // `ensure_devp_alias()` creates `devp` beside the real binary on every run.
138
139    Ok(())
140}
141
142/// Register custom file icon associations for `*.devprune.json`.
143pub fn run_install() -> Result<()> {
144    output::print_header("dev-prune File Icon Registration");
145
146    sync_app_directory()?;
147
148    if let Ok(config_dir) = Registry::config_dir() {
149        output::print_success(&format!(
150            "Icons and JSON Schema written to `{}`",
151            output::clean_path(&config_dir)
152        ));
153    }
154
155    report_file_manager_support();
156
157    output::print_header("Editors");
158    println!("dev-prune does not edit your editor settings. If you want the icon there");
159    println!("too, paste this into your `settings.json` (Material Icon Theme):");
160    println!();
161    println!("{EDITOR_SNIPPET}");
162    println!();
163    output::print_info(
164        "Schema validation needs no setting at all — every `.devprune.json` dev-prune \
165         writes carries a `$schema` link.",
166    );
167
168    Ok(())
169}
170
171/// Say plainly what the current platform's file manager will and will not do.
172fn report_file_manager_support() {
173    output::print_header("File Manager");
174
175    #[cfg(target_os = "linux")]
176    {
177        output::print_success(&format!(
178            "Registered `*.devprune.json` as `{MIME_TYPE}` with icons in the hicolor theme."
179        ));
180        output::print_info(
181            "Some file managers cache icons for the session — log out and back in if the \
182             old icon is still showing.",
183        );
184    }
185
186    #[cfg(windows)]
187    {
188        output::print_info(
189            "Explorer picks a file's icon from its last extension, so `*.devprune.json` \
190             looks the same to it as any other `.json`. Giving it our icon would mean \
191             taking over every JSON file on the machine, which dev-prune will not do.",
192        );
193        output::print_success("The dev-prune config folder has its own icon.");
194    }
195
196    #[cfg(target_os = "macos")]
197    output::print_info(
198        "Finder resolves file icons through UTIs exported by an application bundle. \
199         dev-prune ships a single binary, not a bundle, so file and folder icons stay \
200         at the system default.",
201    );
202}
203
204// Both icon paths are underscore-prefixed because each is used by exactly one target:
205// Windows reads the .ico, Linux reads the .png, and macOS reads neither. Without the
206// prefix the unused one is a `-D warnings` error on the two platforms that ignore it —
207// which only shows up in CI, since a local build sees one platform.
208fn apply_folder_icon(config_dir: &Path, _ico_path: &Path, _png_path: &Path) {
209    #[cfg(windows)]
210    {
211        let ini_file = config_dir.join("desktop.ini");
212        let ini_content = format!(
213            "[.ShellClassInfo]\r\nIconResource={},0\r\n[ViewState]\r\nMode=\r\nVid=\r\nFolderType=Generic\r\n",
214            _ico_path.display()
215        );
216        let _ = fs::write(&ini_file, ini_content);
217
218        let clean_ini = output::clean_path(&ini_file);
219        let clean_dir = output::clean_path(config_dir);
220        let _ = crate::spawn::command("attrib")
221            .args(["+h", "+s", &clean_ini])
222            .output();
223        let _ = crate::spawn::command("attrib")
224            .args(["+s", &clean_dir])
225            .output();
226    }
227
228    #[cfg(target_os = "linux")]
229    {
230        let dot_dir = config_dir.join(".directory");
231        let content = format!("[Desktop Entry]\nIcon={}\n", _png_path.display());
232        let _ = fs::write(&dot_dir, content);
233    }
234
235    #[cfg(target_os = "macos")]
236    {
237        let _ = config_dir;
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Linux: shared-mime-info + hicolor icon theme
243// ---------------------------------------------------------------------------
244
245/// The XDG MIME package describing the glob.
246///
247/// Weight 60 puts it above the default 50 so it beats the built-in `*.json` rule; without
248/// that, `application/json` wins and the icon never appears.
249pub fn mime_package_xml() -> String {
250    format!(
251        r#"<?xml version="1.0" encoding="UTF-8"?>
252<!-- Written by dev-prune. Removed again by `devp uninstall`. -->
253<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
254  <mime-type type="{MIME_TYPE}">
255    <comment>dev-prune project configuration</comment>
256    <sub-class-of type="application/json"/>
257    <glob pattern="*.devprune.json" weight="60"/>
258    <icon name="{MIME_ICON_NAME}"/>
259    <generic-icon name="text-x-generic"/>
260  </mime-type>
261</mime-info>
262"#
263    )
264}
265
266/// Root of the user's XDG data directory (`$XDG_DATA_HOME`, else `~/.local/share`).
267#[cfg(any(target_os = "linux", test))]
268#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
269fn xdg_data_home() -> Option<PathBuf> {
270    if let Ok(dir) = std::env::var("XDG_DATA_HOME")
271        && !dir.is_empty()
272    {
273        return Some(PathBuf::from(dir));
274    }
275    dirs::home_dir().map(|h| h.join(".local").join("share"))
276}
277
278/// Every file the Linux registration owns, so install and uninstall cannot drift apart.
279#[cfg(any(target_os = "linux", test))]
280fn xdg_owned_paths(data_home: &Path) -> Vec<PathBuf> {
281    let mut paths = vec![
282        data_home
283            .join("mime")
284            .join("packages")
285            .join("dev-prune.xml"),
286    ];
287    for (size, _) in EMBEDDED_MIME_ICONS {
288        paths.push(
289            data_home
290                .join("icons")
291                .join("hicolor")
292                .join(format!("{size}x{size}"))
293                .join("mimetypes")
294                .join(format!("{MIME_ICON_NAME}.png")),
295        );
296    }
297    paths
298}
299
300#[cfg(not(target_os = "linux"))]
301fn register_file_type() {}
302
303#[cfg(target_os = "linux")]
304fn register_file_type() {
305    let Some(data_home) = xdg_data_home() else {
306        return;
307    };
308
309    let package = data_home
310        .join("mime")
311        .join("packages")
312        .join("dev-prune.xml");
313    if let Some(parent) = package.parent() {
314        let _ = fs::create_dir_all(parent);
315    }
316    if let Err(e) = fs::write(&package, mime_package_xml()) {
317        output::print_warning(&format!(
318            "Could not write {}: {e}",
319            output::clean_path(&package)
320        ));
321        return;
322    }
323
324    for (size, bytes) in EMBEDDED_MIME_ICONS {
325        let dir = data_home
326            .join("icons")
327            .join("hicolor")
328            .join(format!("{size}x{size}"))
329            .join("mimetypes");
330        let _ = fs::create_dir_all(&dir);
331        let _ = fs::write(dir.join(format!("{MIME_ICON_NAME}.png")), bytes);
332    }
333
334    // These refresh the caches file managers actually read. Both are best-effort: a
335    // machine without shared-mime-info still gets the files, and the next login picks
336    // them up.
337    refresh_cache("update-mime-database", &[data_home.join("mime")]);
338    refresh_cache(
339        "gtk-update-icon-cache",
340        &[data_home.join("icons").join("hicolor")],
341    );
342}
343
344#[cfg(target_os = "linux")]
345fn refresh_cache(program: &str, args: &[PathBuf]) {
346    let _ = std::process::Command::new(program)
347        .args(args)
348        .stdout(std::process::Stdio::null())
349        .stderr(std::process::Stdio::null())
350        .status();
351}
352
353/// Undo `register_file_type`. Called by `devp uninstall`; a no-op off Linux.
354pub fn unregister_file_type() {
355    #[cfg(target_os = "linux")]
356    {
357        let Some(data_home) = xdg_data_home() else {
358            return;
359        };
360        let mut removed = false;
361        for path in xdg_owned_paths(&data_home) {
362            if fs::remove_file(&path).is_ok() {
363                removed = true;
364            }
365        }
366        if removed {
367            refresh_cache("update-mime-database", &[data_home.join("mime")]);
368            refresh_cache(
369                "gtk-update-icon-cache",
370                &[data_home.join("icons").join("hicolor")],
371            );
372            output::print_info("Removed the `*.devprune.json` file type registration.");
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn the_icon_name_follows_the_icon_naming_spec() {
383        // A file manager looks up the MIME type with `/` swapped for `-` and nothing else.
384        assert_eq!(MIME_ICON_NAME, MIME_TYPE.replace('/', "-"));
385    }
386
387    #[test]
388    fn the_mime_package_outranks_the_builtin_json_glob() {
389        let xml = mime_package_xml();
390        // The stock `*.json` rule is weight 50. Anything at or below that loses.
391        assert!(xml.contains(r#"weight="60""#), "{xml}");
392        assert!(xml.contains(r#"pattern="*.devprune.json""#));
393        assert!(xml.contains(&format!(r#"<icon name="{MIME_ICON_NAME}"/>"#)));
394    }
395
396    #[test]
397    fn the_mime_package_is_well_formed_enough_to_have_one_type() {
398        let xml = mime_package_xml();
399        assert_eq!(xml.matches("<mime-type").count(), 1);
400        assert_eq!(xml.matches("</mime-type>").count(), 1);
401        assert!(xml.trim_end().ends_with("</mime-info>"));
402    }
403
404    #[test]
405    fn install_and_uninstall_agree_on_which_files_are_ours() {
406        let root = PathBuf::from("/tmp/xdg");
407        let owned = xdg_owned_paths(&root);
408        // One MIME package plus one icon per shipped size — nothing else is touched.
409        assert_eq!(owned.len(), 1 + EMBEDDED_MIME_ICONS.len());
410        assert!(owned.iter().all(|p| p.starts_with(&root)));
411    }
412
413    #[test]
414    fn the_shipped_icons_are_real_pngs() {
415        for (size, bytes) in EMBEDDED_MIME_ICONS {
416            assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "{size}px is not a PNG");
417        }
418    }
419
420    #[test]
421    fn the_editor_snippet_names_a_theme_icon_rather_than_a_path() {
422        // The original bug: a filesystem path here resolves to nothing, so no icon.
423        assert!(EDITOR_SNIPPET.contains(r#""*.devprune.json": "tune""#));
424        assert!(!EDITOR_SNIPPET.contains(".png"));
425    }
426}