lean_ctx/server/
tools_config_watch.rs1use std::sync::atomic::{AtomicU64, Ordering};
14
15use crate::core::config::Config;
16
17#[must_use]
21pub fn tools_config_hash(cfg: &Config) -> u64 {
22 use std::hash::{Hash, Hasher};
23 let mut hasher = std::collections::hash_map::DefaultHasher::new();
24 cfg.tool_profile.hash(&mut hasher);
25 cfg.tools_enabled.hash(&mut hasher);
26 cfg.disabled_tools.hash(&mut hasher);
27 hasher.finish()
28}
29
30#[must_use]
32pub fn current_hash() -> u64 {
33 tools_config_hash(&Config::load())
34}
35
36#[must_use]
41pub fn has_changed(last_hash: &AtomicU64) -> bool {
42 let now = current_hash();
43 let prev = last_hash.load(Ordering::Relaxed);
44 if now == prev {
45 false
46 } else {
47 last_hash.store(now, Ordering::Relaxed);
48 true
49 }
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55
56 #[test]
57 fn same_config_produces_same_hash() {
58 let cfg = Config::default();
59 assert_eq!(tools_config_hash(&cfg), tools_config_hash(&cfg));
60 }
61
62 #[test]
63 fn different_profile_produces_different_hash() {
64 let mut a = Config::default();
65 let mut b = Config::default();
66 a.tool_profile = Some("minimal".to_string());
67 b.tool_profile = Some("standard".to_string());
68 assert_ne!(tools_config_hash(&a), tools_config_hash(&b));
69 }
70
71 #[test]
72 fn different_disabled_tools_produces_different_hash() {
73 let mut a = Config::default();
74 let mut b = Config::default();
75 a.disabled_tools = vec![];
76 b.disabled_tools = vec!["ctx_call".to_string()];
77 assert_ne!(tools_config_hash(&a), tools_config_hash(&b));
78 }
79
80 #[test]
81 fn has_changed_returns_false_when_unchanged() {
82 let hash = AtomicU64::new(current_hash());
83 assert!(!has_changed(&hash));
84 }
85
86 #[test]
87 fn has_changed_detects_difference() {
88 let hash = AtomicU64::new(0);
89 assert!(has_changed(&hash));
90 assert!(!has_changed(&hash));
91 }
92
93 #[test]
94 fn different_enabled_tools_produces_different_hash() {
95 let mut a = Config::default();
96 let mut b = Config::default();
97 a.tools_enabled = vec![];
98 b.tools_enabled = vec!["ctx_read".to_string(), "ctx_shell".to_string()];
99 assert_ne!(tools_config_hash(&a), tools_config_hash(&b));
100 }
101
102 #[test]
103 fn all_three_fields_contribute_to_hash() {
104 let base = Config::default();
105 let base_hash = tools_config_hash(&base);
106
107 let mut with_profile = base.clone();
108 with_profile.tool_profile = Some("power".to_string());
109
110 let mut with_enabled = base.clone();
111 with_enabled.tools_enabled = vec!["ctx_read".to_string()];
112
113 let mut with_disabled = base.clone();
114 with_disabled.disabled_tools = vec!["ctx_graph".to_string()];
115
116 let hashes = [
117 tools_config_hash(&with_profile),
118 tools_config_hash(&with_enabled),
119 tools_config_hash(&with_disabled),
120 ];
121 for h in &hashes {
122 assert_ne!(*h, base_hash, "changing any field must produce a new hash");
123 }
124 assert_ne!(hashes[0], hashes[1]);
125 assert_ne!(hashes[1], hashes[2]);
126 }
127
128 #[test]
129 fn has_changed_stabilizes_after_update() {
130 let hash = AtomicU64::new(0);
131 assert!(has_changed(&hash), "first call with mismatched seed");
132 let stored = hash.load(Ordering::Relaxed);
133 assert_ne!(stored, 0, "hash should have been updated");
134 assert!(!has_changed(&hash), "same config → no change");
135 assert!(!has_changed(&hash), "still no change on third call");
136 }
137}