1use axum::extract::ws::{Message, WebSocket};
2use futures_util::SinkExt;
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, RwLock};
6use tokio::sync::broadcast;
7
8pub struct ConfigState {
9 pub tx: broadcast::Sender<String>,
10}
11
12impl Default for ConfigState {
13 fn default() -> Self {
14 Self::new()
15 }
16}
17
18impl ConfigState {
19 pub fn new() -> Self {
20 let (tx, _) = broadcast::channel::<String>(64);
21 spawn_watcher(tx.clone());
22 Self { tx }
23 }
24}
25
26fn blit_config_dir() -> PathBuf {
27 #[cfg(unix)]
28 let base = std::env::var("XDG_CONFIG_HOME")
29 .map(PathBuf::from)
30 .unwrap_or_else(|_| {
31 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
32 PathBuf::from(home).join(".config")
33 });
34 #[cfg(windows)]
35 let base = std::env::var("APPDATA")
36 .map(PathBuf::from)
37 .unwrap_or_else(|_| PathBuf::from(r"C:\ProgramData"));
38 base.join("blit")
39}
40
41pub fn config_path() -> PathBuf {
42 if let Ok(p) = std::env::var("BLIT_CONFIG") {
43 return PathBuf::from(p);
44 }
45 blit_config_dir().join("blit.conf")
46}
47
48pub fn remotes_path() -> PathBuf {
49 if let Ok(p) = std::env::var("BLIT_REMOTES") {
50 return PathBuf::from(p);
51 }
52 blit_config_dir().join("blit.remotes")
53}
54
55#[cfg(unix)]
61pub fn default_local_socket() -> String {
62 if let Ok(p) = std::env::var("BLIT_SOCK") {
63 return p;
64 }
65 if let Ok(dir) = std::env::var("TMPDIR") {
66 let p = format!("{dir}/blit.sock");
67 if std::path::Path::new(&p).exists() {
68 return p;
69 }
70 }
71 if let Ok(user) = std::env::var("USER") {
72 let p = format!("/tmp/blit-{user}.sock");
73 if std::path::Path::new(&p).exists() {
74 return p;
75 }
76 let sys = format!("/run/blit/{user}.sock");
77 if std::path::Path::new(&sys).exists() {
78 return sys;
79 }
80 }
81 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
82 return format!("{dir}/blit.sock");
83 }
84 "/tmp/blit.sock".into()
85}
86
87#[cfg(windows)]
89pub fn default_local_socket() -> String {
90 if let Ok(p) = std::env::var("BLIT_SOCK") {
91 return p;
92 }
93 let user = std::env::var("USERNAME").unwrap_or_else(|_| "default".into());
94 format!(r"\\.\pipe\blit-{user}")
95}
96
97fn lock_config_dir() -> Option<std::fs::File> {
101 #[cfg(unix)]
102 {
103 use std::os::unix::fs::OpenOptionsExt;
104 let dir = blit_config_dir();
105 let _ = std::fs::create_dir_all(&dir);
106 let lock_path = dir.join("blit.lock");
107 if let Ok(f) = std::fs::OpenOptions::new()
108 .write(true)
109 .create(true)
110 .truncate(false)
111 .mode(0o600)
112 .open(&lock_path)
113 {
114 use std::os::unix::io::AsRawFd;
116 if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX) } == 0 {
117 return Some(f);
118 }
119 }
120 None
121 }
122 #[cfg(not(unix))]
123 {
124 None
125 }
126}
127
128pub fn read_config() -> HashMap<String, String> {
129 let path = config_path();
130 let contents = match std::fs::read_to_string(&path) {
131 Ok(c) => c,
132 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
133 Err(e) => {
134 eprintln!("blit: could not read {}: {e}", path.display());
135 return HashMap::new();
136 }
137 };
138 parse_config_str(&contents)
139}
140
141pub fn read_remotes() -> Vec<(String, String)> {
144 let path = remotes_path();
145 let contents = match std::fs::read_to_string(&path) {
146 Ok(c) => c,
147 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
148 let default = vec![("local".to_string(), "local".to_string())];
149 write_remotes(&default);
150 return default;
151 }
152 Err(e) => {
153 eprintln!("blit: could not read {}: {e}", path.display());
154 return vec![];
155 }
156 };
157 parse_remotes_str(&contents)
158}
159
160pub fn modify_config(f: impl FnOnce(&mut HashMap<String, String>)) {
162 let _lock = lock_config_dir();
163 let mut map = read_config();
164 f(&mut map);
165 write_config(&map);
166}
167
168pub fn modify_remotes(f: impl FnOnce(&mut Vec<(String, String)>)) {
170 let _lock = lock_config_dir();
171 let mut entries = read_remotes();
172 f(&mut entries);
173 write_remotes(&entries);
174}
175
176pub fn parse_remotes_str(contents: &str) -> Vec<(String, String)> {
180 let mut order: Vec<String> = Vec::new();
183 let mut map: HashMap<String, String> = HashMap::new();
184 for line in contents.lines() {
185 let line = line.trim();
186 if line.is_empty() || line.starts_with('#') {
187 continue;
188 }
189 if let Some((k, v)) = line.split_once('=') {
190 let k = k.trim().to_string();
191 let v = v.trim().to_string();
192 if !k.is_empty() && !v.is_empty() {
193 if !map.contains_key(&k) {
194 order.push(k.clone());
195 }
196 map.insert(k, v);
197 }
198 }
199 }
200 order
201 .into_iter()
202 .map(|k| {
203 let v = map.remove(&k).unwrap();
204 (k, v)
205 })
206 .collect()
207}
208
209fn serialize_remotes(entries: &[(String, String)]) -> String {
210 let mut out = String::new();
211 for (k, v) in entries {
212 out.push_str(k);
213 out.push_str(" = ");
214 out.push_str(v);
215 out.push('\n');
216 }
217 out
218}
219
220pub fn write_remotes(entries: &[(String, String)]) {
222 let path = remotes_path();
223 if let Some(parent) = path.parent() {
224 let _ = std::fs::create_dir_all(parent);
225 }
226 let contents = serialize_remotes(entries);
227 write_secret_file(&path, &contents);
228}
229
230fn write_secret_file(path: &PathBuf, contents: &str) {
234 #[cfg(unix)]
235 {
236 use std::os::unix::fs::OpenOptionsExt;
237 use std::sync::atomic::{AtomicU32, Ordering};
240 static COUNTER: AtomicU32 = AtomicU32::new(0);
241 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
242 let pid = std::process::id();
243 let tmp = path.with_extension(format!("tmp.{pid}.{seq}"));
244 let result = std::fs::OpenOptions::new()
245 .write(true)
246 .create(true)
247 .truncate(true)
248 .mode(0o600)
249 .open(&tmp)
250 .and_then(|mut f| {
251 use std::io::Write;
252 f.write_all(contents.as_bytes())
253 });
254 if result.is_ok() {
255 let _ = std::fs::rename(&tmp, path);
256 } else {
257 let _ = std::fs::remove_file(&tmp);
258 }
259 }
260 #[cfg(not(unix))]
261 {
262 let _ = std::fs::write(path, contents);
263 }
264}
265
266fn serialize_config_str(map: &HashMap<String, String>) -> String {
267 let mut lines: Vec<String> = map.iter().map(|(k, v)| format!("{k} = {v}")).collect();
268 lines.sort();
269 lines.push(String::new());
270 lines.join("\n")
271}
272
273pub fn write_config(map: &HashMap<String, String>) {
274 let path = config_path();
275 if let Some(parent) = path.parent() {
276 let _ = std::fs::create_dir_all(parent);
277 }
278 write_secret_file(&path, &serialize_config_str(map));
279}
280
281fn spawn_file_watcher<F>(path: PathBuf, label: &'static str, on_change: F)
284where
285 F: Fn() + Send + 'static,
286{
287 use notify::{RecursiveMode, Watcher};
288
289 if let Some(parent) = path.parent() {
290 let _ = std::fs::create_dir_all(parent);
291 }
292
293 let watch_dir = path.parent().unwrap_or(&path).to_path_buf();
294 let file_name = path.file_name().map(|n| n.to_os_string());
295
296 std::thread::Builder::new()
297 .name(format!("{label}-watcher"))
298 .spawn(move || {
299 let (ntx, nrx) = std::sync::mpsc::channel();
300 let mut watcher = match notify::recommended_watcher(ntx) {
301 Ok(w) => w,
302 Err(e) => {
303 eprintln!("blit: {label} watcher failed: {e}");
304 return;
305 }
306 };
307 if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
308 eprintln!("blit: {label} watch failed: {e}");
309 return;
310 }
311 loop {
312 match nrx.recv() {
313 Ok(Ok(event)) => {
314 if matches!(event.kind, notify::EventKind::Access(_)) {
315 continue;
316 }
317 let matches = file_name.as_ref().is_none_or(|name| {
318 event.paths.iter().any(|p| p.file_name() == Some(name))
319 });
320 if matches {
321 on_change();
322 }
323 }
324 Ok(Err(_)) => continue,
325 Err(_) => break,
326 }
327 }
328 })
329 .expect("failed to spawn file-watcher thread");
330}
331
332fn spawn_watcher(tx: broadcast::Sender<String>) {
333 let path = config_path();
334 spawn_file_watcher(path, "config", move || {
335 let map = read_config();
336 for (k, v) in &map {
337 let _ = tx.send(format!("{k}={v}"));
338 }
339 let _ = tx.send("ready".into());
340 });
341}
342
343#[derive(Clone)]
354pub struct RemotesState {
355 inner: Arc<RemotesInner>,
356}
357
358struct RemotesInner {
359 contents: RwLock<String>,
361 tx: broadcast::Sender<String>,
362}
363
364impl RemotesState {
365 pub fn new() -> Self {
367 let (tx, _) = broadcast::channel(64);
368 let inner = Arc::new(RemotesInner {
369 contents: RwLock::new(serialize_remotes(&read_remotes())),
370 tx,
371 });
372 let watcher_inner = inner.clone();
373 spawn_file_watcher(remotes_path(), "remotes", move || {
374 let text = std::fs::read_to_string(remotes_path()).unwrap_or_default();
377 *watcher_inner.contents.write().unwrap() = text.clone();
378 let _ = watcher_inner.tx.send(text);
379 });
380 Self { inner }
381 }
382
383 pub fn ephemeral(initial: String) -> Self {
387 let (tx, _) = broadcast::channel(64);
388 Self {
389 inner: Arc::new(RemotesInner {
390 contents: RwLock::new(initial),
391 tx,
392 }),
393 }
394 }
395
396 pub fn get(&self) -> String {
398 self.inner.contents.read().unwrap().clone()
399 }
400
401 pub fn set(&self, entries: &[(String, String)]) {
403 write_remotes(entries);
404 let text = serialize_remotes(entries);
405 *self.inner.contents.write().unwrap() = text.clone();
406 let _ = self.inner.tx.send(text);
407 }
408
409 pub fn modify(&self, f: impl FnOnce(&mut Vec<(String, String)>)) {
412 let _lock = lock_config_dir();
413 let mut entries = parse_remotes_str(&self.get());
414 f(&mut entries);
415 self.set(&entries);
416 }
417
418 pub fn subscribe(&self) -> broadcast::Receiver<String> {
419 self.inner.tx.subscribe()
420 }
421}
422
423impl Default for RemotesState {
424 fn default() -> Self {
425 Self::new()
426 }
427}
428
429fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
430 if a.len() != b.len() {
431 return false;
432 }
433 let mut diff = 0u8;
434 for (x, y) in a.iter().zip(b.iter()) {
435 diff |= x ^ y;
436 }
437 std::hint::black_box(diff) == 0
438}
439
440fn parse_config_str(contents: &str) -> HashMap<String, String> {
441 let mut map = HashMap::new();
442 for line in contents.lines() {
443 let line = line.trim();
444 if line.is_empty() || line.starts_with('#') {
445 continue;
446 }
447 if let Some((k, v)) = line.split_once('=') {
448 map.insert(k.trim().to_string(), v.trim().to_string());
449 }
450 }
451 map
452}
453
454pub async fn handle_config_ws(
481 mut ws: WebSocket,
482 token: &str,
483 config: &ConfigState,
484 remotes: Option<&RemotesState>,
485 remotes_transform: Option<fn(&str) -> String>,
486 extra_init: &[String],
487) {
488 let authed = loop {
489 match ws.recv().await {
490 Some(Ok(Message::Text(pass))) => {
491 if constant_time_eq(pass.trim().as_bytes(), token.as_bytes()) {
492 let _ = ws.send(Message::Text("ok".into())).await;
493 break true;
494 } else {
495 let _ = ws.close().await;
496 break false;
497 }
498 }
499 Some(Ok(Message::Ping(d))) => {
500 let _ = ws.send(Message::Pong(d)).await;
501 }
502 _ => break false,
503 }
504 };
505 if !authed {
506 return;
507 }
508
509 let mut remotes_rx = remotes.map(|r| r.subscribe());
511
512 let remotes_text = remotes.map(|r| r.get()).unwrap_or_default();
515 let remotes_text = remotes_transform
516 .map(|f| f(&remotes_text))
517 .unwrap_or(remotes_text);
518 if ws
519 .send(Message::Text(format!("remotes:{remotes_text}").into()))
520 .await
521 .is_err()
522 {
523 return;
524 }
525
526 let map = read_config();
527 for (k, v) in &map {
528 if ws
529 .send(Message::Text(format!("{k}={v}").into()))
530 .await
531 .is_err()
532 {
533 return;
534 }
535 }
536 for msg in extra_init {
537 if ws.send(Message::Text(msg.clone().into())).await.is_err() {
538 return;
539 }
540 }
541 if ws.send(Message::Text("ready".into())).await.is_err() {
542 return;
543 }
544
545 let mut config_rx = config.tx.subscribe();
546
547 loop {
548 tokio::select! {
552 msg = ws.recv() => {
553 match msg {
554 Some(Ok(Message::Text(text))) => {
555 let text = text.trim();
556 if let Some(rest) = text.strip_prefix("set ")
557 && let Some((k, v)) = rest.split_once(' ') {
558 let k = k.trim().replace(['\n', '\r'], "");
559 let v = v.trim().replace(['\n', '\r'], "");
560 if k.is_empty() { continue; }
561 modify_config(|map| {
562 if v.is_empty() {
563 map.remove(&k);
564 } else {
565 map.insert(k, v);
566 }
567 });
568 } else if let Some(rest) = text.strip_prefix("remotes-add ") {
569 if let Some((raw_name, raw_uri)) = rest.split_once(' ') {
572 let name = raw_name.trim().replace(['\n', '\r'], "");
573 let uri = raw_uri.trim().replace(['\n', '\r'], "");
574 if !name.is_empty()
575 && !name.contains('=')
576 && !uri.is_empty()
577 && let Some(r) = remotes
578 {
579 r.modify(|entries| {
580 if let Some(pos) = entries.iter().position(|(n, _)| n == &name) {
581 entries[pos].1 = uri;
582 } else {
583 entries.push((name, uri));
584 }
585 });
586 }
587 }
588 } else if let Some(name) = text.strip_prefix("remotes-remove ") {
589 let name = name.trim().replace(['\n', '\r'], "");
590 if !name.is_empty()
591 && let Some(r) = remotes
592 {
593 r.modify(|entries| {
594 entries.retain(|(n, _)| n != &name);
595 });
596 }
597 } else if let Some(name) = text.strip_prefix("remotes-set-default ") {
598 let name = name.trim().replace(['\n', '\r'], "");
600 modify_config(|map| {
601 if name.is_empty() || name == "local" {
602 map.remove("blit.target");
603 } else {
604 map.insert("blit.target".into(), name);
605 }
606 });
607 } else if let Some(rest) = text.strip_prefix("remotes-reorder ") {
608 if let Some(r) = remotes {
611 let desired: Vec<String> = rest
612 .split_whitespace()
613 .map(|s| s.replace(['\n', '\r'], ""))
614 .filter(|s| !s.is_empty())
615 .collect();
616 if !desired.is_empty() {
617 r.modify(|entries| {
618 let map: std::collections::HashMap<&str, &str> = entries
619 .iter()
620 .map(|(n, u)| (n.as_str(), u.as_str()))
621 .collect();
622 let mut reordered: Vec<(String, String)> = desired
623 .iter()
624 .filter_map(|n| {
625 map.get(n.as_str())
626 .map(|u| (n.clone(), u.to_string()))
627 })
628 .collect();
629 let desired_set: std::collections::HashSet<&str> =
630 desired.iter().map(|s| s.as_str()).collect();
631 for (n, u) in entries.iter() {
632 if !desired_set.contains(n.as_str()) {
633 reordered.push((n.clone(), u.clone()));
634 }
635 }
636 *entries = reordered;
637 });
638 }
639 }
640 }
641 }
642 Some(Ok(Message::Close(_))) | None => break,
643 Some(Err(_)) => break,
644 _ => continue,
645 }
646 }
647 broadcast = config_rx.recv() => {
648 match broadcast {
649 Ok(line) => {
650 if ws.send(Message::Text(line.into())).await.is_err() {
651 break;
652 }
653 }
654 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
655 Err(_) => break,
656 }
657 }
658 remotes_update = async {
659 match remotes_rx.as_mut() {
660 Some(rx) => rx.recv().await,
661 None => std::future::pending().await,
662 }
663 } => {
664 match remotes_update {
665 Ok(text) => {
666 let text = remotes_transform
667 .map(|f| f(&text))
668 .unwrap_or(text);
669 if ws
670 .send(Message::Text(format!("remotes:{text}").into()))
671 .await
672 .is_err()
673 {
674 break;
675 }
676 }
677 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
678 if let Some(r) = remotes {
680 let text = r.get();
681 let text = remotes_transform
682 .map(|f| f(&text))
683 .unwrap_or(text);
684 if ws
685 .send(Message::Text(format!("remotes:{text}").into()))
686 .await
687 .is_err()
688 {
689 break;
690 }
691 }
692 }
693 Err(_) => break,
694 }
695 }
696 }
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
707 fn ct_eq_equal_slices() {
708 assert!(constant_time_eq(b"hello", b"hello"));
709 }
710
711 #[test]
712 fn ct_eq_different_slices() {
713 assert!(!constant_time_eq(b"hello", b"world"));
714 }
715
716 #[test]
717 fn ct_eq_different_lengths() {
718 assert!(!constant_time_eq(b"short", b"longer"));
719 }
720
721 #[test]
722 fn ct_eq_empty_slices() {
723 assert!(constant_time_eq(b"", b""));
724 }
725
726 #[test]
727 fn ct_eq_single_bit_diff() {
728 assert!(!constant_time_eq(b"\x00", b"\x01"));
729 }
730
731 #[test]
732 fn ct_eq_one_empty_one_not() {
733 assert!(!constant_time_eq(b"", b"x"));
734 }
735
736 #[test]
739 fn parse_empty_string() {
740 let map = parse_config_str("");
741 assert!(map.is_empty());
742 }
743
744 #[test]
745 fn parse_comments_and_blanks() {
746 let map = parse_config_str("# comment\n\n # another\n");
747 assert!(map.is_empty());
748 }
749
750 #[test]
751 fn parse_key_value() {
752 let map = parse_config_str("font = Menlo\ntheme = dark\n");
753 assert_eq!(map.get("font").unwrap(), "Menlo");
754 assert_eq!(map.get("theme").unwrap(), "dark");
755 }
756
757 #[test]
758 fn parse_trims_whitespace() {
759 let map = parse_config_str(" key = value ");
760 assert_eq!(map.get("key").unwrap(), "value");
761 }
762
763 #[test]
764 fn parse_line_without_equals() {
765 let map = parse_config_str("no-equals-here\nkey=val");
766 assert_eq!(map.len(), 1);
767 assert_eq!(map.get("key").unwrap(), "val");
768 }
769
770 #[test]
771 fn parse_equals_in_value() {
772 let map = parse_config_str("cmd = a=b=c");
773 assert_eq!(map.get("cmd").unwrap(), "a=b=c");
774 }
775
776 #[test]
777 fn parse_duplicate_keys_last_wins() {
778 let map = parse_config_str("key = first\nkey = second");
779 assert_eq!(map.get("key").unwrap(), "second");
780 }
781
782 #[test]
783 fn parse_mixed_content() {
784 let input = "# header\nfont = FiraCode\n\n# size\nsize = 14\ntheme=light";
785 let map = parse_config_str(input);
786 assert_eq!(map.len(), 3);
787 assert_eq!(map.get("font").unwrap(), "FiraCode");
788 assert_eq!(map.get("size").unwrap(), "14");
789 assert_eq!(map.get("theme").unwrap(), "light");
790 }
791
792 #[test]
795 fn serialize_config_produces_sorted_output() {
796 let mut map: HashMap<String, String> = HashMap::new();
797 map.insert("z".into(), "last".into());
798 map.insert("a".into(), "first".into());
799 let output = serialize_config_str(&map);
800 assert!(output.starts_with("a = first"));
801 assert!(output.contains("z = last"));
802 }
803
804 #[test]
805 fn round_trip_parse_serialize() {
806 let input = "alpha = 1\nbeta = 2\ngamma = 3";
807 let map = parse_config_str(input);
808 let serialized = serialize_config_str(&map);
809 let reparsed = parse_config_str(&serialized);
810 assert_eq!(map, reparsed);
811 }
812
813 #[test]
816 fn remotes_add_new_entry() {
817 let state = RemotesState::ephemeral(String::new());
818 let mut entries = parse_remotes_str(&state.get());
819 entries.push(("rabbit".to_string(), "ssh:rabbit".to_string()));
820 state.set(&entries);
821 let got = parse_remotes_str(&state.get());
822 assert_eq!(got.len(), 1);
823 assert_eq!(got[0], ("rabbit".to_string(), "ssh:rabbit".to_string()));
824 }
825
826 #[test]
827 fn remotes_add_updates_existing() {
828 let initial = "rabbit = ssh:rabbit\n";
829 let state = RemotesState::ephemeral(initial.to_string());
830 let mut entries = parse_remotes_str(&state.get());
831 if let Some(pos) = entries.iter().position(|(n, _)| n == "rabbit") {
832 entries[pos].1 = "tcp:rabbit:3264".to_string();
833 }
834 state.set(&entries);
835 let got = parse_remotes_str(&state.get());
836 assert_eq!(got.len(), 1);
837 assert_eq!(got[0].1, "tcp:rabbit:3264");
838 }
839
840 #[test]
841 fn remotes_remove_existing() {
842 let initial = "rabbit = ssh:rabbit\nhound = ssh:hound\n";
843 let state = RemotesState::ephemeral(initial.to_string());
844 let mut entries = parse_remotes_str(&state.get());
845 entries.retain(|(n, _)| n != "rabbit");
846 state.set(&entries);
847 let got = parse_remotes_str(&state.get());
848 assert_eq!(got.len(), 1);
849 assert_eq!(got[0].0, "hound");
850 }
851
852 #[test]
853 fn remotes_remove_nonexistent_is_noop() {
854 let initial = "rabbit = ssh:rabbit\n";
855 let state = RemotesState::ephemeral(initial.to_string());
856 let mut entries = parse_remotes_str(&state.get());
857 let before = entries.len();
858 entries.retain(|(n, _)| n != "does-not-exist");
859 assert_eq!(entries.len(), before);
860 }
861
862 #[test]
863 fn remotes_add_rejects_empty_name() {
864 let name = "";
866 assert!(name.is_empty() || name.contains('='));
867 }
868
869 #[test]
870 fn remotes_add_rejects_name_with_equals() {
871 let name = "foo=bar";
872 assert!(name.contains('='));
873 }
874
875 #[test]
878 fn set_default_inserts_target_key() {
879 let mut map = parse_config_str("font = Mono\n");
880 map.insert("blit.target".into(), "rabbit".into());
881 let serialized = serialize_config_str(&map);
882 let reparsed = parse_config_str(&serialized);
883 assert_eq!(
884 reparsed.get("blit.target").map(|s| s.as_str()),
885 Some("rabbit")
886 );
887 assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
888 }
889
890 #[test]
891 fn set_default_local_removes_target_key() {
892 let mut map = parse_config_str("blit.target = rabbit\nfont = Mono\n");
893 map.remove("blit.target");
895 let serialized = serialize_config_str(&map);
896 let reparsed = parse_config_str(&serialized);
897 assert!(!reparsed.contains_key("blit.target"));
898 assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
899 }
900}