@@ -12,10 +12,20 @@ use anyhow::Result;
1212use notify:: { Event , EventKind , RecommendedWatcher , RecursiveMode , Watcher } ;
1313use tokio:: sync:: mpsc;
1414use tracing:: { debug, error, info, warn} ;
15+ use uuid:: Uuid ;
1516
1617use mcpmux_core:: application:: { SyncResult , UserSpaceSyncService } ;
1718use mcpmux_core:: InstalledServerRepository ;
1819
20+ type SyncSuccessHandler = Arc < dyn Fn ( & str , & SyncResult ) + Send + Sync > ;
21+ type SyncErrorHandler = Arc < dyn Fn ( & str , & str ) + Send + Sync > ;
22+
23+ /// Optional UI callbacks for background config sync.
24+ pub struct SpaceFileWatcherEmitters {
25+ pub on_success : Option < SyncSuccessHandler > ,
26+ pub on_error : Option < SyncErrorHandler > ,
27+ }
28+
1929/// File watcher for user space configuration files
2030///
2131/// Monitors a directory for JSON file changes and syncs servers
@@ -34,17 +44,14 @@ impl SpaceFileWatcher {
3444 /// # Arguments
3545 /// * `spaces_dir` - Directory containing space JSON config files
3646 /// * `sync_service` - Service to sync changes
37- /// * `default_space_id` - Default space ID to use for synced servers
38- /// * `event_emitter ` - Optional callback to emit UI events after sync
39- pub fn new < F > (
47+ /// * `default_space_id` - Fallback space ID when filename stem is not a UUID
48+ /// * `emitters ` - Optional callbacks for sync success/failure
49+ pub fn new (
4050 spaces_dir : PathBuf ,
4151 sync_service : Arc < UserSpaceSyncService > ,
4252 default_space_id : String ,
43- event_emitter : Option < F > ,
44- ) -> Result < Self >
45- where
46- F : Fn ( & str , & SyncResult ) + Send + Sync + ' static ,
47- {
53+ emitters : SpaceFileWatcherEmitters ,
54+ ) -> Result < Self > {
4855 // Ensure directory exists
4956 if !spaces_dir. exists ( ) {
5057 std:: fs:: create_dir_all ( & spaces_dir) ?;
@@ -56,10 +63,9 @@ impl SpaceFileWatcher {
5663 // Spawn debounced handler
5764 let sync_clone = sync_service. clone ( ) ;
5865 let space_id = default_space_id. clone ( ) ;
59- let emitter = event_emitter. map ( Arc :: new) ;
6066
6167 tokio:: spawn ( async move {
62- Self :: debounced_handler ( rx, sync_clone, space_id, emitter ) . await ;
68+ Self :: debounced_handler ( rx, sync_clone, space_id, emitters ) . await ;
6369 } ) ;
6470
6571 // Create file watcher
@@ -97,17 +103,24 @@ impl SpaceFileWatcher {
97103 } )
98104 }
99105
106+ /// Resolve space ID from a config filename stem when it is a UUID.
107+ fn space_id_from_config_path ( path : & Path , default_space_id : & str ) -> String {
108+ path. file_stem ( )
109+ . and_then ( |stem| stem. to_str ( ) )
110+ . filter ( |stem| Uuid :: parse_str ( stem) . is_ok ( ) )
111+ . map ( String :: from)
112+ . unwrap_or_else ( || default_space_id. to_string ( ) )
113+ }
114+
100115 /// Debounced handler for file changes
101116 ///
102117 /// Groups rapid file changes and syncs after a debounce period.
103- async fn debounced_handler < F > (
118+ async fn debounced_handler (
104119 mut rx : mpsc:: Receiver < PathBuf > ,
105120 sync_service : Arc < UserSpaceSyncService > ,
106121 default_space_id : String ,
107- event_emitter : Option < Arc < F > > ,
108- ) where
109- F : Fn ( & str , & SyncResult ) + Send + Sync + ' static ,
110- {
122+ emitters : SpaceFileWatcherEmitters ,
123+ ) {
111124 let debounce_duration = Duration :: from_millis ( 500 ) ;
112125 let mut pending: HashMap < PathBuf , Instant > = HashMap :: new ( ) ;
113126
@@ -129,13 +142,12 @@ impl SpaceFileWatcher {
129142 for path in ready {
130143 pending. remove( & path) ;
131144
132- // Extract space_id from filename (e.g., "default.json" -> use default_space_id)
133- // For now, use the default space for all config files
134- let space_id = & default_space_id;
145+ let space_id =
146+ Self :: space_id_from_config_path( & path, & default_space_id) ;
135147
136148 info!( "Syncing changes from: {:?}" , path) ;
137149
138- match sync_service. sync_from_file( space_id, & path) . await {
150+ match sync_service. sync_from_file( & space_id, & path) . await {
139151 Ok ( result) => {
140152 if result. has_changes( ) {
141153 info!(
@@ -145,16 +157,19 @@ impl SpaceFileWatcher {
145157 result. removed. len( )
146158 ) ;
147159
148- // Emit event for UI refresh
149- if let Some ( ref emitter) = event_emitter {
150- emitter( space_id, & result) ;
160+ if let Some ( ref emitter) = emitters. on_success {
161+ emitter( & space_id, & result) ;
151162 }
152163 } else {
153164 debug!( "Sync complete: no changes" ) ;
154165 }
155166 }
156167 Err ( e) => {
157- error!( "Sync failed for {:?}: {}" , path, e) ;
168+ let message = e. to_string( ) ;
169+ error!( "Sync failed for {:?}: {}" , path, message) ;
170+ if let Some ( ref emitter) = emitters. on_error {
171+ emitter( & space_id, & message) ;
172+ }
158173 }
159174 }
160175 }
@@ -198,11 +213,14 @@ impl SpaceFileWatcherBuilder {
198213 /// Build the file watcher without event emitter
199214 pub fn build ( self ) -> Result < SpaceFileWatcher > {
200215 let sync_service = Arc :: new ( UserSpaceSyncService :: new ( self . installed_repo ) ) ;
201- SpaceFileWatcher :: new :: < fn ( & str , & SyncResult ) > (
216+ SpaceFileWatcher :: new (
202217 self . spaces_dir ,
203218 sync_service,
204219 self . default_space_id ,
205- None ,
220+ SpaceFileWatcherEmitters {
221+ on_success : None ,
222+ on_error : None ,
223+ } ,
206224 )
207225 }
208226
@@ -216,17 +234,34 @@ impl SpaceFileWatcherBuilder {
216234 self . spaces_dir ,
217235 sync_service,
218236 self . default_space_id ,
219- Some ( emitter) ,
237+ SpaceFileWatcherEmitters {
238+ on_success : Some ( Arc :: new ( emitter) ) ,
239+ on_error : None ,
240+ } ,
220241 )
221242 }
222243}
223244
224245#[ cfg( test) ]
225246mod tests {
247+ use super :: * ;
248+ use std:: path:: Path ;
249+
250+ #[ test]
251+ fn space_id_from_config_path_uses_uuid_stem ( ) {
252+ let path = Path :: new ( "/tmp/spaces/00000000-0000-0000-0000-000000000001.json" ) ;
253+ assert_eq ! (
254+ SpaceFileWatcher :: space_id_from_config_path( path, "fallback" ) ,
255+ "00000000-0000-0000-0000-000000000001"
256+ ) ;
257+ }
226258
227259 #[ test]
228- fn test_builder_default_space_id ( ) {
229- // Just test the builder pattern compiles
230- // Actual functionality tested via integration tests
260+ fn space_id_from_config_path_falls_back_for_non_uuid_stem ( ) {
261+ let path = Path :: new ( "/tmp/spaces/default.json" ) ;
262+ assert_eq ! (
263+ SpaceFileWatcher :: space_id_from_config_path( path, "fallback-id" ) ,
264+ "fallback-id"
265+ ) ;
231266 }
232267}
0 commit comments