dev_prune/commands/
icon.rs1use anyhow::Result;
28use std::fs;
29use std::path::Path;
30#[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
41pub 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
59pub const EMBEDDED_PNG_BYTES: &[u8] =
61 include_bytes!("../../assets/mimetype/application-x-devprune-256.png");
62
63pub const MIME_TYPE: &str = "application/x-devprune";
65
66pub const MIME_ICON_NAME: &str = "application-x-devprune";
69
70const EDITOR_SNIPPET: &str = r#" "material-icon-theme.files.associations": {
76 "*.devprune.json": "tune"
77 }"#;
78
79pub 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
104pub 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 Ok(())
140}
141
142pub 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
171fn 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
204fn 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
241pub 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#[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#[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 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
353pub 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 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 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 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 assert!(EDITOR_SNIPPET.contains(r#""*.devprune.json": "tune""#));
424 assert!(!EDITOR_SNIPPET.contains(".png"));
425 }
426}