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 let mut diff = (a.len() ^ b.len()) as u8;
431 for i in 0..a.len().min(b.len()) {
432 diff |= a[i] ^ b[i];
433 }
434 std::hint::black_box(diff) == 0
435}
436
437fn parse_config_str(contents: &str) -> HashMap<String, String> {
438 let mut map = HashMap::new();
439 for line in contents.lines() {
440 let line = line.trim();
441 if line.is_empty() || line.starts_with('#') {
442 continue;
443 }
444 if let Some((k, v)) = line.split_once('=') {
445 map.insert(k.trim().to_string(), v.trim().to_string());
446 }
447 }
448 map
449}
450
451pub async fn handle_config_ws(
478 mut ws: WebSocket,
479 token: &str,
480 config: &ConfigState,
481 remotes: Option<&RemotesState>,
482 remotes_transform: Option<fn(&str) -> String>,
483 extra_init: &[String],
484) {
485 let authed = loop {
486 match ws.recv().await {
487 Some(Ok(Message::Text(pass))) => {
488 if constant_time_eq(pass.trim().as_bytes(), token.as_bytes()) {
489 let _ = ws.send(Message::Text("ok".into())).await;
490 break true;
491 } else {
492 let _ = ws.close().await;
493 break false;
494 }
495 }
496 Some(Ok(Message::Ping(d))) => {
497 let _ = ws.send(Message::Pong(d)).await;
498 }
499 _ => break false,
500 }
501 };
502 if !authed {
503 return;
504 }
505
506 let mut remotes_rx = remotes.map(|r| r.subscribe());
508
509 let remotes_text = remotes.map(|r| r.get()).unwrap_or_default();
512 let remotes_text = remotes_transform
513 .map(|f| f(&remotes_text))
514 .unwrap_or(remotes_text);
515 if ws
516 .send(Message::Text(format!("remotes:{remotes_text}").into()))
517 .await
518 .is_err()
519 {
520 return;
521 }
522
523 let map = read_config();
524 for (k, v) in &map {
525 if ws
526 .send(Message::Text(format!("{k}={v}").into()))
527 .await
528 .is_err()
529 {
530 return;
531 }
532 }
533 for msg in extra_init {
534 if ws.send(Message::Text(msg.clone().into())).await.is_err() {
535 return;
536 }
537 }
538 if ws.send(Message::Text("ready".into())).await.is_err() {
539 return;
540 }
541
542 let mut config_rx = config.tx.subscribe();
543
544 loop {
545 tokio::select! {
549 msg = ws.recv() => {
550 match msg {
551 Some(Ok(Message::Text(text))) => {
552 let text = text.trim();
553 if let Some(rest) = text.strip_prefix("set ")
554 && let Some((k, v)) = rest.split_once(' ') {
555 let k = k.trim().replace(['\n', '\r'], "");
556 let v = v.trim().replace(['\n', '\r'], "");
557 if k.is_empty() { continue; }
558 modify_config(|map| {
559 if v.is_empty() {
560 map.remove(&k);
561 } else {
562 map.insert(k, v);
563 }
564 });
565 } else if let Some(rest) = text.strip_prefix("remotes-add ") {
566 if let Some((raw_name, raw_uri)) = rest.split_once(' ') {
569 let name = raw_name.trim().replace(['\n', '\r'], "");
570 let uri = raw_uri.trim().replace(['\n', '\r'], "");
571 if !name.is_empty()
572 && !name.contains('=')
573 && !uri.is_empty()
574 && let Some(r) = remotes
575 {
576 r.modify(|entries| {
577 if let Some(pos) = entries.iter().position(|(n, _)| n == &name) {
578 entries[pos].1 = uri;
579 } else {
580 entries.push((name, uri));
581 }
582 });
583 }
584 }
585 } else if let Some(name) = text.strip_prefix("remotes-remove ") {
586 let name = name.trim().replace(['\n', '\r'], "");
587 if !name.is_empty()
588 && let Some(r) = remotes
589 {
590 r.modify(|entries| {
591 entries.retain(|(n, _)| n != &name);
592 });
593 }
594 } else if let Some(name) = text.strip_prefix("remotes-set-default ") {
595 let name = name.trim().replace(['\n', '\r'], "");
597 modify_config(|map| {
598 if name.is_empty() || name == "local" {
599 map.remove("blit.target");
600 } else {
601 map.insert("blit.target".into(), name);
602 }
603 });
604 } else if let Some(rest) = text.strip_prefix("remotes-reorder ") {
605 if let Some(r) = remotes {
608 let desired: Vec<String> = rest
609 .split_whitespace()
610 .map(|s| s.replace(['\n', '\r'], ""))
611 .filter(|s| !s.is_empty())
612 .collect();
613 if !desired.is_empty() {
614 r.modify(|entries| {
615 let map: std::collections::HashMap<&str, &str> = entries
616 .iter()
617 .map(|(n, u)| (n.as_str(), u.as_str()))
618 .collect();
619 let mut reordered: Vec<(String, String)> = desired
620 .iter()
621 .filter_map(|n| {
622 map.get(n.as_str())
623 .map(|u| (n.clone(), u.to_string()))
624 })
625 .collect();
626 let desired_set: std::collections::HashSet<&str> =
627 desired.iter().map(|s| s.as_str()).collect();
628 for (n, u) in entries.iter() {
629 if !desired_set.contains(n.as_str()) {
630 reordered.push((n.clone(), u.clone()));
631 }
632 }
633 *entries = reordered;
634 });
635 }
636 }
637 }
638 }
639 Some(Ok(Message::Close(_))) | None => break,
640 Some(Err(_)) => break,
641 _ => continue,
642 }
643 }
644 broadcast = config_rx.recv() => {
645 match broadcast {
646 Ok(line) => {
647 if ws.send(Message::Text(line.into())).await.is_err() {
648 break;
649 }
650 }
651 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
652 Err(_) => break,
653 }
654 }
655 remotes_update = async {
656 match remotes_rx.as_mut() {
657 Some(rx) => rx.recv().await,
658 None => std::future::pending().await,
659 }
660 } => {
661 match remotes_update {
662 Ok(text) => {
663 let text = remotes_transform
664 .map(|f| f(&text))
665 .unwrap_or(text);
666 if ws
667 .send(Message::Text(format!("remotes:{text}").into()))
668 .await
669 .is_err()
670 {
671 break;
672 }
673 }
674 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
675 if let Some(r) = remotes {
677 let text = r.get();
678 let text = remotes_transform
679 .map(|f| f(&text))
680 .unwrap_or(text);
681 if ws
682 .send(Message::Text(format!("remotes:{text}").into()))
683 .await
684 .is_err()
685 {
686 break;
687 }
688 }
689 }
690 Err(_) => break,
691 }
692 }
693 }
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
704 fn ct_eq_equal_slices() {
705 assert!(constant_time_eq(b"hello", b"hello"));
706 }
707
708 #[test]
709 fn ct_eq_different_slices() {
710 assert!(!constant_time_eq(b"hello", b"world"));
711 }
712
713 #[test]
714 fn ct_eq_different_lengths() {
715 assert!(!constant_time_eq(b"short", b"longer"));
716 }
717
718 #[test]
719 fn ct_eq_empty_slices() {
720 assert!(constant_time_eq(b"", b""));
721 }
722
723 #[test]
724 fn ct_eq_single_bit_diff() {
725 assert!(!constant_time_eq(b"\x00", b"\x01"));
726 }
727
728 #[test]
729 fn ct_eq_one_empty_one_not() {
730 assert!(!constant_time_eq(b"", b"x"));
731 }
732
733 #[test]
736 fn parse_empty_string() {
737 let map = parse_config_str("");
738 assert!(map.is_empty());
739 }
740
741 #[test]
742 fn parse_comments_and_blanks() {
743 let map = parse_config_str("# comment\n\n # another\n");
744 assert!(map.is_empty());
745 }
746
747 #[test]
748 fn parse_key_value() {
749 let map = parse_config_str("font = Menlo\ntheme = dark\n");
750 assert_eq!(map.get("font").unwrap(), "Menlo");
751 assert_eq!(map.get("theme").unwrap(), "dark");
752 }
753
754 #[test]
755 fn parse_trims_whitespace() {
756 let map = parse_config_str(" key = value ");
757 assert_eq!(map.get("key").unwrap(), "value");
758 }
759
760 #[test]
761 fn parse_line_without_equals() {
762 let map = parse_config_str("no-equals-here\nkey=val");
763 assert_eq!(map.len(), 1);
764 assert_eq!(map.get("key").unwrap(), "val");
765 }
766
767 #[test]
768 fn parse_equals_in_value() {
769 let map = parse_config_str("cmd = a=b=c");
770 assert_eq!(map.get("cmd").unwrap(), "a=b=c");
771 }
772
773 #[test]
774 fn parse_duplicate_keys_last_wins() {
775 let map = parse_config_str("key = first\nkey = second");
776 assert_eq!(map.get("key").unwrap(), "second");
777 }
778
779 #[test]
780 fn parse_mixed_content() {
781 let input = "# header\nfont = FiraCode\n\n# size\nsize = 14\ntheme=light";
782 let map = parse_config_str(input);
783 assert_eq!(map.len(), 3);
784 assert_eq!(map.get("font").unwrap(), "FiraCode");
785 assert_eq!(map.get("size").unwrap(), "14");
786 assert_eq!(map.get("theme").unwrap(), "light");
787 }
788
789 #[test]
792 fn serialize_config_produces_sorted_output() {
793 let mut map: HashMap<String, String> = HashMap::new();
794 map.insert("z".into(), "last".into());
795 map.insert("a".into(), "first".into());
796 let output = serialize_config_str(&map);
797 assert!(output.starts_with("a = first"));
798 assert!(output.contains("z = last"));
799 }
800
801 #[test]
802 fn round_trip_parse_serialize() {
803 let input = "alpha = 1\nbeta = 2\ngamma = 3";
804 let map = parse_config_str(input);
805 let serialized = serialize_config_str(&map);
806 let reparsed = parse_config_str(&serialized);
807 assert_eq!(map, reparsed);
808 }
809
810 #[test]
813 fn remotes_add_new_entry() {
814 let state = RemotesState::ephemeral(String::new());
815 let mut entries = parse_remotes_str(&state.get());
816 entries.push(("rabbit".to_string(), "ssh:rabbit".to_string()));
817 state.set(&entries);
818 let got = parse_remotes_str(&state.get());
819 assert_eq!(got.len(), 1);
820 assert_eq!(got[0], ("rabbit".to_string(), "ssh:rabbit".to_string()));
821 }
822
823 #[test]
824 fn remotes_add_updates_existing() {
825 let initial = "rabbit = ssh:rabbit\n";
826 let state = RemotesState::ephemeral(initial.to_string());
827 let mut entries = parse_remotes_str(&state.get());
828 if let Some(pos) = entries.iter().position(|(n, _)| n == "rabbit") {
829 entries[pos].1 = "tcp:rabbit:3264".to_string();
830 }
831 state.set(&entries);
832 let got = parse_remotes_str(&state.get());
833 assert_eq!(got.len(), 1);
834 assert_eq!(got[0].1, "tcp:rabbit:3264");
835 }
836
837 #[test]
838 fn remotes_remove_existing() {
839 let initial = "rabbit = ssh:rabbit\nhound = ssh:hound\n";
840 let state = RemotesState::ephemeral(initial.to_string());
841 let mut entries = parse_remotes_str(&state.get());
842 entries.retain(|(n, _)| n != "rabbit");
843 state.set(&entries);
844 let got = parse_remotes_str(&state.get());
845 assert_eq!(got.len(), 1);
846 assert_eq!(got[0].0, "hound");
847 }
848
849 #[test]
850 fn remotes_remove_nonexistent_is_noop() {
851 let initial = "rabbit = ssh:rabbit\n";
852 let state = RemotesState::ephemeral(initial.to_string());
853 let mut entries = parse_remotes_str(&state.get());
854 let before = entries.len();
855 entries.retain(|(n, _)| n != "does-not-exist");
856 assert_eq!(entries.len(), before);
857 }
858
859 #[test]
860 fn remotes_add_rejects_empty_name() {
861 let name = "";
863 assert!(name.is_empty() || name.contains('='));
864 }
865
866 #[test]
867 fn remotes_add_rejects_name_with_equals() {
868 let name = "foo=bar";
869 assert!(name.contains('='));
870 }
871
872 #[test]
875 fn set_default_inserts_target_key() {
876 let mut map = parse_config_str("font = Mono\n");
877 map.insert("blit.target".into(), "rabbit".into());
878 let serialized = serialize_config_str(&map);
879 let reparsed = parse_config_str(&serialized);
880 assert_eq!(
881 reparsed.get("blit.target").map(|s| s.as_str()),
882 Some("rabbit")
883 );
884 assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
885 }
886
887 #[test]
888 fn set_default_local_removes_target_key() {
889 let mut map = parse_config_str("blit.target = rabbit\nfont = Mono\n");
890 map.remove("blit.target");
892 let serialized = serialize_config_str(&map);
893 let reparsed = parse_config_str(&serialized);
894 assert!(!reparsed.contains_key("blit.target"));
895 assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
896 }
897}