Skip to main content

lean_ctx/tools/
autonomy.rs

1use std::sync::atomic::Ordering;
2
3pub use crate::core::autonomy::{AutonomyState, SearchHistory, should_auto_consolidate};
4use crate::core::autonomy_drivers::{
5    AutonomyDriverDecisionV1, AutonomyDriverEventV1, AutonomyDriverKindV1, AutonomyPhaseV1,
6    AutonomyVerdictV1,
7};
8use crate::core::cache::SessionCache;
9use crate::core::graph_provider::GraphProvider;
10use crate::core::protocol;
11use crate::core::tokens::count_tokens;
12use crate::tools::CrpMode;
13
14fn profile_autonomy() -> crate::core::profiles::ProfileAutonomy {
15    crate::core::profiles::active_profile().autonomy
16}
17
18fn autonomy_enabled_effective(state: &AutonomyState) -> bool {
19    state.is_enabled() && profile_autonomy().enabled_effective()
20}
21
22fn policy_allows(tool: &str) -> Result<(), (String, String)> {
23    let policy = crate::core::degradation_policy::evaluate_v1_for_tool(tool, None);
24    match policy.decision.verdict {
25        crate::core::degradation_policy::DegradationVerdictV1::Ok
26        | crate::core::degradation_policy::DegradationVerdictV1::Warn => Ok(()),
27        crate::core::degradation_policy::DegradationVerdictV1::Throttle
28        | crate::core::degradation_policy::DegradationVerdictV1::Block => {
29            Err((policy.decision.reason_code, policy.decision.reason))
30        }
31    }
32}
33
34fn record_event(
35    phase: AutonomyPhaseV1,
36    tool: &str,
37    action: Option<&str>,
38    decisions: Vec<AutonomyDriverDecisionV1>,
39) {
40    let mut store = crate::core::autonomy_drivers::AutonomyDriversV1::load();
41    let ev = AutonomyDriverEventV1 {
42        seq: 0,
43        created_at: chrono::Utc::now().to_rfc3339(),
44        phase,
45        role: crate::core::roles::active_role_name(),
46        profile: crate::core::profiles::active_profile_name(),
47        tool: tool.to_string(),
48        action: action.map(std::string::ToString::to_string),
49        decisions,
50    };
51    store.record(ev);
52    let _ = store.save();
53}
54
55/// Auto-preloads project context on the first tool call of a session.
56pub fn session_lifecycle_pre_hook(
57    state: &AutonomyState,
58    tool_name: &str,
59    cache: &mut SessionCache,
60    task: Option<&str>,
61    project_root: Option<&str>,
62    crp_mode: CrpMode,
63) -> Option<String> {
64    if !autonomy_enabled_effective(state) {
65        return None;
66    }
67
68    if tool_name == "ctx_overview" || tool_name == "ctx_preload" {
69        return None;
70    }
71
72    let prof = profile_autonomy();
73    let root = match project_root {
74        Some(r) if !r.is_empty() && r != "." => r.to_string(),
75        _ => return None,
76    };
77
78    // Keep the session eligible for a later retry: do not claim initialization
79    // while the guardian is asking opportunistic work to back off.
80    if crate::core::memory_guard::is_under_pressure() {
81        return None;
82    }
83
84    if state
85        .session_initialized
86        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
87        .is_err()
88    {
89        return None;
90    }
91
92    let mut decisions = Vec::new();
93
94    if !state.config.auto_preload || !prof.auto_preload_effective() {
95        decisions.push(AutonomyDriverDecisionV1 {
96            driver: AutonomyDriverKindV1::Preload,
97            verdict: AutonomyVerdictV1::Skip,
98            reason_code: "disabled".to_string(),
99            reason: "auto_preload disabled by config/profile".to_string(),
100            detail: None,
101        });
102        record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
103        return None;
104    }
105
106    let chosen_tool = if task.is_some() {
107        "ctx_preload"
108    } else {
109        "ctx_overview"
110    };
111    if let Err((code, reason)) = policy_allows(chosen_tool) {
112        decisions.push(AutonomyDriverDecisionV1 {
113            driver: AutonomyDriverKindV1::Preload,
114            verdict: AutonomyVerdictV1::Skip,
115            reason_code: code,
116            reason,
117            detail: Some("policy guard (budget/slo)".to_string()),
118        });
119        record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
120        return None;
121    }
122
123    let (result, usable) = if let Some(task_desc) = task {
124        crate::tools::ctx_preload::handle(cache, task_desc, Some(&root), crp_mode)
125    } else {
126        let cache_readonly = &*cache;
127        crate::tools::ctx_overview::handle(cache_readonly, None, Some(&root), crp_mode)
128    };
129
130    let empty = !usable || result.trim().is_empty();
131    decisions.push(AutonomyDriverDecisionV1 {
132        driver: AutonomyDriverKindV1::Preload,
133        verdict: AutonomyVerdictV1::Run,
134        reason_code: "session_start".to_string(),
135        reason: "first tool call in session".to_string(),
136        detail: Some(format!("tool={chosen_tool} empty={empty}")),
137    });
138    record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
139
140    if empty {
141        return None;
142    }
143
144    Some(format!(
145        "--- AUTO CONTEXT ---\n{result}\n--- END AUTO CONTEXT ---"
146    ))
147}
148
149/// Appends related-file hints and silently preloads imports after a file read.
150pub fn enrich_after_read(
151    state: &AutonomyState,
152    cache: &mut SessionCache,
153    file_path: &str,
154    project_root: Option<&str>,
155    task: Option<&str>,
156    crp_mode: CrpMode,
157    minimal_overhead: bool,
158) -> EnrichResult {
159    let mut result = EnrichResult::default();
160
161    if !autonomy_enabled_effective(state) {
162        return result;
163    }
164
165    let prof = profile_autonomy();
166    let root = match project_root {
167        Some(r) if !r.is_empty() && r != "." => r.to_string(),
168        _ => return result,
169    };
170
171    let Some(open) = crate::core::graph_provider::open_or_build(&root) else {
172        return result;
173    };
174    let provider = &open.provider;
175    if provider.file_count() == 0 {
176        return result;
177    }
178
179    if state.config.auto_related && prof.auto_related_effective() {
180        result.related_hint = build_related_hints(cache, file_path, provider);
181    }
182
183    if state.config.silent_preload && prof.silent_preload_effective() {
184        silent_preload_imports(cache, file_path, provider, &root);
185    }
186
187    if !minimal_overhead && prof.auto_prefetch_effective() {
188        let mut decisions = Vec::new();
189        if let Err((code, reason)) = policy_allows("ctx_prefetch") {
190            decisions.push(AutonomyDriverDecisionV1 {
191                driver: AutonomyDriverKindV1::Prefetch,
192                verdict: AutonomyVerdictV1::Skip,
193                reason_code: code,
194                reason,
195                detail: Some("policy guard (budget/slo)".to_string()),
196            });
197            record_event(AutonomyPhaseV1::PostRead, "ctx_read", None, decisions);
198        } else {
199            let changed = vec![file_path.to_string()];
200            let out = crate::tools::ctx_prefetch::handle(
201                cache,
202                &root,
203                task,
204                Some(&changed),
205                prof.prefetch_budget_tokens_effective(),
206                Some(prof.prefetch_max_files_effective()),
207                crp_mode,
208            );
209            let summary = out.lines().next().unwrap_or("").trim().to_string();
210            decisions.push(AutonomyDriverDecisionV1 {
211                driver: AutonomyDriverKindV1::Prefetch,
212                verdict: AutonomyVerdictV1::Run,
213                reason_code: "after_read".to_string(),
214                reason: "bounded prefetch after ctx_read".to_string(),
215                detail: if summary.is_empty() {
216                    None
217                } else {
218                    Some(summary.clone())
219                },
220            });
221            record_event(AutonomyPhaseV1::PostRead, "ctx_read", None, decisions);
222            let _ = summary;
223        }
224    }
225
226    result
227}
228
229/// Output from post-read enrichment: optional related-file hints.
230#[derive(Default)]
231pub struct EnrichResult {
232    pub related_hint: Option<String>,
233}
234
235fn build_related_hints(
236    cache: &SessionCache,
237    file_path: &str,
238    provider: &GraphProvider,
239) -> Option<String> {
240    let mut related: Vec<String> = Vec::new();
241    for path in provider
242        .dependencies(file_path)
243        .into_iter()
244        .chain(provider.dependents(file_path))
245    {
246        if related.len() >= 3 {
247            break;
248        }
249        if cache.get(&path).is_none() && !related.contains(&path) {
250            related.push(path);
251        }
252    }
253
254    if related.is_empty() {
255        return None;
256    }
257
258    let hints: Vec<String> = related.iter().map(|p| protocol::shorten_path(p)).collect();
259
260    Some(format!("[related: {}]", hints.join(", ")))
261}
262
263fn silent_preload_imports(
264    cache: &mut SessionCache,
265    file_path: &str,
266    provider: &GraphProvider,
267    project_root: &str,
268) {
269    let imports: Vec<String> = provider
270        .dependencies(file_path)
271        .into_iter()
272        .take(2)
273        .collect();
274
275    let jail_root = std::path::Path::new(project_root);
276    for path in imports {
277        let candidate = std::path::Path::new(&path);
278        let candidate = if candidate.is_absolute() {
279            candidate.to_path_buf()
280        } else {
281            jail_root.join(&path)
282        };
283        let Ok((jailed, warning)) = crate::core::io_boundary::jail_and_check_path(
284            "autonomy:silent_preload",
285            &candidate,
286            jail_root,
287        ) else {
288            continue;
289        };
290        if warning.is_some() {
291            continue;
292        }
293        let jailed_s = jailed.to_string_lossy().to_string();
294        if cache.get(&jailed_s).is_some() {
295            continue;
296        }
297        // Don't hydrate cloud placeholders during automatic import preload (#363).
298        if crate::core::cloud_files::is_cloud_placeholder(&jailed) {
299            continue;
300        }
301
302        if let Ok(content) = std::fs::read_to_string(&jailed) {
303            let tokens = count_tokens(&content);
304            if tokens < 5000 {
305                cache.store(&jailed_s, &content);
306            }
307        }
308    }
309}
310
311/// Runs cache deduplication once the entry count exceeds the configured threshold.
312pub fn maybe_auto_dedup(state: &AutonomyState, cache: &mut SessionCache, trigger_tool: &str) {
313    if !autonomy_enabled_effective(state) {
314        return;
315    }
316
317    let prof = profile_autonomy();
318    if !state.config.auto_dedup || !prof.auto_dedup_effective() {
319        return;
320    }
321
322    if state
323        .dedup_applied
324        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
325        .is_err()
326    {
327        return;
328    }
329
330    let entries = cache.get_all_entries();
331    let threshold = state
332        .config
333        .dedup_threshold
334        .max(prof.dedup_threshold_effective())
335        .max(1);
336    if entries.len() < threshold {
337        state.dedup_applied.store(false, Ordering::SeqCst);
338        return;
339    }
340
341    let mut decisions = Vec::new();
342    if let Err((code, reason)) = policy_allows("ctx_dedup") {
343        decisions.push(AutonomyDriverDecisionV1 {
344            driver: AutonomyDriverKindV1::Dedup,
345            verdict: AutonomyVerdictV1::Skip,
346            reason_code: code,
347            reason,
348            detail: Some("policy guard (budget/slo)".to_string()),
349        });
350        record_event(AutonomyPhaseV1::PostRead, trigger_tool, None, decisions);
351        state.dedup_applied.store(false, Ordering::SeqCst);
352        return;
353    }
354
355    let out = crate::tools::ctx_dedup::handle_action(cache, "apply");
356    let summary = out.lines().next().unwrap_or("").trim().to_string();
357    decisions.push(AutonomyDriverDecisionV1 {
358        driver: AutonomyDriverKindV1::Dedup,
359        verdict: AutonomyVerdictV1::Run,
360        reason_code: "threshold_reached".to_string(),
361        reason: format!("cache entries >= {threshold}"),
362        detail: if summary.is_empty() {
363            None
364        } else {
365            Some(summary)
366        },
367    });
368    record_event(AutonomyPhaseV1::PostRead, trigger_tool, None, decisions);
369}
370
371fn take_large_output_hint_once(state: &AutonomyState, key: &str) -> bool {
372    if !autonomy_enabled_effective(state) {
373        return false;
374    }
375    let mut set = state
376        .large_output_hints_shown
377        .lock()
378        .unwrap_or_else(std::sync::PoisonError::into_inner);
379    set.insert(key.to_string())
380}
381
382/// `ctx_shell`: suggest sandbox / read modes when final output is large (bytes).
383pub fn large_ctx_shell_output_hint(
384    state: &AutonomyState,
385    command: &str,
386    output_bytes: usize,
387) -> Option<String> {
388    const THRESHOLD_BYTES: usize = 5000;
389    if output_bytes <= THRESHOLD_BYTES {
390        return None;
391    }
392    if !take_large_output_hint_once(state, "ctx_shell_large_bytes") {
393        return None;
394    }
395    let n = output_bytes;
396    if shell_command_looks_structured(command) {
397        Some(format!(
398            "[hint: large output ({n} bytes). For structured output (e.g. cargo test, npm test, grep), use ctx_execute for automatic compression; for file contents use ctx_read(mode=\"aggressive\")]"
399        ))
400    } else {
401        Some(format!(
402            "[hint: large output ({n} bytes). Consider piping through ctx_execute for automatic compression, or use ctx_read(mode=\"aggressive\") for file contents]"
403        ))
404    }
405}
406
407fn shell_command_looks_structured(cmd: &str) -> bool {
408    let t = cmd.trim();
409    let lower = t.to_lowercase();
410    lower.contains("cargo test")
411        || lower.contains("npm test")
412        || t.starts_with("grep ")
413        || t.starts_with("rg ")
414}
415
416/// `ctx_read` full mode: suggest compressed read modes when output is very large (tokens).
417pub fn large_ctx_read_full_hint(
418    state: &AutonomyState,
419    mode: Option<&str>,
420    output: &str,
421) -> Option<String> {
422    const THRESHOLD_TOKENS: usize = 10_000;
423    let m = mode.unwrap_or("").trim();
424    if m != "full" {
425        return None;
426    }
427    let n = count_tokens(output);
428    if n <= THRESHOLD_TOKENS {
429        return None;
430    }
431    if !take_large_output_hint_once(state, "ctx_read_full_large_tokens") {
432        return None;
433    }
434    Some(format!(
435        "[hint: large file ({n} tokens). Consider mode=\"map\" or mode=\"aggressive\" for compressed view]"
436    ))
437}
438
439/// Suggests a more token-efficient lean-ctx tool when shell compression is low.
440pub fn shell_efficiency_hint(
441    state: &AutonomyState,
442    command: &str,
443    input_tokens: usize,
444    output_tokens: usize,
445) -> Option<String> {
446    if !autonomy_enabled_effective(state) {
447        return None;
448    }
449
450    if input_tokens == 0 {
451        return None;
452    }
453
454    let savings_pct =
455        (input_tokens.saturating_sub(output_tokens) as f64 / input_tokens as f64) * 100.0;
456    if savings_pct >= 20.0 {
457        return None;
458    }
459
460    let cmd_lower = command.to_lowercase();
461    if cmd_lower.starts_with("grep ")
462        || cmd_lower.starts_with("rg ")
463        || cmd_lower.starts_with("find ")
464        || cmd_lower.starts_with("ag ")
465    {
466        return Some("[hint: ctx_search is more token-efficient for code search]".to_string());
467    }
468
469    if cmd_lower.starts_with("cat ") || cmd_lower.starts_with("head ") {
470        return Some("[hint: ctx_read provides cached, compressed file access]".to_string());
471    }
472
473    None
474}
475
476fn looks_like_json(text: &str) -> bool {
477    let t = text.trim();
478    if !(t.starts_with('{') || t.starts_with('[')) {
479        return false;
480    }
481    serde_json::from_str::<serde_json::Value>(t).is_ok()
482}
483
484/// Applies `ctx_response` automatically for large outputs (guarded + bounded).
485/// Never runs on JSON outputs to avoid breaking machine-readable responses.
486pub fn maybe_auto_response(
487    state: &AutonomyState,
488    tool_name: &str,
489    action: Option<&str>,
490    output: &str,
491    crp_mode: CrpMode,
492    minimal_overhead: bool,
493) -> String {
494    if minimal_overhead || !autonomy_enabled_effective(state) {
495        return output.to_string();
496    }
497
498    let prof = profile_autonomy();
499    if !prof.auto_response_effective() {
500        return output.to_string();
501    }
502    if tool_name == "ctx_response" {
503        return output.to_string();
504    }
505
506    let input_tokens = count_tokens(output);
507    if input_tokens < prof.response_min_tokens_effective() {
508        return output.to_string();
509    }
510    if looks_like_json(output) {
511        record_event(
512            AutonomyPhaseV1::PostCall,
513            tool_name,
514            action,
515            vec![AutonomyDriverDecisionV1 {
516                driver: AutonomyDriverKindV1::Response,
517                verdict: AutonomyVerdictV1::Skip,
518                reason_code: "json_output".to_string(),
519                reason: "skip response shaping for JSON outputs".to_string(),
520                detail: None,
521            }],
522        );
523        return output.to_string();
524    }
525
526    if let Err((code, reason)) = policy_allows("ctx_response") {
527        record_event(
528            AutonomyPhaseV1::PostCall,
529            tool_name,
530            action,
531            vec![AutonomyDriverDecisionV1 {
532                driver: AutonomyDriverKindV1::Response,
533                verdict: AutonomyVerdictV1::Skip,
534                reason_code: code,
535                reason,
536                detail: Some("policy guard (budget/slo)".to_string()),
537            }],
538        );
539        return output.to_string();
540    }
541
542    let start = std::time::Instant::now();
543    let compressed = crate::tools::ctx_response::handle(output, crp_mode);
544    let duration = start.elapsed();
545    let output_tokens = count_tokens(&compressed);
546
547    let (verdict, reason_code, reason) = if compressed == output {
548        (
549            AutonomyVerdictV1::Skip,
550            "no_savings".to_string(),
551            "ctx_response made no changes".to_string(),
552        )
553    } else {
554        (
555            AutonomyVerdictV1::Run,
556            "output_large".to_string(),
557            "response shaping applied".to_string(),
558        )
559    };
560
561    record_event(
562        AutonomyPhaseV1::PostCall,
563        tool_name,
564        action,
565        vec![AutonomyDriverDecisionV1 {
566            driver: AutonomyDriverKindV1::Response,
567            verdict,
568            reason_code,
569            reason,
570            detail: Some(format!(
571                "tokens {}→{} in {:.1}ms",
572                input_tokens,
573                output_tokens,
574                duration.as_micros() as f64 / 1000.0
575            )),
576        }],
577    );
578
579    compressed
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn autonomy_state_starts_uninitialized() {
588        let state = AutonomyState::new();
589        assert!(!state.session_initialized.load(Ordering::SeqCst));
590        assert!(!state.dedup_applied.load(Ordering::SeqCst));
591    }
592
593    #[test]
594    fn session_initialized_fires_once() {
595        let state = AutonomyState::new();
596        let first = state.session_initialized.compare_exchange(
597            false,
598            true,
599            Ordering::SeqCst,
600            Ordering::SeqCst,
601        );
602        assert!(first.is_ok());
603        let second = state.session_initialized.compare_exchange(
604            false,
605            true,
606            Ordering::SeqCst,
607            Ordering::SeqCst,
608        );
609        assert!(second.is_err());
610    }
611
612    #[test]
613    fn shell_hint_for_grep() {
614        let state = AutonomyState::new();
615        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 95);
616        assert!(hint.is_some());
617        assert!(hint.unwrap().contains("ctx_search"));
618    }
619
620    #[test]
621    fn shell_hint_none_when_good_savings() {
622        let state = AutonomyState::new();
623        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 50);
624        assert!(hint.is_none());
625    }
626
627    #[test]
628    fn shell_hint_none_for_unknown_command() {
629        let state = AutonomyState::new();
630        let hint = shell_efficiency_hint(&state, "cargo build", 100, 95);
631        assert!(hint.is_none());
632    }
633
634    #[test]
635    fn large_shell_hint_once_per_session() {
636        let state = AutonomyState::new();
637        let h1 = large_ctx_shell_output_hint(&state, "ls -la", 5001).expect("first");
638        assert!(h1.contains("5001 bytes"));
639        assert!(h1.contains("ctx_execute"));
640        assert!(large_ctx_shell_output_hint(&state, "ls -la", 5001).is_none());
641    }
642
643    #[test]
644    fn large_shell_structured_hint_mentions_execute() {
645        let state = AutonomyState::new();
646        let h = large_ctx_shell_output_hint(&state, "cargo test", 6000).expect("hint");
647        assert!(h.contains("structured"));
648        assert!(h.contains("ctx_execute"));
649    }
650
651    #[test]
652    fn large_read_full_hint_respects_mode() {
653        let state = AutonomyState::new();
654        let big = "word ".repeat(20_000);
655        assert!(large_ctx_read_full_hint(&state, Some("map"), &big).is_none());
656        let h = large_ctx_read_full_hint(&state, Some("full"), &big).expect("hint");
657        assert!(h.contains("tokens"));
658        assert!(h.contains("aggressive"));
659        assert!(large_ctx_read_full_hint(&state, Some("full"), &big).is_none());
660    }
661
662    #[test]
663    fn large_hints_disabled_when_autonomy_off() {
664        let mut state = AutonomyState::new();
665        state.config.enabled = false;
666        let big = "word ".repeat(20_000);
667        assert!(large_ctx_shell_output_hint(&state, "cargo test", 6000).is_none());
668        assert!(large_ctx_read_full_hint(&state, Some("full"), &big).is_none());
669    }
670
671    #[test]
672    fn disabled_state_blocks_all() {
673        let mut state = AutonomyState::new();
674        state.config.enabled = false;
675        assert!(!state.is_enabled());
676        let hint = shell_efficiency_hint(&state, "grep foo", 100, 95);
677        assert!(hint.is_none());
678    }
679
680    #[test]
681    fn track_search_none_first_three() {
682        let _lock = crate::core::data_dir::test_env_lock();
683        let state = AutonomyState::new();
684        assert!(state.track_search("foo", "src").is_none());
685        assert!(state.track_search("foo", "src").is_none());
686        assert!(state.track_search("foo", "src").is_none());
687    }
688
689    #[test]
690    fn track_search_hint_band() {
691        let _lock = crate::core::data_dir::test_env_lock();
692        let state = AutonomyState::new();
693        for _ in 0..3 {
694            assert!(state.track_search("bar", ".").is_none());
695        }
696        let h = state.track_search("bar", ".").expect("hint on 4th");
697        assert!(h.starts_with("[hint: repeated search (4/6)."));
698        assert!(h.contains("ctx_knowledge"));
699    }
700
701    #[test]
702    fn track_search_throttle_seventh() {
703        let _lock = crate::core::data_dir::test_env_lock();
704        let state = AutonomyState::new();
705        for _ in 0..6 {
706            let _ = state.track_search("baz", "p");
707        }
708        let h = state.track_search("baz", "p").expect("throttle on 7th");
709        assert!(h.starts_with("[throttle: search repeated 7 times"));
710        assert!(h.contains("ctx_pack"));
711    }
712
713    #[test]
714    fn track_search_resets_after_idle() {
715        let _lock = crate::core::data_dir::test_env_lock();
716        let state = AutonomyState::new();
717        for _ in 0..3 {
718            assert!(state.track_search("idle", "x").is_none());
719        }
720        std::thread::sleep(std::time::Duration::from_millis(600));
721        assert!(
722            state.track_search("idle", "x").is_none(),
723            "count should reset after idle window"
724        );
725    }
726
727    #[test]
728    fn track_search_disabled_no_tracking_messages() {
729        let _lock = crate::core::data_dir::test_env_lock();
730        let mut state = AutonomyState::new();
731        state.config.enabled = false;
732        for _ in 0..8 {
733            assert!(state.track_search("q", "/").is_none());
734        }
735    }
736
737    #[test]
738    fn track_search_distinct_keys() {
739        let _lock = crate::core::data_dir::test_env_lock();
740        let state = AutonomyState::new();
741        assert!(state.track_search("pat", "a").is_none());
742        assert!(state.track_search("pat", "a").is_none());
743        assert!(state.track_search("pat", "a").is_none());
744        assert!(state.track_search("pat", "a").is_some());
745        assert!(state.track_search("pat", "b").is_none());
746    }
747}