Skip to main content

dev_prune/commands/
icon.rs

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