1use crate::{Plan, Step, doctor::System, quote};
8use helixforge_core::{Risk, printable};
9use serde::{Deserialize, Serialize};
10use std::{
11 collections::BTreeMap,
12 path::{Path, PathBuf},
13};
14
15#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Kind {
19 Kde,
20 Gnome,
21 Xfce,
22 Cinnamon,
23 Mate,
24 Hyprland,
25 Sway,
26 Other,
27}
28
29impl Kind {
30 pub fn id(self) -> &'static str {
31 match self {
32 Kind::Kde => "kde",
33 Kind::Gnome => "gnome",
34 Kind::Xfce => "xfce",
35 Kind::Cinnamon => "cinnamon",
36 Kind::Mate => "mate",
37 Kind::Hyprland => "hyprland",
38 Kind::Sway => "sway",
39 Kind::Other => "other",
40 }
41 }
42
43 pub fn label(self) -> &'static str {
44 match self {
45 Kind::Kde => "KDE Plasma",
46 Kind::Gnome => "GNOME",
47 Kind::Xfce => "Xfce",
48 Kind::Cinnamon => "Cinnamon",
49 Kind::Mate => "MATE",
50 Kind::Hyprland => "Hyprland",
51 Kind::Sway => "Sway",
52 Kind::Other => "another desktop",
53 }
54 }
55
56 pub fn from_desktop(value: &str) -> Option<Self> {
58 value
59 .split(':')
60 .find_map(|name| match name.to_ascii_lowercase().as_str() {
61 "kde" | "plasma" => Some(Kind::Kde),
62 "gnome" | "gnome-classic" | "ubuntu" | "pop" => Some(Kind::Gnome),
63 "xfce" => Some(Kind::Xfce),
64 "x-cinnamon" | "cinnamon" => Some(Kind::Cinnamon),
65 "mate" => Some(Kind::Mate),
66 "hyprland" => Some(Kind::Hyprland),
67 "sway" => Some(Kind::Sway),
68 _ => None,
69 })
70 }
71
72 fn process(self) -> &'static str {
74 match self {
75 Kind::Kde => "plasmashell",
76 Kind::Gnome => "gnome-shell",
77 Kind::Xfce => "xfce4-session",
78 Kind::Cinnamon => "cinnamon",
79 Kind::Mate => "mate-session",
80 Kind::Hyprland => "Hyprland",
81 Kind::Sway => "sway",
82 Kind::Other => "",
83 }
84 }
85}
86
87struct Setting {
89 key: &'static str,
90 label: &'static str,
91 read: &'static [&'static str],
92 apply: &'static [&'static str],
94}
95
96const KDE: &[Setting] = &[
97 Setting {
98 key: "color-scheme",
99 label: "Colour scheme",
100 read: &[
101 "kreadconfig6",
102 "--file",
103 "kdeglobals",
104 "--group",
105 "General",
106 "--key",
107 "ColorScheme",
108 ],
109 apply: &["plasma-apply-colorscheme"],
110 },
111 Setting {
112 key: "plasma-theme",
113 label: "Plasma style",
114 read: &[
115 "kreadconfig6",
116 "--file",
117 "plasmarc",
118 "--group",
119 "Theme",
120 "--key",
121 "name",
122 ],
123 apply: &["plasma-apply-desktoptheme"],
124 },
125 Setting {
126 key: "icons",
127 label: "Icons",
128 read: &[
129 "kreadconfig6",
130 "--file",
131 "kdeglobals",
132 "--group",
133 "Icons",
134 "--key",
135 "Theme",
136 ],
137 apply: &[
138 "kwriteconfig6",
139 "--file",
140 "kdeglobals",
141 "--group",
142 "Icons",
143 "--key",
144 "Theme",
145 ],
146 },
147 Setting {
148 key: "cursor",
149 label: "Cursor",
150 read: &[
151 "kreadconfig6",
152 "--file",
153 "kcminputrc",
154 "--group",
155 "Mouse",
156 "--key",
157 "cursorTheme",
158 ],
159 apply: &["plasma-apply-cursortheme"],
160 },
161 Setting {
162 key: "global-theme",
163 label: "Global theme",
164 read: &[
165 "kreadconfig6",
166 "--file",
167 "kdeglobals",
168 "--group",
169 "KDE",
170 "--key",
171 "LookAndFeelPackage",
172 ],
173 apply: &["plasma-apply-lookandfeel", "--apply"],
174 },
175 Setting {
176 key: "font",
177 label: "Font",
178 read: &[
179 "kreadconfig6",
180 "--file",
181 "kdeglobals",
182 "--group",
183 "General",
184 "--key",
185 "font",
186 ],
187 apply: &[
188 "kwriteconfig6",
189 "--file",
190 "kdeglobals",
191 "--group",
192 "General",
193 "--key",
194 "font",
195 ],
196 },
197];
198
199const GNOME: &[Setting] = &[
200 Setting {
201 key: "gtk-theme",
202 label: "Applications",
203 read: &[
204 "gsettings",
205 "get",
206 "org.gnome.desktop.interface",
207 "gtk-theme",
208 ],
209 apply: &[
210 "gsettings",
211 "set",
212 "org.gnome.desktop.interface",
213 "gtk-theme",
214 ],
215 },
216 Setting {
217 key: "color-scheme",
218 label: "Style",
219 read: &[
220 "gsettings",
221 "get",
222 "org.gnome.desktop.interface",
223 "color-scheme",
224 ],
225 apply: &[
226 "gsettings",
227 "set",
228 "org.gnome.desktop.interface",
229 "color-scheme",
230 ],
231 },
232 Setting {
233 key: "icons",
234 label: "Icons",
235 read: &[
236 "gsettings",
237 "get",
238 "org.gnome.desktop.interface",
239 "icon-theme",
240 ],
241 apply: &[
242 "gsettings",
243 "set",
244 "org.gnome.desktop.interface",
245 "icon-theme",
246 ],
247 },
248 Setting {
249 key: "cursor",
250 label: "Cursor",
251 read: &[
252 "gsettings",
253 "get",
254 "org.gnome.desktop.interface",
255 "cursor-theme",
256 ],
257 apply: &[
258 "gsettings",
259 "set",
260 "org.gnome.desktop.interface",
261 "cursor-theme",
262 ],
263 },
264 Setting {
265 key: "font",
266 label: "Font",
267 read: &[
268 "gsettings",
269 "get",
270 "org.gnome.desktop.interface",
271 "font-name",
272 ],
273 apply: &[
274 "gsettings",
275 "set",
276 "org.gnome.desktop.interface",
277 "font-name",
278 ],
279 },
280];
281
282const CINNAMON: &[Setting] = &[
283 Setting {
284 key: "gtk-theme",
285 label: "Applications",
286 read: &[
287 "gsettings",
288 "get",
289 "org.cinnamon.desktop.interface",
290 "gtk-theme",
291 ],
292 apply: &[
293 "gsettings",
294 "set",
295 "org.cinnamon.desktop.interface",
296 "gtk-theme",
297 ],
298 },
299 Setting {
300 key: "desktop-theme",
301 label: "Desktop",
302 read: &["gsettings", "get", "org.cinnamon.theme", "name"],
303 apply: &["gsettings", "set", "org.cinnamon.theme", "name"],
304 },
305 Setting {
306 key: "icons",
307 label: "Icons",
308 read: &[
309 "gsettings",
310 "get",
311 "org.cinnamon.desktop.interface",
312 "icon-theme",
313 ],
314 apply: &[
315 "gsettings",
316 "set",
317 "org.cinnamon.desktop.interface",
318 "icon-theme",
319 ],
320 },
321 Setting {
322 key: "cursor",
323 label: "Cursor",
324 read: &[
325 "gsettings",
326 "get",
327 "org.cinnamon.desktop.interface",
328 "cursor-theme",
329 ],
330 apply: &[
331 "gsettings",
332 "set",
333 "org.cinnamon.desktop.interface",
334 "cursor-theme",
335 ],
336 },
337];
338
339const MATE: &[Setting] = &[
340 Setting {
341 key: "gtk-theme",
342 label: "Applications",
343 read: &["gsettings", "get", "org.mate.interface", "gtk-theme"],
344 apply: &["gsettings", "set", "org.mate.interface", "gtk-theme"],
345 },
346 Setting {
347 key: "icons",
348 label: "Icons",
349 read: &["gsettings", "get", "org.mate.interface", "icon-theme"],
350 apply: &["gsettings", "set", "org.mate.interface", "icon-theme"],
351 },
352 Setting {
353 key: "cursor",
354 label: "Cursor",
355 read: &[
356 "gsettings",
357 "get",
358 "org.mate.peripherals-mouse",
359 "cursor-theme",
360 ],
361 apply: &[
362 "gsettings",
363 "set",
364 "org.mate.peripherals-mouse",
365 "cursor-theme",
366 ],
367 },
368];
369
370const XFCE: &[Setting] = &[
371 Setting {
372 key: "gtk-theme",
373 label: "Style",
374 read: &["xfconf-query", "-c", "xsettings", "-p", "/Net/ThemeName"],
375 apply: &[
376 "xfconf-query",
377 "-c",
378 "xsettings",
379 "-p",
380 "/Net/ThemeName",
381 "-s",
382 ],
383 },
384 Setting {
385 key: "window-theme",
386 label: "Window manager",
387 read: &["xfconf-query", "-c", "xfwm4", "-p", "/general/theme"],
388 apply: &["xfconf-query", "-c", "xfwm4", "-p", "/general/theme", "-s"],
389 },
390 Setting {
391 key: "icons",
392 label: "Icons",
393 read: &[
394 "xfconf-query",
395 "-c",
396 "xsettings",
397 "-p",
398 "/Net/IconThemeName",
399 ],
400 apply: &[
401 "xfconf-query",
402 "-c",
403 "xsettings",
404 "-p",
405 "/Net/IconThemeName",
406 "-s",
407 ],
408 },
409 Setting {
410 key: "cursor",
411 label: "Cursor",
412 read: &[
413 "xfconf-query",
414 "-c",
415 "xsettings",
416 "-p",
417 "/Gtk/CursorThemeName",
418 ],
419 apply: &[
420 "xfconf-query",
421 "-c",
422 "xsettings",
423 "-p",
424 "/Gtk/CursorThemeName",
425 "-s",
426 ],
427 },
428 Setting {
429 key: "font",
430 label: "Font",
431 read: &["xfconf-query", "-c", "xsettings", "-p", "/Gtk/FontName"],
432 apply: &[
433 "xfconf-query",
434 "-c",
435 "xsettings",
436 "-p",
437 "/Gtk/FontName",
438 "-s",
439 ],
440 },
441];
442
443fn settings_of(kind: Kind) -> &'static [Setting] {
444 match kind {
445 Kind::Kde => KDE,
446 Kind::Gnome => GNOME,
447 Kind::Cinnamon => CINNAMON,
448 Kind::Mate => MATE,
449 Kind::Xfce => XFCE,
450 Kind::Hyprland | Kind::Sway | Kind::Other => &[],
451 }
452}
453
454#[derive(Clone, Debug, Default, Serialize)]
456pub struct Desktop {
457 pub kind: Option<Kind>,
458 pub name: Option<String>,
460 pub session: Option<String>,
462 pub display_manager: Option<DisplayManager>,
463 pub appearance: Vec<Value>,
465 pub owners: Vec<String>,
467 pub blocked: Option<String>,
469}
470
471#[derive(Clone, Debug, Default, Serialize)]
472pub struct Value {
473 pub key: String,
474 pub label: String,
475 pub value: Option<String>,
476}
477
478#[derive(Clone, Debug, Default, Serialize)]
479pub struct DisplayManager {
480 pub name: String,
482 pub files: Vec<String>,
484 pub theme: Option<String>,
486}
487
488pub fn detect(system: &dyn System, environment: &dyn Fn(&str) -> Option<String>) -> Desktop {
490 let processes = system.processes();
491 let kind = environment("XDG_CURRENT_DESKTOP")
492 .and_then(|value| Kind::from_desktop(&value))
493 .or_else(|| {
494 [
495 Kind::Kde,
496 Kind::Gnome,
497 Kind::Xfce,
498 Kind::Cinnamon,
499 Kind::Mate,
500 Kind::Hyprland,
501 Kind::Sway,
502 ]
503 .into_iter()
504 .find(|kind| processes.iter().any(|name| name == kind.process()))
505 });
506 let version = |command: &[&str]| -> Option<String> {
507 let (true, text) = system.run(command)? else {
508 return None;
509 };
510 text.split_whitespace()
511 .find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))
512 .map(printable)
513 };
514 let name = kind.map(|kind| {
515 let found = match kind {
516 Kind::Kde => version(&["plasmashell", "--version"]),
517 Kind::Gnome => version(&["gnome-shell", "--version"]),
518 Kind::Xfce => version(&["xfce4-session", "--version"]),
519 Kind::Cinnamon => version(&["cinnamon", "--version"]),
520 Kind::Mate => version(&["mate-session", "--version"]),
521 _ => None,
522 };
523 match found {
524 Some(version) => format!("{} {version}", kind.label()),
525 None => kind.label().to_string(),
526 }
527 });
528 let appearance = kind
529 .map(|kind| {
530 settings_of(kind)
531 .iter()
532 .map(|setting| Value {
533 key: setting.key.to_string(),
534 label: setting.label.to_string(),
535 value: system
536 .run(setting.read)
537 .filter(|(ok, _)| *ok)
538 .map(|(_, text)| unquote(text.trim()))
539 .filter(|text| !text.is_empty()),
540 })
541 .collect()
542 })
543 .unwrap_or_default();
544 let (owners, blocked) = owners(system, kind);
545 Desktop {
546 kind,
547 name,
548 session: environment("XDG_SESSION_TYPE").map(|value| printable(&value)),
549 display_manager: display_manager(system),
550 appearance,
551 owners,
552 blocked,
553 }
554}
555
556fn unquote(text: &str) -> String {
558 printable(
559 text.strip_prefix('\'')
560 .and_then(|rest| rest.strip_suffix('\''))
561 .unwrap_or(text),
562 )
563}
564
565fn display_manager(system: &dyn System) -> Option<DisplayManager> {
566 let target = system.link(Path::new("/etc/systemd/system/display-manager.service"))?;
567 let name = target.file_stem()?.to_string_lossy().into_owned();
568 let mut files: Vec<String> = match name.as_str() {
569 "sddm" => {
570 let mut files = vec!["/etc/sddm.conf".to_string()];
571 files.extend(
572 system
573 .list(Path::new("/etc/sddm.conf.d"))
574 .into_iter()
575 .map(|file| format!("/etc/sddm.conf.d/{file}")),
576 );
577 files
578 }
579 "gdm" | "gdm3" => vec![
580 "/etc/gdm/custom.conf".into(),
581 "/etc/gdm3/custom.conf".into(),
582 ],
583 "lightdm" => vec![
584 "/etc/lightdm/lightdm.conf".into(),
585 "/etc/lightdm/lightdm-gtk-greeter.conf".into(),
586 ],
587 _ => Vec::new(),
588 };
589 files.retain(|file| system.exists(Path::new(file)));
590 let theme = (name == "sddm")
592 .then(|| {
593 files
594 .iter()
595 .filter_map(|file| system.read(Path::new(file)))
596 .flat_map(|text| {
597 text.lines()
598 .filter_map(|line| line.trim().strip_prefix("Current="))
599 .map(|value| printable(value.trim()))
600 .collect::<Vec<_>>()
601 })
602 .last()
603 })
604 .flatten();
605 Some(DisplayManager {
606 name: printable(&name),
607 files: files.iter().map(|file| printable(file)).collect(),
608 theme,
609 })
610}
611
612fn owners(system: &dyn System, kind: Option<Kind>) -> (Vec<String>, Option<String>) {
616 let (_, _, home) = system.user();
617 let mut owners = Vec::new();
618 let mut blocked = None;
619 let files: &[&str] = match kind {
620 Some(Kind::Kde) => &[
621 ".config/kdeglobals",
622 ".config/plasmarc",
623 ".config/kcminputrc",
624 ],
625 Some(Kind::Xfce) => &[".config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml"],
626 Some(Kind::Gnome | Kind::Cinnamon | Kind::Mate) => &[".config/dconf/user"],
627 _ => &[],
628 };
629 for file in files {
630 let path = home.join(file);
631 if let Some(target) = system.link(&path)
632 && target.starts_with("/nix/store")
633 {
634 blocked = Some(format!(
635 "~/{file} is a Home Manager link into the Nix store; change the look in your Home Manager configuration instead"
636 ));
637 }
638 }
639 let state = home.join(".local/state/home-manager/gcroots/current-home");
640 if let Some(generation) = system.canonical(&state)
641 && let Some(activate) = system.read(&generation.join("activate"))
642 {
643 let script = activate.to_lowercase();
644 if script.contains("stylix") {
645 owners.push(
646 "Stylix themes this desktop through Home Manager at every switch".to_string(),
647 );
648 }
649 match kind {
650 Some(Kind::Kde) if script.contains("plasma") => owners.push(
651 "Home Manager runs a Plasma configuration step at every switch (plasma-manager or your own), which may set these again".to_string(),
652 ),
653 Some(Kind::Gnome | Kind::Cinnamon | Kind::Mate) if script.contains("dconf") => owners.push(
654 "Home Manager loads dconf settings at every switch, which may set these again".to_string(),
655 ),
656 _ => {}
657 }
658 }
659 (owners, blocked)
660}
661
662#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
664pub struct Look {
665 pub name: String,
666 pub desktop: String,
667 #[serde(default)]
668 pub settings: BTreeMap<String, String>,
669}
670
671#[derive(Debug, thiserror::Error)]
673pub enum LookError {
674 #[error("cannot read {0}: {1}")]
675 Read(PathBuf, String),
676 #[error("{0} is not a HelixForge look: {1}")]
677 Format(PathBuf, String),
678 #[error(
679 "the look has a name or value HelixForge does not set: control characters, a leading dash or too long"
680 )]
681 Invalid,
682 #[error("the name {0:?} gives no usable file name; use letters or digits")]
683 Name(String),
684 #[error("cannot write {0}: {1}")]
685 Write(PathBuf, String),
686}
687
688pub fn dir() -> Option<PathBuf> {
690 crate::profiles::dir().and_then(|profiles| Some(profiles.parent()?.join("looks")))
691}
692
693pub fn capture(name: &str, desktop: &Desktop) -> Option<Look> {
695 let kind = desktop.kind?;
696 let settings: BTreeMap<String, String> = desktop
697 .appearance
698 .iter()
699 .filter_map(|value| Some((value.key.clone(), value.value.clone()?)))
700 .collect();
701 (!settings.is_empty()).then(|| Look {
702 name: name.trim().to_string(),
703 desktop: kind.id().to_string(),
704 settings,
705 })
706}
707
708fn valid_value(value: &str) -> bool {
711 !value.is_empty()
712 && value.len() <= 200
713 && !value.starts_with('-')
714 && !value.chars().any(char::is_control)
715}
716
717pub fn check(look: &Look) -> Result<(), LookError> {
718 let plain = look.name.len() <= 100 && !look.name.chars().any(char::is_control);
719 let keys = look.settings.keys().all(|key| {
720 key.len() <= 32
721 && key
722 .bytes()
723 .all(|byte| byte.is_ascii_lowercase() || byte == b'-')
724 });
725 if plain
726 && keys
727 && look.settings.values().all(|value| valid_value(value))
728 && helixforge_core::valid_id(&look.desktop)
729 {
730 Ok(())
731 } else {
732 Err(LookError::Invalid)
733 }
734}
735
736pub fn read(path: &Path) -> Result<Look, LookError> {
737 let text = std::fs::read_to_string(path)
738 .map_err(|error| LookError::Read(path.to_path_buf(), error.to_string()))?;
739 let look: Look = toml::from_str(&text)
740 .map_err(|error| LookError::Format(path.to_path_buf(), printable(error.message())))?;
741 check(&look)?;
742 Ok(look)
743}
744
745pub fn save(directory: &Path, look: &Look) -> Result<PathBuf, LookError> {
746 check(look)?;
747 let slug =
748 crate::profiles::slug(&look.name).ok_or_else(|| LookError::Name(look.name.clone()))?;
749 let path = directory.join(format!("{slug}.toml"));
750 let failed = |error: String| LookError::Write(path.clone(), error);
751 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
752 let text = toml::to_string(look).map_err(|error| failed(error.to_string()))?;
753 let staging = path.with_extension("toml.new");
754 std::fs::write(
755 &staging,
756 format!("# A HelixForge desktop look: helixforge desktop apply <name>\n{text}"),
757 )
758 .map_err(|error| failed(error.to_string()))?;
759 std::fs::rename(&staging, &path).map_err(|error| failed(error.to_string()))?;
760 Ok(path)
761}
762
763pub fn load_all(directory: &Path) -> (Vec<(String, Look)>, Vec<String>) {
765 let mut found = Vec::new();
766 let mut problems = Vec::new();
767 let Ok(listing) = std::fs::read_dir(directory) else {
768 return (found, problems);
769 };
770 let mut paths: Vec<PathBuf> = listing
771 .flatten()
772 .map(|item| item.path())
773 .filter(|path| {
774 path.extension()
775 .is_some_and(|extension| extension == "toml")
776 })
777 .collect();
778 paths.sort();
779 for path in paths {
780 let Some(slug) = path
781 .file_stem()
782 .and_then(|stem| crate::profiles::slug(&stem.to_string_lossy()))
783 else {
784 continue;
785 };
786 match read(&path) {
787 Ok(look) => found.push((slug, look)),
788 Err(error) => problems.push(error.to_string()),
789 }
790 }
791 (found, problems)
792}
793
794pub fn plan_apply(look: &Look, desktop: &Desktop) -> Result<Plan, String> {
798 check(look).map_err(|error| error.to_string())?;
799 let Some(kind) = desktop.kind else {
800 return Err("no desktop environment is running here".to_string());
801 };
802 if look.desktop != kind.id() {
803 return Err(format!(
804 "the look was saved on {}; this is {}",
805 printable(&look.desktop),
806 kind.label()
807 ));
808 }
809 if let Some(why) = &desktop.blocked {
810 return Err(why.clone());
811 }
812 let mut lines = Vec::new();
813 let mut commands = Vec::new();
814 let mut shown = Vec::new();
815 let mut requires: Vec<String> = Vec::new();
816 for setting in settings_of(kind) {
817 let Some(value) = look.settings.get(setting.key) else {
818 continue;
819 };
820 let now = desktop
821 .appearance
822 .iter()
823 .find(|current| current.key == setting.key)
824 .and_then(|current| current.value.as_deref());
825 if now == Some(value.as_str()) {
826 continue;
827 }
828 let mut words: Vec<String> = setting.apply.iter().map(|word| word.to_string()).collect();
829 words.push(if setting.apply[0] == "gsettings" {
831 format!("'{}'", value.replace('\'', ""))
832 } else {
833 value.clone()
834 });
835 lines.push(
836 words
837 .iter()
838 .map(|word| quote(word))
839 .collect::<Vec<_>>()
840 .join(" "),
841 );
842 commands.push(printable(&words.join(" ")));
843 shown.push(printable(&format!(
844 "{}: {} → {value}",
845 setting.label,
846 now.unwrap_or("unset")
847 )));
848 if !requires.contains(&words[0]) {
849 requires.push(words[0].clone());
850 }
851 }
852 let mut plan = Plan::default();
853 if lines.is_empty() {
854 plan.notes.push(format!(
855 "{}: the desktop already looks like this",
856 printable(&look.name)
857 ));
858 return Ok(plan);
859 }
860 requires.sort();
861 plan.steps.push(Step::Shell {
862 id: "desktop-look".to_string(),
863 name: format!("Apply the look {}", printable(&look.name)),
864 program: lines.join(" &&\n"),
865 display: commands.join(" && "),
866 requires: requires.clone(),
867 changes: Vec::new(),
868 });
869 plan.notes.extend(shown);
870 plan.notes
871 .extend(desktop.owners.iter().map(|owner| format!("Note: {owner}")));
872 plan.requirements = requires;
873 plan.risk = Risk::Low;
874 plan.depends = vec![Vec::new()];
875 Ok(plan)
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881 use std::collections::HashMap;
882
883 #[derive(Default)]
884 struct Fake {
885 commands: HashMap<String, String>,
886 links: HashMap<PathBuf, PathBuf>,
887 files: HashMap<PathBuf, String>,
888 processes: Vec<String>,
889 }
890
891 impl System for Fake {
892 fn read(&self, path: &Path) -> Option<String> {
893 self.files.get(path).cloned()
894 }
895 fn exists(&self, path: &Path) -> bool {
896 self.files.contains_key(path)
897 }
898 fn list(&self, path: &Path) -> Vec<String> {
899 self.files
900 .keys()
901 .filter(|known| known.parent() == Some(path))
902 .filter_map(|known| known.file_name())
903 .map(|name| name.to_string_lossy().into_owned())
904 .collect()
905 }
906 fn link(&self, path: &Path) -> Option<PathBuf> {
907 self.links.get(path).cloned()
908 }
909 fn run(&self, command: &[&str]) -> Option<(bool, String)> {
910 self.commands
911 .get(&command.join(" "))
912 .map(|text| (true, text.clone()))
913 }
914 fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
915 Err("offline".into())
916 }
917 fn space(&self, _: &Path) -> Option<(u64, u64)> {
918 None
919 }
920 fn owner(&self, _: &Path) -> Option<u32> {
921 None
922 }
923 fn is_link(&self, path: &Path) -> bool {
924 self.links.contains_key(path)
925 }
926 fn canonical(&self, path: &Path) -> Option<PathBuf> {
927 self.links.get(path).cloned()
928 }
929 fn user(&self) -> (u32, u32, PathBuf) {
930 (1000, 1000, PathBuf::from("/home/me"))
931 }
932 fn on_path(&self, _: &str) -> bool {
933 true
934 }
935 fn processes(&self) -> Vec<String> {
936 self.processes.clone()
937 }
938 }
939
940 fn plasma() -> Fake {
941 let mut fake = Fake::default();
942 fake.commands
943 .insert("plasmashell --version".into(), "plasmashell 6.8.0\n".into());
944 fake.commands.insert(
945 "kreadconfig6 --file kdeglobals --group General --key ColorScheme".into(),
946 "BreezeDark\n".into(),
947 );
948 fake.commands.insert(
949 "kreadconfig6 --file kdeglobals --group Icons --key Theme".into(),
950 "breeze-dark\n".into(),
951 );
952 fake.links.insert(
953 PathBuf::from("/etc/systemd/system/display-manager.service"),
954 PathBuf::from("/usr/lib/systemd/system/sddm.service"),
955 );
956 fake.files
957 .insert(PathBuf::from("/etc/sddm.conf"), "".into());
958 fake.files.insert(
959 PathBuf::from("/etc/sddm.conf.d/zz-commander-theme.conf"),
960 "[Theme]\nCurrent=silent\n".into(),
961 );
962 fake
963 }
964
965 fn environment(name: &str) -> Option<String> {
966 match name {
967 "XDG_CURRENT_DESKTOP" => Some("KDE".into()),
968 "XDG_SESSION_TYPE" => Some("wayland".into()),
969 _ => None,
970 }
971 }
972
973 #[test]
974 fn plasma_is_read_with_its_own_tools() {
975 let found = detect(&plasma(), &environment);
976 assert_eq!(found.kind, Some(Kind::Kde));
977 assert_eq!(found.name.as_deref(), Some("KDE Plasma 6.8.0"));
978 assert_eq!(found.session.as_deref(), Some("wayland"));
979 let dm = found.display_manager.as_ref().unwrap();
980 assert_eq!(dm.name, "sddm");
981 assert_eq!(dm.theme.as_deref(), Some("silent"));
982 assert_eq!(found.appearance[0].value.as_deref(), Some("BreezeDark"));
983 assert!(found.appearance.iter().any(|value| value.value.is_none()));
984 assert!(found.owners.is_empty());
985 assert!(found.blocked.is_none());
986 let mut fake = plasma();
988 fake.processes = vec!["plasmashell".into()];
989 assert_eq!(detect(&fake, &|_| None).kind, Some(Kind::Kde));
990 assert_eq!(Kind::from_desktop("ubuntu:GNOME"), Some(Kind::Gnome));
991 }
992
993 #[test]
994 fn a_look_is_saved_and_put_back_with_only_what_differs() {
995 let desktop = detect(&plasma(), &environment);
996 let mut look = capture("Dark", &desktop).unwrap();
997 assert_eq!(look.desktop, "kde");
998 assert_eq!(look.settings.len(), 2);
999 let directory = tempfile::tempdir().unwrap();
1000 let path = save(directory.path(), &look).unwrap();
1001 assert_eq!(read(&path).unwrap(), look);
1002 assert_eq!(load_all(directory.path()).0.len(), 1);
1003 let planned = plan_apply(&look, &desktop).unwrap();
1005 assert!(planned.steps.is_empty());
1006 look.settings.insert("color-scheme".into(), "Sweet".into());
1007 let planned = plan_apply(&look, &desktop).unwrap();
1008 assert_eq!(planned.steps.len(), 1);
1009 assert_eq!(planned.steps[0].display(), "plasma-apply-colorscheme Sweet");
1010 assert_eq!(planned.notes[0], "Colour scheme: BreezeDark → Sweet");
1011 assert!(
1012 planned
1013 .requirements
1014 .contains(&"plasma-apply-colorscheme".to_string())
1015 );
1016 let gnome = Look {
1018 desktop: "gnome".into(),
1019 ..look.clone()
1020 };
1021 assert!(
1022 plan_apply(&gnome, &desktop)
1023 .unwrap_err()
1024 .contains("saved on gnome; this is KDE Plasma")
1025 );
1026 look.settings.insert("icons".into(), "--help".into());
1027 assert!(plan_apply(&look, &desktop).is_err());
1028 }
1029
1030 #[test]
1031 fn home_manager_owners_are_named_and_its_links_block_changes() {
1032 let mut fake = plasma();
1033 fake.links.insert(
1034 PathBuf::from("/home/me/.local/state/home-manager/gcroots/current-home"),
1035 PathBuf::from("/nix/store/abc-home-manager-generation"),
1036 );
1037 fake.files.insert(
1038 PathBuf::from("/nix/store/abc-home-manager-generation/activate"),
1039 "_iNote \"Activating %s\" \"configure-plasma\"\n".into(),
1040 );
1041 let found = detect(&fake, &environment);
1042 assert_eq!(found.owners.len(), 1);
1043 assert!(found.owners[0].contains("Plasma"));
1044 assert!(found.blocked.is_none());
1045 fake.links.insert(
1046 PathBuf::from("/home/me/.config/kdeglobals"),
1047 PathBuf::from("/nix/store/xyz-home-manager-files/.config/kdeglobals"),
1048 );
1049 let found = detect(&fake, &environment);
1050 assert!(found.blocked.as_ref().unwrap().contains("kdeglobals"));
1051 let look = Look {
1052 name: "x".into(),
1053 desktop: "kde".into(),
1054 settings: [("color-scheme".to_string(), "Sweet".to_string())].into(),
1055 };
1056 assert!(
1057 plan_apply(&look, &found)
1058 .unwrap_err()
1059 .contains("Home Manager")
1060 );
1061 }
1062}