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
141#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct RemoteEntry {
146 pub name: String,
147 pub uri: String,
148 pub disabled: bool,
149}
150
151pub fn read_remotes() -> Vec<(String, String)> {
155 read_remotes_full()
156 .into_iter()
157 .filter(|e| !e.disabled)
158 .map(|e| (e.name, e.uri))
159 .collect()
160}
161
162pub fn read_remotes_full() -> Vec<RemoteEntry> {
164 let path = remotes_path();
165 let contents = match std::fs::read_to_string(&path) {
166 Ok(c) => c,
167 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
168 let default = vec![RemoteEntry {
169 name: "local".to_string(),
170 uri: "local".to_string(),
171 disabled: false,
172 }];
173 write_remotes(&default);
174 return default;
175 }
176 Err(e) => {
177 eprintln!("blit: could not read {}: {e}", path.display());
178 return vec![];
179 }
180 };
181 parse_remotes_full(&contents)
182}
183
184pub fn modify_config(f: impl FnOnce(&mut HashMap<String, String>)) {
186 let _lock = lock_config_dir();
187 let mut map = read_config();
188 f(&mut map);
189 write_config(&map);
190}
191
192pub fn modify_remotes(f: impl FnOnce(&mut Vec<RemoteEntry>)) {
194 let _lock = lock_config_dir();
195 let mut entries = read_remotes_full();
196 f(&mut entries);
197 write_remotes(&entries);
198}
199
200pub fn parse_remotes_str(contents: &str) -> Vec<(String, String)> {
204 parse_remotes_full(contents)
205 .into_iter()
206 .filter(|e| !e.disabled)
207 .map(|e| (e.name, e.uri))
208 .collect()
209}
210
211pub fn parse_remotes_full(contents: &str) -> Vec<RemoteEntry> {
216 let mut order: Vec<String> = Vec::new();
217 let mut map: HashMap<String, RemoteEntry> = HashMap::new();
218 for line in contents.lines() {
219 let line = line.trim();
220 if line.is_empty() {
221 continue;
222 }
223 let (body, disabled) = if let Some(rest) = line.strip_prefix('#') {
224 (rest.trim_start(), true)
225 } else {
226 (line, false)
227 };
228 let Some((k, v)) = body.split_once('=') else {
229 continue;
230 };
231 let name = k.trim().to_string();
232 let uri = v.trim().to_string();
233 if name.is_empty() || uri.is_empty() {
234 continue;
235 }
236 if !map.contains_key(&name) {
237 order.push(name.clone());
238 }
239 map.insert(
240 name.clone(),
241 RemoteEntry {
242 name,
243 uri,
244 disabled,
245 },
246 );
247 }
248 order.into_iter().map(|k| map.remove(&k).unwrap()).collect()
249}
250
251fn serialize_remotes(entries: &[RemoteEntry]) -> String {
252 let mut out = String::new();
253 for e in entries {
254 if e.disabled {
255 out.push_str("# ");
256 }
257 out.push_str(&e.name);
258 out.push_str(" = ");
259 out.push_str(&e.uri);
260 out.push('\n');
261 }
262 out
263}
264
265pub fn write_remotes(entries: &[RemoteEntry]) {
267 let path = remotes_path();
268 if let Some(parent) = path.parent() {
269 let _ = std::fs::create_dir_all(parent);
270 }
271 let contents = serialize_remotes(entries);
272 write_secret_file(&path, &contents);
273}
274
275fn write_secret_file(path: &PathBuf, contents: &str) {
279 #[cfg(unix)]
280 {
281 use std::os::unix::fs::OpenOptionsExt;
282 use std::sync::atomic::{AtomicU32, Ordering};
285 static COUNTER: AtomicU32 = AtomicU32::new(0);
286 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
287 let pid = std::process::id();
288 let tmp = path.with_extension(format!("tmp.{pid}.{seq}"));
289 let result = std::fs::OpenOptions::new()
290 .write(true)
291 .create(true)
292 .truncate(true)
293 .mode(0o600)
294 .open(&tmp)
295 .and_then(|mut f| {
296 use std::io::Write;
297 f.write_all(contents.as_bytes())
298 });
299 if result.is_ok() {
300 let _ = std::fs::rename(&tmp, path);
301 } else {
302 let _ = std::fs::remove_file(&tmp);
303 }
304 }
305 #[cfg(not(unix))]
306 {
307 let _ = std::fs::write(path, contents);
308 }
309}
310
311fn serialize_config_str(map: &HashMap<String, String>) -> String {
312 let mut lines: Vec<String> = map.iter().map(|(k, v)| format!("{k} = {v}")).collect();
313 lines.sort();
314 lines.push(String::new());
315 lines.join("\n")
316}
317
318pub fn write_config(map: &HashMap<String, String>) {
319 let path = config_path();
320 if let Some(parent) = path.parent() {
321 let _ = std::fs::create_dir_all(parent);
322 }
323 write_secret_file(&path, &serialize_config_str(map));
324}
325
326fn spawn_file_watcher<F>(path: PathBuf, label: &'static str, on_change: F)
329where
330 F: Fn() + Send + 'static,
331{
332 use notify::{RecursiveMode, Watcher};
333
334 if let Some(parent) = path.parent() {
335 let _ = std::fs::create_dir_all(parent);
336 }
337
338 let watch_dir = path.parent().unwrap_or(&path).to_path_buf();
339 let file_name = path.file_name().map(|n| n.to_os_string());
340
341 std::thread::Builder::new()
342 .name(format!("{label}-watcher"))
343 .spawn(move || {
344 let (ntx, nrx) = std::sync::mpsc::channel();
345 let mut watcher = match notify::recommended_watcher(ntx) {
346 Ok(w) => w,
347 Err(e) => {
348 eprintln!("blit: {label} watcher failed: {e}");
349 return;
350 }
351 };
352 if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
353 eprintln!("blit: {label} watch failed: {e}");
354 return;
355 }
356 loop {
357 match nrx.recv() {
358 Ok(Ok(event)) => {
359 if matches!(event.kind, notify::EventKind::Access(_)) {
360 continue;
361 }
362 let matches = file_name.as_ref().is_none_or(|name| {
363 event.paths.iter().any(|p| p.file_name() == Some(name))
364 });
365 if matches {
366 on_change();
367 }
368 }
369 Ok(Err(_)) => continue,
370 Err(_) => break,
371 }
372 }
373 })
374 .expect("failed to spawn file-watcher thread");
375}
376
377fn spawn_watcher(tx: broadcast::Sender<String>) {
378 let path = config_path();
379 spawn_file_watcher(path, "config", move || {
380 let map = read_config();
381 for (k, v) in &map {
382 let _ = tx.send(format!("{k}={v}"));
383 }
384 let _ = tx.send("ready".into());
385 });
386}
387
388#[derive(Clone)]
399pub struct RemotesState {
400 inner: Arc<RemotesInner>,
401}
402
403struct RemotesInner {
404 contents: RwLock<String>,
406 tx: broadcast::Sender<String>,
407}
408
409impl RemotesState {
410 pub fn new() -> Self {
412 let (tx, _) = broadcast::channel(64);
413 let inner = Arc::new(RemotesInner {
414 contents: RwLock::new(serialize_remotes(&read_remotes_full())),
415 tx,
416 });
417 let watcher_inner = inner.clone();
418 spawn_file_watcher(remotes_path(), "remotes", move || {
419 let text = std::fs::read_to_string(remotes_path()).unwrap_or_default();
422 *watcher_inner.contents.write().unwrap() = text.clone();
423 let _ = watcher_inner.tx.send(text);
424 });
425 Self { inner }
426 }
427
428 pub fn ephemeral(initial: String) -> Self {
432 let (tx, _) = broadcast::channel(64);
433 Self {
434 inner: Arc::new(RemotesInner {
435 contents: RwLock::new(initial),
436 tx,
437 }),
438 }
439 }
440
441 pub fn get(&self) -> String {
443 self.inner.contents.read().unwrap().clone()
444 }
445
446 pub fn set(&self, entries: &[RemoteEntry]) {
448 write_remotes(entries);
449 let text = serialize_remotes(entries);
450 *self.inner.contents.write().unwrap() = text.clone();
451 let _ = self.inner.tx.send(text);
452 }
453
454 pub fn modify(&self, f: impl FnOnce(&mut Vec<RemoteEntry>)) {
457 let _lock = lock_config_dir();
458 let mut entries = parse_remotes_full(&self.get());
459 f(&mut entries);
460 self.set(&entries);
461 }
462
463 pub fn subscribe(&self) -> broadcast::Receiver<String> {
464 self.inner.tx.subscribe()
465 }
466}
467
468impl Default for RemotesState {
469 fn default() -> Self {
470 Self::new()
471 }
472}
473
474fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
475 let mut diff = (a.len() ^ b.len()) as u8;
476 for i in 0..a.len().min(b.len()) {
477 diff |= a[i] ^ b[i];
478 }
479 std::hint::black_box(diff) == 0
480}
481
482fn parse_config_str(contents: &str) -> HashMap<String, String> {
483 let mut map = HashMap::new();
484 for line in contents.lines() {
485 let line = line.trim();
486 if line.is_empty() || line.starts_with('#') {
487 continue;
488 }
489 if let Some((k, v)) = line.split_once('=') {
490 map.insert(k.trim().to_string(), v.trim().to_string());
491 }
492 }
493 map
494}
495
496pub async fn handle_config_ws(
529 mut ws: WebSocket,
530 token: &str,
531 config: &ConfigState,
532 remotes: Option<&RemotesState>,
533 remotes_transform: Option<fn(&str) -> String>,
534 extra_init: &[String],
535) {
536 let authed = loop {
537 match ws.recv().await {
538 Some(Ok(Message::Text(pass))) => {
539 if constant_time_eq(pass.trim().as_bytes(), token.as_bytes()) {
540 let _ = ws.send(Message::Text("ok".into())).await;
541 break true;
542 } else {
543 let _ = ws.close().await;
544 break false;
545 }
546 }
547 Some(Ok(Message::Ping(d))) => {
548 let _ = ws.send(Message::Pong(d)).await;
549 }
550 _ => break false,
551 }
552 };
553 if !authed {
554 return;
555 }
556
557 let mut remotes_rx = remotes.map(|r| r.subscribe());
559
560 let remotes_text = remotes.map(|r| r.get()).unwrap_or_default();
563 let remotes_text = remotes_transform
564 .map(|f| f(&remotes_text))
565 .unwrap_or(remotes_text);
566 if ws
567 .send(Message::Text(format!("remotes:{remotes_text}").into()))
568 .await
569 .is_err()
570 {
571 return;
572 }
573
574 let map = read_config();
575 for (k, v) in &map {
576 if ws
577 .send(Message::Text(format!("{k}={v}").into()))
578 .await
579 .is_err()
580 {
581 return;
582 }
583 }
584 for msg in extra_init {
585 if ws.send(Message::Text(msg.clone().into())).await.is_err() {
586 return;
587 }
588 }
589 if ws.send(Message::Text("ready".into())).await.is_err() {
590 return;
591 }
592
593 let mut config_rx = config.tx.subscribe();
594
595 loop {
596 tokio::select! {
600 msg = ws.recv() => {
601 match msg {
602 Some(Ok(Message::Text(text))) => {
603 let text = text.trim();
604 if let Some(rest) = text.strip_prefix("set ")
605 && let Some((k, v)) = rest.split_once(' ') {
606 let k = k.trim().replace(['\n', '\r'], "");
607 let v = v.trim().replace(['\n', '\r'], "");
608 if k.is_empty() { continue; }
609 modify_config(|map| {
610 if v.is_empty() {
611 map.remove(&k);
612 } else {
613 map.insert(k, v);
614 }
615 });
616 } else if let Some(rest) = text.strip_prefix("remotes-add ") {
617 if let Some((raw_name, raw_uri)) = rest.split_once(' ') {
620 let name = raw_name.trim().replace(['\n', '\r'], "");
621 let uri = raw_uri.trim().replace(['\n', '\r'], "");
622 if !name.is_empty()
623 && !name.contains('=')
624 && !uri.is_empty()
625 && let Some(r) = remotes
626 {
627 r.modify(|entries| {
628 if let Some(pos) = entries.iter().position(|e| e.name == name) {
629 entries[pos].uri = uri;
630 entries[pos].disabled = false;
633 } else {
634 entries.push(RemoteEntry {
635 name,
636 uri,
637 disabled: false,
638 });
639 }
640 });
641 }
642 }
643 } else if let Some(name) = text.strip_prefix("remotes-remove ") {
644 let name = name.trim().replace(['\n', '\r'], "");
645 if !name.is_empty()
646 && let Some(r) = remotes
647 {
648 r.modify(|entries| {
649 entries.retain(|e| e.name != name);
650 });
651 }
652 } else if let Some(name) = text.strip_prefix("remotes-toggle ") {
653 let name = name.trim().replace(['\n', '\r'], "");
654 if !name.is_empty()
655 && let Some(r) = remotes
656 {
657 r.modify(|entries| {
658 if let Some(pos) =
659 entries.iter().position(|e| e.name == name)
660 {
661 entries[pos].disabled = !entries[pos].disabled;
662 }
663 });
664 }
665 } else if let Some(name) = text.strip_prefix("remotes-set-default ") {
666 let name = name.trim().replace(['\n', '\r'], "");
668 modify_config(|map| {
669 if name.is_empty() || name == "local" {
670 map.remove("blit.target");
671 } else {
672 map.insert("blit.target".into(), name);
673 }
674 });
675 } else if let Some(rest) = text.strip_prefix("remotes-reorder ") {
676 if let Some(r) = remotes {
679 let desired: Vec<String> = rest
680 .split_whitespace()
681 .map(|s| s.replace(['\n', '\r'], ""))
682 .filter(|s| !s.is_empty())
683 .collect();
684 if !desired.is_empty() {
685 r.modify(|entries| {
686 let by_name: std::collections::HashMap<String, RemoteEntry> =
687 entries
688 .iter()
689 .map(|e| (e.name.clone(), e.clone()))
690 .collect();
691 let mut reordered: Vec<RemoteEntry> = desired
692 .iter()
693 .filter_map(|n| by_name.get(n).cloned())
694 .collect();
695 let desired_set: std::collections::HashSet<&str> =
696 desired.iter().map(|s| s.as_str()).collect();
697 for e in entries.iter() {
698 if !desired_set.contains(e.name.as_str()) {
699 reordered.push(e.clone());
700 }
701 }
702 *entries = reordered;
703 });
704 }
705 }
706 }
707 }
708 Some(Ok(Message::Close(_))) | None => break,
709 Some(Err(_)) => break,
710 _ => continue,
711 }
712 }
713 broadcast = config_rx.recv() => {
714 match broadcast {
715 Ok(line) => {
716 if ws.send(Message::Text(line.into())).await.is_err() {
717 break;
718 }
719 }
720 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
721 Err(_) => break,
722 }
723 }
724 remotes_update = async {
725 match remotes_rx.as_mut() {
726 Some(rx) => rx.recv().await,
727 None => std::future::pending().await,
728 }
729 } => {
730 match remotes_update {
731 Ok(text) => {
732 let text = remotes_transform
733 .map(|f| f(&text))
734 .unwrap_or(text);
735 if ws
736 .send(Message::Text(format!("remotes:{text}").into()))
737 .await
738 .is_err()
739 {
740 break;
741 }
742 }
743 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
744 if let Some(r) = remotes {
746 let text = r.get();
747 let text = remotes_transform
748 .map(|f| f(&text))
749 .unwrap_or(text);
750 if ws
751 .send(Message::Text(format!("remotes:{text}").into()))
752 .await
753 .is_err()
754 {
755 break;
756 }
757 }
758 }
759 Err(_) => break,
760 }
761 }
762 }
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[test]
773 fn ct_eq_equal_slices() {
774 assert!(constant_time_eq(b"hello", b"hello"));
775 }
776
777 #[test]
778 fn ct_eq_different_slices() {
779 assert!(!constant_time_eq(b"hello", b"world"));
780 }
781
782 #[test]
783 fn ct_eq_different_lengths() {
784 assert!(!constant_time_eq(b"short", b"longer"));
785 }
786
787 #[test]
788 fn ct_eq_empty_slices() {
789 assert!(constant_time_eq(b"", b""));
790 }
791
792 #[test]
793 fn ct_eq_single_bit_diff() {
794 assert!(!constant_time_eq(b"\x00", b"\x01"));
795 }
796
797 #[test]
798 fn ct_eq_one_empty_one_not() {
799 assert!(!constant_time_eq(b"", b"x"));
800 }
801
802 #[test]
805 fn parse_empty_string() {
806 let map = parse_config_str("");
807 assert!(map.is_empty());
808 }
809
810 #[test]
811 fn parse_comments_and_blanks() {
812 let map = parse_config_str("# comment\n\n # another\n");
813 assert!(map.is_empty());
814 }
815
816 #[test]
817 fn parse_key_value() {
818 let map = parse_config_str("font = Menlo\ntheme = dark\n");
819 assert_eq!(map.get("font").unwrap(), "Menlo");
820 assert_eq!(map.get("theme").unwrap(), "dark");
821 }
822
823 #[test]
824 fn parse_trims_whitespace() {
825 let map = parse_config_str(" key = value ");
826 assert_eq!(map.get("key").unwrap(), "value");
827 }
828
829 #[test]
830 fn parse_line_without_equals() {
831 let map = parse_config_str("no-equals-here\nkey=val");
832 assert_eq!(map.len(), 1);
833 assert_eq!(map.get("key").unwrap(), "val");
834 }
835
836 #[test]
837 fn parse_equals_in_value() {
838 let map = parse_config_str("cmd = a=b=c");
839 assert_eq!(map.get("cmd").unwrap(), "a=b=c");
840 }
841
842 #[test]
843 fn parse_duplicate_keys_last_wins() {
844 let map = parse_config_str("key = first\nkey = second");
845 assert_eq!(map.get("key").unwrap(), "second");
846 }
847
848 #[test]
849 fn parse_mixed_content() {
850 let input = "# header\nfont = FiraCode\n\n# size\nsize = 14\ntheme=light";
851 let map = parse_config_str(input);
852 assert_eq!(map.len(), 3);
853 assert_eq!(map.get("font").unwrap(), "FiraCode");
854 assert_eq!(map.get("size").unwrap(), "14");
855 assert_eq!(map.get("theme").unwrap(), "light");
856 }
857
858 #[test]
861 fn serialize_config_produces_sorted_output() {
862 let mut map: HashMap<String, String> = HashMap::new();
863 map.insert("z".into(), "last".into());
864 map.insert("a".into(), "first".into());
865 let output = serialize_config_str(&map);
866 assert!(output.starts_with("a = first"));
867 assert!(output.contains("z = last"));
868 }
869
870 #[test]
871 fn round_trip_parse_serialize() {
872 let input = "alpha = 1\nbeta = 2\ngamma = 3";
873 let map = parse_config_str(input);
874 let serialized = serialize_config_str(&map);
875 let reparsed = parse_config_str(&serialized);
876 assert_eq!(map, reparsed);
877 }
878
879 fn entry(name: &str, uri: &str) -> RemoteEntry {
882 RemoteEntry {
883 name: name.to_string(),
884 uri: uri.to_string(),
885 disabled: false,
886 }
887 }
888
889 #[test]
890 fn remotes_add_new_entry() {
891 let state = RemotesState::ephemeral(String::new());
892 let mut entries = parse_remotes_full(&state.get());
893 entries.push(entry("rabbit", "ssh:rabbit"));
894 state.set(&entries);
895 let got = parse_remotes_str(&state.get());
896 assert_eq!(got.len(), 1);
897 assert_eq!(got[0], ("rabbit".to_string(), "ssh:rabbit".to_string()));
898 }
899
900 #[test]
901 fn remotes_add_updates_existing() {
902 let initial = "rabbit = ssh:rabbit\n";
903 let state = RemotesState::ephemeral(initial.to_string());
904 let mut entries = parse_remotes_full(&state.get());
905 if let Some(pos) = entries.iter().position(|e| e.name == "rabbit") {
906 entries[pos].uri = "tcp:rabbit:3264".to_string();
907 }
908 state.set(&entries);
909 let got = parse_remotes_str(&state.get());
910 assert_eq!(got.len(), 1);
911 assert_eq!(got[0].1, "tcp:rabbit:3264");
912 }
913
914 #[test]
915 fn remotes_remove_existing() {
916 let initial = "rabbit = ssh:rabbit\nhound = ssh:hound\n";
917 let state = RemotesState::ephemeral(initial.to_string());
918 let mut entries = parse_remotes_full(&state.get());
919 entries.retain(|e| e.name != "rabbit");
920 state.set(&entries);
921 let got = parse_remotes_str(&state.get());
922 assert_eq!(got.len(), 1);
923 assert_eq!(got[0].0, "hound");
924 }
925
926 #[test]
927 fn remotes_remove_nonexistent_is_noop() {
928 let initial = "rabbit = ssh:rabbit\n";
929 let state = RemotesState::ephemeral(initial.to_string());
930 let mut entries = parse_remotes_full(&state.get());
931 let before = entries.len();
932 entries.retain(|e| e.name != "does-not-exist");
933 assert_eq!(entries.len(), before);
934 }
935
936 #[test]
939 fn parse_disabled_entry() {
940 let entries = parse_remotes_full("# rabbit = ssh:rabbit\nhound = ssh:hound\n");
941 assert_eq!(entries.len(), 2);
942 assert_eq!(entries[0].name, "rabbit");
943 assert_eq!(entries[0].uri, "ssh:rabbit");
944 assert!(entries[0].disabled);
945 assert_eq!(entries[1].name, "hound");
946 assert!(!entries[1].disabled);
947 }
948
949 #[test]
950 fn parse_disabled_no_space_after_hash() {
951 let entries = parse_remotes_full("#rabbit = ssh:rabbit\n");
952 assert_eq!(entries.len(), 1);
953 assert!(entries[0].disabled);
954 }
955
956 #[test]
957 fn parse_remotes_str_filters_disabled() {
958 let active = parse_remotes_str("# rabbit = ssh:rabbit\nhound = ssh:hound\n");
959 assert_eq!(active.len(), 1);
960 assert_eq!(active[0].0, "hound");
961 }
962
963 #[test]
964 fn parse_skips_pure_comments() {
965 let entries = parse_remotes_full("# This is just a header\n# also a comment\n");
966 assert!(entries.is_empty());
967 }
968
969 #[test]
970 fn round_trip_disabled() {
971 let initial = "rabbit = ssh:rabbit\n# hound = ssh:hound\n";
972 let entries = parse_remotes_full(initial);
973 let serialized = serialize_remotes(&entries);
974 let reparsed = parse_remotes_full(&serialized);
975 assert_eq!(entries, reparsed);
976 assert!(serialized.contains("# hound = ssh:hound"));
977 }
978
979 #[test]
980 fn remotes_toggle_flips_state() {
981 let state = RemotesState::ephemeral("rabbit = ssh:rabbit\n".into());
982 state.modify(|entries| {
983 if let Some(pos) = entries.iter().position(|e| e.name == "rabbit") {
984 entries[pos].disabled = !entries[pos].disabled;
985 }
986 });
987 let entries = parse_remotes_full(&state.get());
988 assert_eq!(entries.len(), 1);
989 assert!(entries[0].disabled);
990 assert!(parse_remotes_str(&state.get()).is_empty());
992 }
993
994 #[test]
995 fn remotes_add_reenables_disabled() {
996 let state = RemotesState::ephemeral("# rabbit = ssh:old\n".into());
997 state.modify(|entries| {
999 let name = "rabbit".to_string();
1000 if let Some(pos) = entries.iter().position(|e| e.name == name) {
1001 entries[pos].uri = "ssh:new".to_string();
1002 entries[pos].disabled = false;
1003 } else {
1004 entries.push(RemoteEntry {
1005 name,
1006 uri: "ssh:new".to_string(),
1007 disabled: false,
1008 });
1009 }
1010 });
1011 let entries = parse_remotes_full(&state.get());
1012 assert_eq!(entries.len(), 1);
1013 assert_eq!(entries[0].uri, "ssh:new");
1014 assert!(!entries[0].disabled);
1015 }
1016
1017 #[test]
1018 fn remotes_reorder_preserves_disabled() {
1019 let initial = "alpha = a\n# beta = b\ngamma = c\n";
1020 let entries = parse_remotes_full(initial);
1021 let desired = ["gamma", "alpha", "beta"];
1023 let by_name: std::collections::HashMap<String, RemoteEntry> = entries
1024 .iter()
1025 .map(|e| (e.name.clone(), e.clone()))
1026 .collect();
1027 let reordered: Vec<RemoteEntry> = desired
1028 .iter()
1029 .filter_map(|n| by_name.get(*n).cloned())
1030 .collect();
1031 let serialized = serialize_remotes(&reordered);
1032 let reparsed = parse_remotes_full(&serialized);
1033 assert_eq!(reparsed.len(), 3);
1034 assert_eq!(reparsed[0].name, "gamma");
1035 assert!(!reparsed[0].disabled);
1036 assert_eq!(reparsed[2].name, "beta");
1037 assert!(reparsed[2].disabled);
1038 }
1039
1040 #[test]
1041 fn remotes_add_rejects_empty_name() {
1042 let name = "";
1044 assert!(name.is_empty() || name.contains('='));
1045 }
1046
1047 #[test]
1048 fn remotes_add_rejects_name_with_equals() {
1049 let name = "foo=bar";
1050 assert!(name.contains('='));
1051 }
1052
1053 #[test]
1056 fn set_default_inserts_target_key() {
1057 let mut map = parse_config_str("font = Mono\n");
1058 map.insert("blit.target".into(), "rabbit".into());
1059 let serialized = serialize_config_str(&map);
1060 let reparsed = parse_config_str(&serialized);
1061 assert_eq!(
1062 reparsed.get("blit.target").map(|s| s.as_str()),
1063 Some("rabbit")
1064 );
1065 assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
1066 }
1067
1068 #[test]
1069 fn set_default_local_removes_target_key() {
1070 let mut map = parse_config_str("blit.target = rabbit\nfont = Mono\n");
1071 map.remove("blit.target");
1073 let serialized = serialize_config_str(&map);
1074 let reparsed = parse_config_str(&serialized);
1075 assert!(!reparsed.contains_key("blit.target"));
1076 assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
1077 }
1078}