dev_prune/commands/
icon.rs1use anyhow::Result;
42use std::fs;
43use std::path::Path;
44#[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
55pub 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
73pub const EMBEDDED_PNG_BYTES: &[u8] =
75 include_bytes!("../../assets/mimetype/application-x-devprune-256.png");
76
77pub const MIME_TYPE: &str = "application/x-devprune";
79
80pub const MIME_ICON_NAME: &str = "application-x-devprune";
83
84const EDITOR_SNIPPET: &str = r#" "material-icon-theme.files.associations": {
90 "*.devprune.json": "tune"
91 }"#;
92
93pub 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
118pub 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 Ok(())
154}
155
156pub 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
185fn 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
218fn 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
255pub 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#[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#[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 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
367pub 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 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 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 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 assert!(EDITOR_SNIPPET.contains(r#""*.devprune.json": "tune""#));
438 assert!(!EDITOR_SNIPPET.contains(".png"));
439 }
440}