Skip to main content

subcog/hooks/
stop.rs

1//! Stop hook handler.
2
3use super::HookHandler;
4use crate::Result;
5use crate::current_timestamp;
6use crate::observability::current_request_id;
7use crate::services::SyncService;
8use std::time::{Duration, Instant};
9use tracing::instrument;
10
11/// Default timeout for stop hook operations (30 seconds).
12const DEFAULT_TIMEOUT_MS: u64 = 30_000;
13
14/// Handles Stop hook events.
15///
16/// Performs session analysis and sync at session end.
17/// Includes timeout enforcement to prevent hanging (RES-M2).
18pub struct StopHandler {
19    /// Sync service.
20    sync: Option<SyncService>,
21    /// Whether to auto-sync on stop.
22    auto_sync: bool,
23    /// Timeout for stop hook operations in milliseconds.
24    timeout_ms: u64,
25}
26
27impl StopHandler {
28    /// Creates a new handler with default 30s timeout.
29    #[must_use]
30    pub const fn new() -> Self {
31        Self {
32            sync: None,
33            auto_sync: true,
34            timeout_ms: DEFAULT_TIMEOUT_MS,
35        }
36    }
37
38    /// Sets the timeout in milliseconds.
39    ///
40    /// Operations that exceed this timeout will return a partial response.
41    #[must_use]
42    pub const fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
43        self.timeout_ms = timeout_ms;
44        self
45    }
46
47    /// Sets the sync service.
48    #[must_use]
49    pub fn with_sync(mut self, sync: SyncService) -> Self {
50        self.sync = Some(sync);
51        self
52    }
53
54    /// Enables or disables auto-sync.
55    #[must_use]
56    pub const fn with_auto_sync(mut self, enabled: bool) -> Self {
57        self.auto_sync = enabled;
58        self
59    }
60
61    /// Generates a session summary.
62    #[allow(clippy::cast_possible_truncation)]
63    fn generate_summary(&self, input: &serde_json::Value) -> SessionSummary {
64        // Extract session info
65        let session_id = input
66            .get("session_id")
67            .and_then(|v| v.as_str())
68            .unwrap_or("unknown")
69            .to_string();
70
71        let start_time = input
72            .get("start_time")
73            .and_then(serde_json::Value::as_u64)
74            .unwrap_or(0);
75
76        let end_time = current_timestamp();
77        let duration_seconds = end_time.saturating_sub(start_time);
78
79        // Count interactions (from transcript if available)
80        // Safe cast: interaction counts are always small
81        let interaction_count = input
82            .get("interaction_count")
83            .and_then(serde_json::Value::as_u64)
84            .unwrap_or(0) as usize;
85
86        // Count memories captured during session
87        // Safe cast: memory counts are always small
88        let memories_captured = input
89            .get("memories_captured")
90            .and_then(serde_json::Value::as_u64)
91            .unwrap_or(0) as usize;
92
93        // Count tools used
94        let tools_used = input
95            .get("tools_used")
96            .and_then(|v| v.as_array())
97            .map_or(0, std::vec::Vec::len);
98
99        // Extract namespace counts
100        let namespace_counts = Self::extract_namespace_counts(input);
101
102        // Extract tags used with frequencies
103        let tags_used = Self::extract_tags_used(input);
104
105        // Extract query patterns
106        let query_patterns = Self::extract_query_patterns(input);
107
108        // Extract resources read
109        let resources_read = Self::extract_resources_read(input);
110
111        SessionSummary {
112            session_id,
113            duration_seconds,
114            interaction_count,
115            memories_captured,
116            tools_used,
117            namespace_counts,
118            tags_used,
119            query_patterns,
120            resources_read,
121        }
122    }
123
124    /// Extracts namespace statistics from input.
125    #[allow(clippy::cast_possible_truncation)]
126    fn extract_namespace_counts(
127        input: &serde_json::Value,
128    ) -> std::collections::HashMap<String, NamespaceStats> {
129        let Some(ns_stats) = input.get("namespace_stats").and_then(|v| v.as_object()) else {
130            return std::collections::HashMap::new();
131        };
132
133        ns_stats
134            .iter()
135            .filter_map(|(ns, stats)| {
136                let captures = stats
137                    .get("captures")
138                    .and_then(serde_json::Value::as_u64)
139                    .unwrap_or(0) as usize;
140                let recalls = stats
141                    .get("recalls")
142                    .and_then(serde_json::Value::as_u64)
143                    .unwrap_or(0) as usize;
144
145                if captures > 0 || recalls > 0 {
146                    Some((ns.clone(), NamespaceStats { captures, recalls }))
147                } else {
148                    None
149                }
150            })
151            .collect()
152    }
153
154    /// Extracts tags used with frequencies, sorted by count descending.
155    #[allow(clippy::cast_possible_truncation)]
156    fn extract_tags_used(input: &serde_json::Value) -> Vec<(String, usize)> {
157        let Some(tag_array) = input.get("tags_used").and_then(|v| v.as_array()) else {
158            return Vec::new();
159        };
160
161        let mut tags: Vec<(String, usize)> = tag_array
162            .iter()
163            .filter_map(|entry| {
164                let obj = entry.as_object()?;
165                let tag = obj.get("tag").and_then(|v| v.as_str())?;
166                let count = obj.get("count").and_then(serde_json::Value::as_u64)? as usize;
167                Some((tag.to_string(), count))
168            })
169            .collect();
170
171        // Sort by count descending, limit to top 10
172        tags.sort_by(|a, b| b.1.cmp(&a.1));
173        tags.truncate(10);
174        tags
175    }
176
177    /// Extracts query patterns from the session.
178    fn extract_query_patterns(input: &serde_json::Value) -> Vec<String> {
179        input
180            .get("query_patterns")
181            .and_then(|v| v.as_array())
182            .map(|arr| {
183                arr.iter()
184                    .filter_map(|v| v.as_str().map(String::from))
185                    .collect()
186            })
187            .unwrap_or_default()
188    }
189
190    /// Extracts MCP resources read during the session.
191    fn extract_resources_read(input: &serde_json::Value) -> Vec<String> {
192        input
193            .get("resources_read")
194            .and_then(|v| v.as_array())
195            .map(|arr| {
196                arr.iter()
197                    .filter_map(|v| v.as_str().map(String::from))
198                    .collect()
199            })
200            .unwrap_or_default()
201    }
202
203    /// Performs sync if enabled and available.
204    fn perform_sync(&self) -> Option<SyncResult> {
205        if !self.auto_sync {
206            return None;
207        }
208
209        let sync = self.sync.as_ref()?;
210
211        match sync.sync() {
212            Ok(stats) => Some(SyncResult {
213                success: true,
214                pushed: stats.pushed,
215                pulled: stats.pulled,
216                error: None,
217            }),
218            Err(e) => Some(SyncResult {
219                success: false,
220                pushed: 0,
221                pulled: 0,
222                error: Some(e.to_string()),
223            }),
224        }
225    }
226
227    /// Builds metadata JSON from session summary.
228    fn build_metadata(
229        summary: &SessionSummary,
230        sync_result: Option<&SyncResult>,
231    ) -> serde_json::Value {
232        let mut metadata = serde_json::json!({
233            "session_id": summary.session_id,
234            "duration_seconds": summary.duration_seconds,
235            "interaction_count": summary.interaction_count,
236            "memories_captured": summary.memories_captured,
237            "tools_used": summary.tools_used,
238        });
239
240        // Add namespace stats
241        if !summary.namespace_counts.is_empty() {
242            let ns_json: serde_json::Map<String, serde_json::Value> = summary
243                .namespace_counts
244                .iter()
245                .map(|(ns, stats)| {
246                    (
247                        ns.clone(),
248                        serde_json::json!({
249                            "captures": stats.captures,
250                            "recalls": stats.recalls
251                        }),
252                    )
253                })
254                .collect();
255            metadata["namespace_stats"] = serde_json::Value::Object(ns_json);
256        }
257
258        // Add tags
259        if !summary.tags_used.is_empty() {
260            metadata["tags_used"] = serde_json::json!(summary.tags_used);
261        }
262
263        // Add query patterns
264        if !summary.query_patterns.is_empty() {
265            metadata["query_patterns"] = serde_json::json!(summary.query_patterns);
266        }
267
268        // Add resources read
269        if !summary.resources_read.is_empty() {
270            metadata["resources_read"] = serde_json::json!(summary.resources_read);
271        }
272
273        // Add sync results
274        if let Some(sync) = sync_result {
275            metadata["sync"] = serde_json::json!({
276                "performed": true,
277                "success": sync.success,
278                "pushed": sync.pushed,
279                "pulled": sync.pulled,
280                "error": sync.error
281            });
282        } else {
283            metadata["sync"] = serde_json::json!({ "performed": false });
284        }
285
286        // Add hints if applicable
287        if summary.memories_captured == 0 && summary.interaction_count > 5 {
288            metadata["hints"] = serde_json::json!([
289                "Consider capturing key decisions made during this session",
290                "Use 'mcp__plugin_subcog_subcog__subcog_capture' to save important learnings"
291            ]);
292        }
293
294        metadata
295    }
296
297    /// Builds context message lines from session summary.
298    fn build_context_lines(summary: &SessionSummary, sync_result: Option<&SyncResult>) -> String {
299        let mut lines = vec![
300            "**Subcog Session Summary**\n".to_string(),
301            format!("Session: `{}`", summary.session_id),
302            format!("Duration: {} seconds", summary.duration_seconds),
303            format!("Interactions: {}", summary.interaction_count),
304            format!("Memories captured: {}", summary.memories_captured),
305            format!("Tools used: {}", summary.tools_used),
306        ];
307
308        // Namespace breakdown
309        if !summary.namespace_counts.is_empty() {
310            lines.push("\n**Namespace Breakdown**:".to_string());
311            lines.push("| Namespace | Captures | Recalls |".to_string());
312            lines.push("|-----------|----------|---------|".to_string());
313            let mut sorted_ns: Vec<_> = summary.namespace_counts.iter().collect();
314            sorted_ns.sort_by_key(|(ns, _)| *ns);
315            for (ns, stats) in sorted_ns {
316                lines.push(format!(
317                    "| {} | {} | {} |",
318                    ns, stats.captures, stats.recalls
319                ));
320            }
321        }
322
323        // Top tags
324        if !summary.tags_used.is_empty() {
325            let tags_str: Vec<String> = summary
326                .tags_used
327                .iter()
328                .take(5)
329                .map(|(tag, count)| format!("`{tag}` ({count})"))
330                .collect();
331            lines.push(format!("\n**Top Tags**: {}", tags_str.join(", ")));
332        }
333
334        // Query patterns
335        if !summary.query_patterns.is_empty() {
336            let patterns_str: Vec<String> = summary
337                .query_patterns
338                .iter()
339                .take(5)
340                .map(|p| format!("`{p}`"))
341                .collect();
342            lines.push(format!("\n**Query Patterns**: {}", patterns_str.join(", ")));
343        }
344
345        // Resources read
346        if !summary.resources_read.is_empty() {
347            lines.push(format!(
348                "\n**Resources Read**: {} unique resources",
349                summary.resources_read.len()
350            ));
351        }
352
353        // Sync status
354        if let Some(sync) = sync_result {
355            if sync.success {
356                lines.push(format!(
357                    "\n**Sync**: ✓ {} pushed, {} pulled",
358                    sync.pushed, sync.pulled
359                ));
360            } else {
361                lines.push(format!(
362                    "\n**Sync**: ✗ Failed - {}",
363                    sync.error.as_deref().unwrap_or("Unknown error")
364                ));
365            }
366        }
367
368        // Capture hint
369        if summary.memories_captured == 0 && summary.interaction_count > 5 {
370            lines.push("\n**Tip**: No memories were captured this session. Consider using `mcp__plugin_subcog_subcog__subcog_capture` to save important decisions and learnings.".to_string());
371        }
372
373        lines.join("\n")
374    }
375}
376
377impl Default for StopHandler {
378    fn default() -> Self {
379        Self::new()
380    }
381}
382
383impl HookHandler for StopHandler {
384    fn event_type(&self) -> &'static str {
385        "Stop"
386    }
387
388    #[instrument(
389        name = "subcog.hook.stop",
390        skip(self, input),
391        fields(
392            request_id = tracing::field::Empty,
393            component = "hooks",
394            operation = "stop",
395            hook = "Stop",
396            session_id = tracing::field::Empty,
397            sync_performed = tracing::field::Empty,
398            timed_out = tracing::field::Empty
399        )
400    )]
401    fn handle(&self, input: &str) -> Result<String> {
402        let start = Instant::now();
403        let deadline = Duration::from_millis(self.timeout_ms);
404        let mut timed_out = false;
405        if let Some(request_id) = current_request_id() {
406            tracing::Span::current().record("request_id", request_id.as_str());
407        }
408
409        tracing::info!(
410            hook = "Stop",
411            timeout_ms = self.timeout_ms,
412            "Processing stop hook"
413        );
414
415        // Parse input and generate summary
416        let input_json: serde_json::Value =
417            serde_json::from_str(input).unwrap_or_else(|_| serde_json::json!({}));
418        let summary = self.generate_summary(&input_json);
419
420        // Record session ID in span
421        let span = tracing::Span::current();
422        span.record("session_id", summary.session_id.as_str());
423
424        // Check deadline before sync (RES-M2)
425        // Reserve 1 second for response building
426        let sync_result = if start.elapsed() < deadline.saturating_sub(Duration::from_secs(1)) {
427            self.perform_sync()
428        } else {
429            tracing::warn!(
430                hook = "Stop",
431                elapsed_ms = start.elapsed().as_millis(),
432                deadline_ms = self.timeout_ms,
433                "Skipping sync due to timeout deadline"
434            );
435            timed_out = true;
436            None
437        };
438        span.record("sync_performed", sync_result.is_some());
439
440        // Check deadline before response building
441        if start.elapsed() >= deadline {
442            tracing::warn!(
443                hook = "Stop",
444                elapsed_ms = start.elapsed().as_millis(),
445                deadline_ms = self.timeout_ms,
446                "Stop hook exceeded timeout, returning minimal response"
447            );
448            metrics::counter!(
449                "hook_timeouts_total",
450                "hook_type" => "Stop"
451            )
452            .increment(1);
453
454            // Return empty response on timeout
455            // Note: Stop hooks don't support hookSpecificOutput/additionalContext
456            // per Claude Code hook specification. Context is logged but not returned.
457            tracing::debug!(
458                session_id = %summary.session_id,
459                timed_out = true,
460                elapsed_ms = start.elapsed().as_millis(),
461                "Stop hook timed out, returning empty response"
462            );
463            span.record("timed_out", true);
464            return Ok("{}".to_string());
465        }
466
467        // Build response components for logging/debugging
468        let mut metadata = Self::build_metadata(&summary, sync_result.as_ref());
469        let context = Self::build_context_lines(&summary, sync_result.as_ref());
470
471        // Add timeout info to metadata if we were close to deadline
472        if timed_out {
473            metadata["sync_skipped_timeout"] = serde_json::json!(true);
474        }
475        #[allow(clippy::cast_possible_truncation)]
476        let elapsed_ms = start.elapsed().as_millis() as u64; // u128 to u64 safe for <584M years
477        metadata["elapsed_ms"] = serde_json::json!(elapsed_ms);
478
479        // Log the session summary for debugging (Stop hooks don't support
480        // hookSpecificOutput/additionalContext per Claude Code hook specification)
481        tracing::info!(
482            session_id = %summary.session_id,
483            duration_seconds = summary.duration_seconds,
484            interaction_count = summary.interaction_count,
485            memories_captured = summary.memories_captured,
486            sync_performed = sync_result.is_some(),
487            "Session ended"
488        );
489        tracing::debug!(context = %context, metadata = ?metadata, "Stop hook context (not returned)");
490
491        span.record("timed_out", timed_out);
492
493        // Return empty response - Stop hooks don't support context injection
494        let result = Ok("{}".to_string());
495
496        // Record metrics
497        let status = if result.is_ok() { "success" } else { "error" };
498        metrics::counter!("hook_executions_total", "hook_type" => "Stop", "status" => status)
499            .increment(1);
500        metrics::histogram!("hook_duration_ms", "hook_type" => "Stop")
501            .record(start.elapsed().as_secs_f64() * 1000.0);
502
503        result
504    }
505}
506
507/// Summary of a session.
508#[derive(Debug, Clone)]
509struct SessionSummary {
510    /// Session identifier.
511    session_id: String,
512    /// Duration in seconds.
513    duration_seconds: u64,
514    /// Number of interactions.
515    interaction_count: usize,
516    /// Number of memories captured.
517    memories_captured: usize,
518    /// Number of tools used.
519    tools_used: usize,
520    /// Per-namespace statistics (captures and recalls).
521    namespace_counts: std::collections::HashMap<String, NamespaceStats>,
522    /// Tags used with frequency (sorted by count, descending).
523    tags_used: Vec<(String, usize)>,
524    /// Query patterns seen during the session.
525    query_patterns: Vec<String>,
526    /// MCP resources read during the session.
527    resources_read: Vec<String>,
528}
529
530/// Statistics for a specific namespace.
531#[derive(Debug, Clone, Default)]
532struct NamespaceStats {
533    /// Number of captures in this namespace.
534    captures: usize,
535    /// Number of recalls in this namespace.
536    recalls: usize,
537}
538
539/// Result of a sync operation.
540#[derive(Debug, Clone)]
541struct SyncResult {
542    /// Whether sync succeeded.
543    success: bool,
544    /// Number of memories pushed.
545    pushed: usize,
546    /// Number of memories pulled.
547    pulled: usize,
548    /// Error message if failed.
549    error: Option<String>,
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn test_handler_creation() {
558        let handler = StopHandler::default();
559        assert_eq!(handler.event_type(), "Stop");
560    }
561
562    #[test]
563    fn test_generate_summary() {
564        let handler = StopHandler::default();
565
566        let now = current_timestamp();
567        let input = serde_json::json!({
568            "session_id": "test-session",
569            "start_time": now - 3600, // 1 hour ago
570            "interaction_count": 10,
571            "memories_captured": 2,
572            "tools_used": ["Read", "Write", "Bash"]
573        });
574
575        let summary = handler.generate_summary(&input);
576
577        assert_eq!(summary.session_id, "test-session");
578        assert_eq!(summary.interaction_count, 10);
579        assert_eq!(summary.memories_captured, 2);
580        assert_eq!(summary.tools_used, 3);
581        assert!(summary.duration_seconds >= 3600);
582    }
583
584    #[test]
585    fn test_handle_basic() {
586        let handler = StopHandler::default();
587
588        let input = r#"{"session_id": "test-session", "interaction_count": 5}"#;
589
590        let result = handler.handle(input);
591        assert!(result.is_ok());
592
593        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
594        // Stop hooks don't support hookSpecificOutput per Claude Code spec
595        // Response should be empty JSON (context is logged only)
596        assert!(response.as_object().unwrap().is_empty());
597    }
598
599    #[test]
600    fn test_handle_with_hints() {
601        let handler = StopHandler::default();
602
603        let input =
604            r#"{"session_id": "test-session", "interaction_count": 10, "memories_captured": 0}"#;
605
606        let result = handler.handle(input);
607        assert!(result.is_ok());
608
609        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
610        // Stop hooks return empty JSON - context is logged only
611        assert!(response.as_object().unwrap().is_empty());
612    }
613
614    #[test]
615    fn test_auto_sync_disabled() {
616        let handler = StopHandler::default().with_auto_sync(false);
617
618        let sync_result = handler.perform_sync();
619        assert!(sync_result.is_none());
620    }
621
622    #[test]
623    fn test_configuration() {
624        let handler = StopHandler::default().with_auto_sync(true);
625
626        assert!(handler.auto_sync);
627    }
628
629    #[test]
630    fn test_empty_input() {
631        let handler = StopHandler::default();
632
633        let input = "{}";
634
635        let result = handler.handle(input);
636        assert!(result.is_ok());
637
638        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
639        // Stop hooks return empty JSON - context is logged only
640        assert!(response.as_object().unwrap().is_empty());
641    }
642
643    #[test]
644    fn test_namespace_breakdown() {
645        let handler = StopHandler::default();
646
647        let input = serde_json::json!({
648            "session_id": "test-session",
649            "namespace_stats": {
650                "decisions": {"captures": 3, "recalls": 5},
651                "learnings": {"captures": 2, "recalls": 1}
652            }
653        });
654
655        let result = handler.handle(&input.to_string());
656        assert!(result.is_ok());
657
658        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
659        // Stop hooks return empty JSON - context is logged only
660        assert!(response.as_object().unwrap().is_empty());
661    }
662
663    #[test]
664    fn test_tags_analysis() {
665        let handler = StopHandler::default();
666
667        let input = serde_json::json!({
668            "session_id": "test-session",
669            "tags_used": [
670                {"tag": "rust", "count": 5},
671                {"tag": "architecture", "count": 3},
672                {"tag": "testing", "count": 2}
673            ]
674        });
675
676        let result = handler.handle(&input.to_string());
677        assert!(result.is_ok());
678
679        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
680        // Stop hooks return empty JSON - context is logged only
681        assert!(response.as_object().unwrap().is_empty());
682    }
683
684    #[test]
685    fn test_query_patterns() {
686        let handler = StopHandler::default();
687
688        let input = serde_json::json!({
689            "session_id": "test-session",
690            "query_patterns": ["how to implement", "where is the config"]
691        });
692
693        let result = handler.handle(&input.to_string());
694        assert!(result.is_ok());
695
696        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
697        // Stop hooks return empty JSON - context is logged only
698        assert!(response.as_object().unwrap().is_empty());
699    }
700
701    #[test]
702    fn test_resources_tracking() {
703        let handler = StopHandler::default();
704
705        let input = serde_json::json!({
706            "session_id": "test-session",
707            "resources_read": [
708                "subcog://decisions/mem-1",
709                "subcog://learnings/mem-2"
710            ]
711        });
712
713        let result = handler.handle(&input.to_string());
714        assert!(result.is_ok());
715
716        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
717        // Stop hooks return empty JSON - context is logged only
718        assert!(response.as_object().unwrap().is_empty());
719    }
720
721    #[test]
722    fn test_extract_namespace_counts() {
723        let input = serde_json::json!({
724            "namespace_stats": {
725                "decisions": {"captures": 3, "recalls": 5},
726                "patterns": {"captures": 1, "recalls": 0}
727            }
728        });
729
730        let counts = StopHandler::extract_namespace_counts(&input);
731
732        assert_eq!(counts.len(), 2);
733        assert_eq!(counts.get("decisions").map(|s| s.captures), Some(3));
734        assert_eq!(counts.get("decisions").map(|s| s.recalls), Some(5));
735        assert_eq!(counts.get("patterns").map(|s| s.captures), Some(1));
736    }
737
738    #[test]
739    fn test_extract_tags_used() {
740        let input = serde_json::json!({
741            "tags_used": [
742                {"tag": "rust", "count": 10},
743                {"tag": "testing", "count": 5},
744                {"tag": "docs", "count": 3}
745            ]
746        });
747
748        let tags = StopHandler::extract_tags_used(&input);
749
750        assert_eq!(tags.len(), 3);
751        assert_eq!(tags[0], ("rust".to_string(), 10)); // Highest count first
752        assert_eq!(tags[1], ("testing".to_string(), 5));
753    }
754
755    #[test]
756    fn test_default_timeout() {
757        let handler = StopHandler::new();
758        assert_eq!(handler.timeout_ms, DEFAULT_TIMEOUT_MS);
759        assert_eq!(handler.timeout_ms, 30_000);
760    }
761
762    #[test]
763    fn test_with_timeout_ms() {
764        let handler = StopHandler::new().with_timeout_ms(5_000);
765        assert_eq!(handler.timeout_ms, 5_000);
766    }
767
768    #[test]
769    fn test_returns_empty_json() {
770        let handler = StopHandler::new();
771        let input = r#"{"session_id": "test-session"}"#;
772
773        let result = handler.handle(input);
774        assert!(result.is_ok());
775
776        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
777        // Stop hooks return empty JSON - context and metadata logged only
778        assert!(response.as_object().unwrap().is_empty());
779    }
780
781    #[test]
782    fn test_builder_chaining() {
783        let handler = StopHandler::new()
784            .with_timeout_ms(10_000)
785            .with_auto_sync(false);
786
787        assert_eq!(handler.timeout_ms, 10_000);
788        assert!(!handler.auto_sync);
789    }
790}