1#[cfg(test)]
14use std::collections::HashMap;
15use std::collections::{BTreeMap, BTreeSet};
16use std::io::{self, Write};
17
18use anyhow::{anyhow, bail, Result};
19use clap::Args;
20use regex::Regex;
21use serde::Serialize;
22
23use crate::config::{AnalyticsConfig, Config};
24use crate::dates::DateRange;
25use crate::db::Db;
26use crate::message_search::{
27 DetailLevel, FieldViewBudget, MatchViewBudget, DEFAULT_MATCH_EVIDENCE_MAX_CHARS,
28};
29use crate::messages::{CliFieldViewChars, CliMatchViewChars};
30use crate::models::{
31 MessageClassificationMatch, MessageFilters, MessageHit, MessageSearchMode, PlanningCount,
32 Provider, Role,
33};
34use crate::render::{render, OutputFormat, Row};
35use crate::util::truncate_for_display;
36
37const TABLE_CONTENT_CHARS: usize = 100;
38const USER_REQUEST_START: &str = "<USER_REQUEST>";
39const USER_REQUEST_END: &str = "</USER_REQUEST>";
40
41pub(crate) fn default_correction_patterns() -> Vec<(&'static str, Vec<&'static str>)> {
47 vec![
48 (
49 "regression",
50 vec![
51 r"\byou (deleted|removed|reverted|lost|regressed|undid|rolled back|broke)\b",
52 r"\b(that|this|it) (reverted|deleted|removed|undid|regressed)\b",
53 r"\bbroke the (build|tests?|code|app)\b",
54 r"\bregressed\b",
55 ],
56 ),
57 (
58 "skip_step",
59 vec![
60 r"\byou forgot\b",
61 r"\byou missed\b",
62 r"\byou skipped\b",
63 r"\bdon'?t forget\b",
64 r"\bmissing step\b",
65 r"\byou didn'?t\b",
66 ],
67 ),
68 (
69 "misunderstanding",
70 vec![
71 r"\b(that is|that'?s|it is|it'?s) (actually )?(wrong|incorrect|not correct|not right|not what|a mistake)\b",
72 r"\byou'?re wrong\b",
73 r"\byou (misunderstood|got it wrong|misread)\b",
74 r"\bnono\b",
75 r"\bno,?\s+that'?s\b",
76 r"\bno,?\s+i (meant|asked|said)\b",
77 r"\bwait,?\s+(no|that'?s)\b",
78 r"\bwrong approach\b",
79 ],
80 ),
81 (
82 "incomplete",
83 vec![
84 r"\balso need\b",
85 r"\bmust also\b",
86 r"\bnot done\b",
87 r"\bnot finished\b",
88 r"\bstill need\b",
89 r"\byou should have\b",
90 r"\bbut you\b",
91 ],
92 ),
93 (
99 "other",
100 vec![
101 r"^\s*(?:ok,?\s+|no,?\s+|wait,?\s+|please\s+|just\s+)?stop\b",
102 r"\bjust stop\b",
103 r"\bplease stop\b",
104 r"\bstop doing\b",
105 r"\bstop that\b",
106 r"\bstop it\b",
107 r"\bstop changing\b",
108 r"\bstop making\b",
109 r"\bstop breaking\b",
110 ],
111 ),
112 ]
113}
114
115#[cfg(test)]
124fn compile_category_patterns(
125 custom: &[String],
126 builtins: Vec<(&'static str, Vec<&'static str>)>,
127 label: &str,
128) -> Result<Vec<(String, Regex)>> {
129 if custom.is_empty() {
130 return builtins
131 .into_iter()
132 .map(|(category, patterns)| {
133 let re = Regex::new(&format!("(?i){}", patterns.join("|"))).map_err(|err| {
134 anyhow!("invalid built-in {label} pattern for '{category}': {err}")
135 })?;
136 Ok((category.to_string(), re))
137 })
138 .collect();
139 }
140 let mut order: Vec<String> = Vec::new();
141 let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
142 for spec in custom {
143 let (category, rx) = spec
144 .split_once(':')
145 .ok_or_else(|| anyhow!("invalid {label} pattern '{spec}': expected CATEGORY:REGEX"))?;
146 if !grouped.contains_key(category) {
147 order.push(category.to_string());
148 }
149 grouped
150 .entry(category.to_string())
151 .or_default()
152 .push(rx.to_string());
153 }
154 order
155 .into_iter()
156 .map(|category| {
157 let joined = grouped[&category].join("|");
158 let re = Regex::new(&format!("(?i){joined}"))
159 .map_err(|err| anyhow!("invalid {label} regex for category '{category}': {err}"))?;
160 Ok((category, re))
161 })
162 .collect()
163}
164
165#[cfg(test)]
170pub(crate) fn compile_patterns(_config: &Config) -> Result<Vec<(String, Regex)>> {
171 compile_category_patterns(&[], default_correction_patterns(), "correction")
172}
173
174impl Row for MessageClassificationMatch {
175 fn headers() -> &'static [&'static str] {
176 &[
180 "session", "seq", "ts", "policy", "category", "match", "content",
181 ]
182 }
183 fn cells(&self) -> Vec<String> {
184 vec![
185 self.session_id.clone(),
186 self.message_seq.to_string(),
187 self.ts.map(|ts| ts.to_rfc3339()).unwrap_or_default(),
188 self.policy_name.clone(),
189 self.category.clone(),
190 self.matched_text.clone(),
191 truncate_for_display(&self.content, TABLE_CONTENT_CHARS),
192 ]
193 }
194}
195
196impl Row for PlanningCount {
197 fn headers() -> &'static [&'static str] {
198 &["command", "count", "sessions", "projects"]
199 }
200 fn cells(&self) -> Vec<String> {
201 vec![
202 self.command.clone(),
203 self.count.to_string(),
204 self.unique_sessions.to_string(),
205 self.unique_projects.to_string(),
206 ]
207 }
208}
209
210#[derive(Debug, Clone, Serialize)]
212pub struct RoleStat {
213 pub role: String,
214 pub count: i64,
215}
216impl Row for RoleStat {
217 fn headers() -> &'static [&'static str] {
218 &["role", "count"]
219 }
220 fn cells(&self) -> Vec<String> {
221 vec![self.role.clone(), self.count.to_string()]
222 }
223}
224
225#[derive(Debug, Clone, Args)]
226pub struct CorrectionsArgs {
227 #[arg(long, value_name = "JSON")]
230 pub definition_json: Option<String>,
231 #[arg(long)]
234 pub session_id: Option<String>,
235 #[arg(long, value_enum)]
237 pub provider: Option<Provider>,
238 #[arg(long)]
242 pub path: Option<String>,
243 #[command(flatten)]
244 pub dates: DateRange,
245 #[arg(long)]
247 pub limit: Option<usize>,
248 #[arg(long, default_value_t = 0)]
250 pub offset: usize,
251 #[arg(long = "session-kinds", value_enum, num_args = 1..)]
256 pub session_kinds: Vec<crate::models::SessionKind>,
257 #[arg(long = "skill", value_name = "NAME_OR_PATH")]
260 pub additional_skills: Vec<std::ffi::OsString>,
261 #[arg(
265 long,
266 value_enum,
267 conflicts_with_all = ["field_view_chars", "match_view_chars"]
268 )]
269 pub detail: Option<DetailLevel>,
270 #[arg(long)]
273 pub field_view_chars: Option<CliFieldViewChars>,
274 #[arg(long)]
277 pub match_view_chars: Option<CliMatchViewChars>,
278 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
280 pub format: OutputFormat,
281}
282
283#[derive(Debug, Args)]
284pub struct PlanningArgs {
285 #[arg(long, value_enum)]
287 pub provider: Option<Provider>,
288 #[arg(long)]
290 pub session_id: Option<String>,
291 #[arg(long)]
295 pub path: Option<String>,
296 #[command(flatten)]
297 pub dates: DateRange,
298 #[arg(long = "commands", alias = "command")]
303 pub command_patterns: Vec<String>,
304 #[arg(long)]
306 pub limit: Option<usize>,
307 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
309 pub format: OutputFormat,
310}
311
312#[derive(Debug, Args)]
313pub struct StatsArgs {
314 #[arg(long, value_enum)]
316 pub provider: Option<Provider>,
317 #[arg(long)]
319 pub session_id: Option<String>,
320 #[arg(long)]
324 pub path: Option<String>,
325 #[command(flatten)]
326 pub dates: DateRange,
327 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
329 pub format: OutputFormat,
330}
331
332fn filters_from(
336 db: &Db,
337 session_id: &Option<String>,
338 provider: Option<Provider>,
339 path: &Option<String>,
340 dates: &DateRange,
341 limit: usize,
342 offset: usize,
343) -> Result<MessageFilters> {
344 let (since, until) = dates.resolve_now()?;
345 let exact_session_id = session_id
346 .as_deref()
347 .map(|id| db.resolve_session_record(id).map(|session| session.id))
348 .transpose()?;
349 Ok(MessageFilters {
350 providers: provider.map(|provider| vec![provider]),
351 session_id: exact_session_id,
352 path_prefix: path.as_deref().map(crate::util::normalize_path_prefix),
353 since,
354 until,
355 limit,
356 offset,
357 ..Default::default()
358 })
359}
360
361pub(crate) fn message_classification_filters(
362 db: &Db,
363 config: &Config,
364 args: &CorrectionsArgs,
365) -> Result<MessageFilters> {
366 let mut filters = filters_from(
367 db,
368 &args.session_id,
369 args.provider,
370 &args.path,
371 &args.dates,
372 args.limit
373 .unwrap_or(config.capabilities.message_classification.limit),
374 args.offset,
375 )?;
376 if !args.session_kinds.is_empty() {
380 filters.session_kinds = Some(args.session_kinds.clone());
381 }
382 Ok(filters)
383}
384
385pub(crate) fn render_skill_run_report(
386 report: &crate::skill_run::SkillRunReport,
387 config: &Config,
388 args: &CorrectionsArgs,
389) -> Result<()> {
390 match args.format {
391 OutputFormat::Json => {
392 if let Some((field_budget, match_budget)) = skill_presentation_budgets(config, args) {
393 println!(
394 "{}",
395 serde_json::to_string_pretty(&bounded_skill_run_value(
396 report,
397 field_budget,
398 match_budget
399 )?)?
400 );
401 } else {
402 println!("{}", serde_json::to_string_pretty(report)?);
403 }
404 Ok(())
405 }
406 OutputFormat::Jsonl => {
407 if let Some((field_budget, match_budget)) = skill_presentation_budgets(config, args) {
408 println!(
409 "{}",
410 serde_json::to_string(&bounded_skill_run_value(
411 report,
412 field_budget,
413 match_budget,
414 )?)?
415 );
416 } else {
417 println!("{}", serde_json::to_string(report)?);
418 }
419 Ok(())
420 }
421 OutputFormat::Table | OutputFormat::Csv | OutputFormat::Plain => {
422 let crate::skill_run::SkillCapabilityOutput::MessageClassification(result) =
423 &report.output;
424 render_correction_report(&result.report, args.format)
425 }
426 }
427}
428
429fn skill_presentation_budgets(
430 config: &Config,
431 args: &CorrectionsArgs,
432) -> Option<(FieldViewBudget, MatchViewBudget)> {
433 if args.detail.is_none() && args.field_view_chars.is_none() && args.match_view_chars.is_none() {
434 return None;
435 }
436 let field_budget = match args.detail {
437 Some(DetailLevel::Compact) => FieldViewBudget::MaxChars {
438 max_chars: std::num::NonZeroUsize::new(config.cli.evidence_preview_chars)
439 .expect("validated CLI evidence preview chars are positive"),
440 },
441 Some(DetailLevel::Full) => FieldViewBudget::NoCharLimit,
442 None => match args.field_view_chars {
443 Some(CliFieldViewChars::NoCharLimit) | None => FieldViewBudget::NoCharLimit,
444 Some(CliFieldViewChars::MaxChars(max_chars)) => FieldViewBudget::MaxChars { max_chars },
445 },
446 };
447 let match_budget = match args.detail {
448 Some(DetailLevel::Full) => MatchViewBudget::MinimalSpan,
449 Some(DetailLevel::Compact) => MatchViewBudget::MaxChars {
450 max_chars: config
451 .search
452 .message_search
453 .match_evidence_max_chars
454 .unwrap_or_else(|| {
455 std::num::NonZeroUsize::new(DEFAULT_MATCH_EVIDENCE_MAX_CHARS)
456 .expect("typed match evidence default is positive")
457 }),
458 },
459 None => match args.match_view_chars {
460 Some(CliMatchViewChars::Minimal) => MatchViewBudget::MinimalSpan,
461 Some(CliMatchViewChars::MaxChars(max_chars)) => MatchViewBudget::MaxChars { max_chars },
462 None => MatchViewBudget::MaxChars {
463 max_chars: config
464 .search
465 .message_search
466 .match_evidence_max_chars
467 .unwrap_or_else(|| {
468 std::num::NonZeroUsize::new(DEFAULT_MATCH_EVIDENCE_MAX_CHARS)
469 .expect("typed match evidence default is positive")
470 }),
471 },
472 },
473 };
474 Some((field_budget, match_budget))
475}
476
477fn bounded_skill_run_value(
478 report: &crate::skill_run::SkillRunReport,
479 field_budget: FieldViewBudget,
480 match_budget: MatchViewBudget,
481) -> Result<serde_json::Value> {
482 let mut value = serde_json::to_value(report)?;
483 let crate::skill_run::SkillCapabilityOutput::MessageClassification(output) = &report.output;
484 let matches = output
485 .report
486 .matches
487 .iter()
488 .map(|matched| {
489 crate::message_search::classification_presentation_document(
490 matched,
491 field_budget,
492 match_budget,
493 )
494 })
495 .collect::<Result<Vec<_>>>()?;
496 value["output"]["result"]["report"]["matches"] = serde_json::Value::Array(matches);
497 Ok(value)
498}
499
500fn render_correction_report(
501 report: &crate::corrections::MessageClassificationReport,
502 format: OutputFormat,
503) -> Result<()> {
504 let stdout = io::stdout();
507 let mut out = stdout.lock();
508 crate::render::render_record(report, &report.matches, format, &mut out)?;
509 out.flush()?;
510 Ok(())
511}
512
513fn compile_planning_regex(label: &str, pattern: &str) -> Result<Regex> {
514 Regex::new(&format!("(?i){pattern}"))
515 .map_err(|err| anyhow!("invalid {label} regex '{pattern}': {err}"))
516}
517
518pub(crate) fn compile_planning_filters(
521 config: &Config,
522 cli_patterns: &[String],
523) -> Result<Vec<Regex>> {
524 let mut filters =
525 Vec::with_capacity(config.analytics.planning_commands.len() + cli_patterns.len());
526 for pattern in &config.analytics.planning_commands {
527 filters.push(compile_planning_regex("planning_commands", pattern)?);
528 }
529 for pattern in cli_patterns {
530 filters.push(compile_planning_regex("--commands", pattern)?);
531 }
532 Ok(filters)
533}
534
535pub fn run_planning(db: &Db, config: &Config, args: &PlanningArgs) -> Result<()> {
536 let filters = filters_from(
537 db,
538 &args.session_id,
539 args.provider,
540 &args.path,
541 &args.dates,
542 args.limit.unwrap_or(config.analytics.planning_limit),
543 0,
544 )?;
545 let counts = crate::service::AnalysisService::new(config, db)
546 .planning(&filters, &args.command_patterns)?;
547 emit(&counts, args.format)
548}
549
550pub fn run_stats(db: &Db, config: &Config, args: &StatsArgs) -> Result<()> {
551 let filters = filters_from(
552 db,
553 &args.session_id,
554 args.provider,
555 &args.path,
556 &args.dates,
557 0,
558 0,
559 )?;
560 let rows = crate::service::AnalysisService::new(config, db).role_statistics(&filters)?;
561 emit(&rows, args.format)
562}
563
564#[derive(Debug, Serialize)]
566struct VocabRow {
567 term: String,
568 docs: i64,
570 count: i64,
572}
573
574impl Row for VocabRow {
575 fn headers() -> &'static [&'static str] {
576 &["term", "docs", "count"]
577 }
578 fn cells(&self) -> Vec<String> {
579 vec![
580 self.term.clone(),
581 self.docs.to_string(),
582 self.count.to_string(),
583 ]
584 }
585}
586
587#[derive(Debug, Args)]
588pub struct VocabArgs {
589 #[arg(long)]
593 pub prefix: Option<String>,
594 #[arg(long)]
597 pub trigram: bool,
598 #[arg(long)]
600 pub limit: Option<usize>,
601 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
603 pub format: OutputFormat,
604}
605
606pub fn run_vocab(db: &Db, config: &AnalyticsConfig, args: &VocabArgs) -> Result<()> {
607 let limit = args.limit.unwrap_or(config.vocab_limit);
608 let rows: Vec<VocabRow> = db
609 .vocabulary(args.trigram, limit, args.prefix.as_deref())?
610 .into_iter()
611 .map(|(term, docs, count)| VocabRow { term, docs, count })
612 .collect();
613 emit(&rows, args.format)
614}
615
616#[derive(Debug, Clone, Serialize)]
617struct RepeatGroupExample {
618 session_id: String,
619 seq: i64,
620 ts: Option<String>,
621 preview: String,
622 context_command: String,
623}
624
625impl RepeatGroupExample {
626 fn from_hit(hit: &MessageHit, context: i64) -> Self {
627 Self {
628 session_id: hit.session_id.clone(),
629 seq: hit.seq,
630 ts: hit.ts.map(|ts| ts.to_rfc3339()),
631 preview: truncate_for_display(repeat_mining_text(&hit.content), TABLE_CONTENT_CHARS),
632 context_command: context_command(&hit.session_id, hit.seq, context),
633 }
634 }
635}
636
637#[derive(Debug, Serialize)]
638struct RepeatGroup {
639 repeat: String,
640 matches: usize,
641 sessions: usize,
642 examples: Vec<RepeatGroupExample>,
643}
644
645impl Row for RepeatGroup {
646 fn headers() -> &'static [&'static str] {
647 &["repeat", "matches", "sessions", "examples", "preview"]
648 }
649 fn cells(&self) -> Vec<String> {
650 let examples = self
651 .examples
652 .iter()
653 .take(3)
654 .map(|m| format!("{}:{}", m.session_id, m.seq))
655 .collect::<Vec<_>>()
656 .join(", ");
657 let preview = self
658 .examples
659 .first()
660 .map(|m| m.preview.clone())
661 .unwrap_or_default();
662 vec![
663 self.repeat.clone(),
664 self.matches.to_string(),
665 self.sessions.to_string(),
666 truncate_for_display(&examples, TABLE_CONTENT_CHARS),
667 preview,
668 ]
669 }
670}
671
672#[derive(Debug, Args)]
673pub struct RepeatsArgs {
674 pub query: Option<String>,
678 #[arg(long)]
680 pub regex: bool,
681 #[arg(long = "role", value_enum)]
684 pub role: Option<Role>,
685 #[arg(long, value_enum)]
687 pub provider: Option<Provider>,
688 #[arg(long)]
690 pub session_id: Option<String>,
691 #[arg(long)]
695 pub path: Option<String>,
696 #[command(flatten)]
697 pub dates: DateRange,
698 #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(i64).range(0..))]
700 pub context: i64,
701 #[arg(long, default_value_t = 0)]
703 pub limit: usize,
704 #[arg(long)]
706 pub max_groups: Option<usize>,
707 #[arg(long)]
711 pub max_examples_per_group: Option<usize>,
712 #[arg(long)]
715 pub min_matches: Option<usize>,
716 #[arg(long)]
718 pub phrase_min_words: Option<usize>,
719 #[arg(long)]
721 pub phrase_max_words: Option<usize>,
722 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
724 pub format: OutputFormat,
725}
726
727pub fn run_repeats(db: &Db, config: &AnalyticsConfig, args: &RepeatsArgs) -> Result<()> {
728 if args.regex && args.query.is_none() {
729 bail!("--regex requires QUERY");
730 }
731 run_repeats_issues(db, config, args)
732}
733
734fn repeat_filters(
735 db: &Db,
736 args: &RepeatsArgs,
737 default_role: Option<Role>,
738) -> Result<MessageFilters> {
739 let (since, until) = args.dates.resolve_now()?;
740 let exact_session_id = args
741 .session_id
742 .as_deref()
743 .map(|id| db.resolve_session_record(id).map(|session| session.id))
744 .transpose()?;
745 Ok(MessageFilters {
746 role: args.role.or(default_role),
747 providers: args.provider.map(|provider| vec![provider]),
748 session_id: exact_session_id,
749 path_prefix: args.path.as_deref().map(crate::util::normalize_path_prefix),
750 since,
751 until,
752 match_mode: if args.regex {
753 MessageSearchMode::Regex
754 } else {
755 MessageSearchMode::Literal
756 },
757 limit: args.limit,
758 ..Default::default()
759 })
760}
761
762fn repeat_query(args: &RepeatsArgs) -> &str {
767 args.query.as_deref().unwrap_or("")
768}
769
770fn run_repeats_issues(db: &Db, config: &AnalyticsConfig, args: &RepeatsArgs) -> Result<()> {
771 let max_groups = args.max_groups.unwrap_or(config.repeat_max_groups);
772 let max_examples_per_group = args
773 .max_examples_per_group
774 .unwrap_or(config.repeat_max_examples_per_group);
775 let min_matches = args.min_matches.unwrap_or(config.repeat_min_matches);
776 let phrase_min_words = args
777 .phrase_min_words
778 .unwrap_or(config.repeat_phrase_min_words);
779 let phrase_max_words = args
780 .phrase_max_words
781 .unwrap_or(config.repeat_phrase_max_words);
782
783 if phrase_min_words == 0 {
784 bail!("--phrase-min-words must be at least 1");
785 }
786 if min_matches == 0 {
787 bail!("--min-matches must be at least 1");
788 }
789 if phrase_max_words < phrase_min_words {
790 bail!("--phrase-max-words must be >= --phrase-min-words");
791 }
792 let filters = repeat_filters(db, args, Some(Role::User))?;
793 let query = repeat_query(args);
794 let hits = if args.role.is_none() || args.role == Some(Role::User) {
798 db.search_attributable_human_messages(query, &filters)?
799 } else {
800 db.search_messages(query, &filters)?
801 };
802 let rows = repeat_phrase_groups(
803 &hits,
804 args.context,
805 min_matches,
806 phrase_min_words,
807 phrase_max_words,
808 max_groups,
809 max_examples_per_group,
810 );
811 emit(&rows, args.format)
812}
813
814fn repeat_phrase_groups(
815 hits: &[MessageHit],
816 context: i64,
817 min_matches: usize,
818 min_words: usize,
819 max_words: usize,
820 max_groups: usize,
821 max_examples_per_group: usize,
822) -> Vec<RepeatGroup> {
823 if hits.is_empty() {
824 return Vec::new();
825 }
826
827 let mut phrase_hits: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
828 for (index, hit) in hits.iter().enumerate() {
829 for phrase in phrases_in_message(&hit.content, min_words, max_words) {
830 phrase_hits.entry(phrase).or_default().insert(index);
831 }
832 }
833
834 let mut candidates: Vec<(String, BTreeSet<usize>)> = phrase_hits
835 .into_iter()
836 .filter(|(_, indices)| indices.len() >= min_matches)
837 .collect();
838 candidates.sort_by(|(phrase_a, hits_a), (phrase_b, hits_b)| {
839 hits_b
840 .len()
841 .cmp(&hits_a.len())
842 .then_with(|| phrase_word_count(phrase_b).cmp(&phrase_word_count(phrase_a)))
843 .then_with(|| phrase_a.cmp(phrase_b))
844 });
845
846 remove_equal_support_contained_phrases(&mut candidates);
847 if max_groups > 0 && candidates.len() > max_groups {
848 candidates.truncate(max_groups);
849 }
850
851 candidates
852 .into_iter()
853 .map(|(repeat, indices)| {
854 let matches = indices.len();
855 let sessions = indices
856 .iter()
857 .map(|&index| hits[index].session_id.as_str())
858 .collect::<BTreeSet<_>>()
859 .len();
860 let example_count = if max_examples_per_group == 0 {
861 matches
862 } else {
863 max_examples_per_group.min(matches)
864 };
865 let examples = indices
866 .iter()
867 .take(example_count)
868 .map(|&index| RepeatGroupExample::from_hit(&hits[index], context))
869 .collect();
870 RepeatGroup {
871 repeat,
872 matches,
873 sessions,
874 examples,
875 }
876 })
877 .collect()
878}
879
880fn remove_equal_support_contained_phrases(candidates: &mut Vec<(String, BTreeSet<usize>)>) {
881 let mut kept: Vec<(String, BTreeSet<usize>)> = Vec::with_capacity(candidates.len());
882 for (phrase, indices) in candidates.drain(..) {
883 let is_duplicate = kept.iter().any(|(kept_phrase, kept_indices)| {
884 kept_indices == &indices && phrase_is_contiguous_subphrase_of(&phrase, kept_phrase)
885 });
886 if !is_duplicate {
887 kept.push((phrase, indices));
888 }
889 }
890 *candidates = kept;
891}
892
893fn phrase_is_contiguous_subphrase_of(needle: &str, haystack: &str) -> bool {
894 if needle == haystack {
895 return true;
896 }
897 let needle_words = needle.split_whitespace().collect::<Vec<_>>();
898 let haystack_words = haystack.split_whitespace().collect::<Vec<_>>();
899 needle_words.len() < haystack_words.len()
900 && haystack_words
901 .windows(needle_words.len())
902 .any(|window| window == needle_words)
903}
904
905fn phrases_in_message(content: &str, min_words: usize, max_words: usize) -> BTreeSet<String> {
906 let tokens = normalized_tokens(repeat_mining_text(content));
907 let mut phrases = BTreeSet::new();
908 if tokens.len() < min_words {
909 return phrases;
910 }
911 let max_words = max_words.max(min_words).min(tokens.len());
912 for width in min_words..=max_words {
913 for window in tokens.windows(width) {
914 if informative_phrase(window) {
915 phrases.insert(window.join(" "));
916 }
917 }
918 }
919 phrases
920}
921
922fn repeat_mining_text(content: &str) -> &str {
923 extract_user_request_body(content).unwrap_or(content)
924}
925
926fn extract_user_request_body(content: &str) -> Option<&str> {
927 let start = content.find(USER_REQUEST_START)? + USER_REQUEST_START.len();
928 let end = content[start..].find(USER_REQUEST_END)? + start;
929 Some(content[start..end].trim())
930}
931
932pub(crate) fn normalized_tokens(content: &str) -> Vec<String> {
938 let mut tokens = Vec::new();
939 let mut current = String::new();
940 for ch in content.chars() {
941 if ch.is_alphanumeric() {
942 current.extend(crate::util::fold_caseless_char(ch));
943 } else if !current.is_empty() {
944 tokens.push(std::mem::take(&mut current));
945 }
946 }
947 if !current.is_empty() {
948 tokens.push(current);
949 }
950 tokens
951}
952
953fn informative_phrase(tokens: &[String]) -> bool {
954 if tokens
955 .iter()
956 .any(|token| token.chars().any(|ch| ch.is_ascii_digit()))
957 {
958 return false;
959 }
960 tokens
961 .first()
962 .is_some_and(|token| !is_repeat_stopword(token))
963 && tokens
964 .last()
965 .is_some_and(|token| !is_repeat_stopword(token))
966 && tokens.iter().any(|token| {
967 token.len() >= 4
968 && !is_repeat_stopword(token)
969 && !token.chars().all(|ch| ch.is_ascii_digit())
970 })
971}
972
973fn is_repeat_stopword(token: &str) -> bool {
974 matches!(
975 token,
976 "a" | "an"
977 | "additional"
978 | "and"
979 | "are"
980 | "as"
981 | "at"
982 | "be"
983 | "but"
984 | "by"
985 | "can"
986 | "does"
987 | "do"
988 | "for"
989 | "from"
990 | "has"
991 | "have"
992 | "i"
993 | "if"
994 | "in"
995 | "is"
996 | "it"
997 | "local"
998 | "metadata"
999 | "not"
1000 | "of"
1001 | "on"
1002 | "or"
1003 | "rather"
1004 | "request"
1005 | "s"
1006 | "so"
1007 | "that"
1008 | "than"
1009 | "the"
1010 | "there"
1011 | "this"
1012 | "time"
1013 | "to"
1014 | "user"
1015 | "was"
1016 | "with"
1017 | "you"
1018 | "your"
1019 )
1020}
1021
1022fn phrase_word_count(phrase: &str) -> usize {
1023 phrase.split_whitespace().count()
1024}
1025
1026fn context_command(session_id: &str, seq: i64, context: i64) -> String {
1027 format!("aise messages get {session_id} --seq {seq} --context {context}")
1028}
1029
1030fn emit<T: Serialize + Row>(rows: &[T], format: OutputFormat) -> Result<()> {
1031 let stdout = io::stdout();
1032 let mut out = stdout.lock();
1033 render(rows, format, &mut out)?;
1034 out.flush()?;
1035 Ok(())
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040 use super::*;
1041
1042 fn patterns() -> Vec<(String, Regex)> {
1043 compile_patterns(&Config::default()).unwrap()
1044 }
1045
1046 #[test]
1054 fn a_greek_word_normalizes_to_one_token_whichever_sigma_it_is_written_with() {
1055 let expected = vec!["οδοσσ".to_string()];
1056 for spelling in ["ΟΔΟΣΣ", "οδοσς", "οδοσσ", "Οδοσς"] {
1057 assert_eq!(
1058 normalized_tokens(spelling),
1059 expected,
1060 "{spelling:?} is the same token"
1061 );
1062 }
1063 }
1064
1065 fn categorize(text: &str) -> Option<String> {
1066 patterns()
1067 .iter()
1068 .find_map(|(cat, re)| re.is_match(text).then(|| cat.clone()))
1069 }
1070
1071 fn hit(seq: i64, role: Role, content: &str) -> MessageHit {
1072 MessageHit {
1073 session_id: "claude:test".to_string(),
1074 provider: Provider::Claude,
1075 seq,
1076 role,
1077 kind: crate::models::MessageKind::Conversation,
1078 ts: None,
1079 tool_name: None,
1080 tool_call_id: None,
1081 fuzzy_score: None,
1082 content: content.to_string(),
1083 }
1084 }
1085
1086 #[test]
1087 fn phrase_is_contiguous_subphrase_of_requires_contiguity() {
1088 assert!(phrase_is_contiguous_subphrase_of("a b", "a b")); assert!(phrase_is_contiguous_subphrase_of("a b", "a b c")); assert!(phrase_is_contiguous_subphrase_of("b c", "a b c")); assert!(phrase_is_contiguous_subphrase_of("b", "a b c")); assert!(!phrase_is_contiguous_subphrase_of("a c", "a b c")); assert!(!phrase_is_contiguous_subphrase_of("a b c", "a b")); assert!(!phrase_is_contiguous_subphrase_of("x", "a b c")); }
1096
1097 #[test]
1098 fn informative_phrase_rejects_stopword_ends_and_numeric_noise() {
1099 let toks = |s: &str| s.split(' ').map(String::from).collect::<Vec<_>>();
1100 assert!(informative_phrase(&toks("deploy service")));
1102 assert!(!informative_phrase(&toks("and service")));
1104 assert!(!informative_phrase(&toks("deploy and")));
1105 assert!(!informative_phrase(&toks("deploy service2")));
1107 assert!(!informative_phrase(&toks("run ci")));
1109 assert!(!informative_phrase(&[]));
1111 }
1112
1113 #[test]
1114 fn remove_equal_support_contained_phrases_is_support_and_order_sensitive() {
1115 let set = |xs: &[usize]| xs.iter().copied().collect::<BTreeSet<usize>>();
1116 let mut c = vec![
1118 ("deploy service".to_string(), set(&[1, 2])),
1119 ("deploy".to_string(), set(&[1, 2])),
1120 ];
1121 remove_equal_support_contained_phrases(&mut c);
1122 assert_eq!(c.len(), 1);
1123 assert_eq!(c[0].0, "deploy service");
1124 let mut c = vec![
1126 ("deploy service".to_string(), set(&[1, 2])),
1127 ("deploy".to_string(), set(&[5])),
1128 ];
1129 remove_equal_support_contained_phrases(&mut c);
1130 assert_eq!(c.len(), 2);
1131 let mut c = vec![
1134 ("deploy".to_string(), set(&[1, 2])),
1135 ("deploy service".to_string(), set(&[1, 2])),
1136 ];
1137 remove_equal_support_contained_phrases(&mut c);
1138 assert_eq!(c.len(), 2);
1139 }
1140
1141 #[test]
1142 fn compile_planning_regex_is_case_insensitive_and_reports_invalid_patterns() {
1143 let re = compile_planning_regex("planning", "/goal").unwrap();
1145 assert!(re.is_match("/GOAL"));
1146 let err = compile_planning_regex("planning", "[unclosed")
1148 .unwrap_err()
1149 .to_string();
1150 assert!(err.contains("invalid planning regex '[unclosed'"), "{err}");
1151 }
1152
1153 #[test]
1154 fn repeats_mine_only_original_attributable_human_content() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let db = Db::open(&dir.path().join("index.db")).unwrap();
1157 db.execute_batch_for_test(
1158 "insert into sessions (
1159 id, provider, provider_session_id, preview_text,
1160 source_path, parse_version, discovery_source
1161 ) values ('claude:repeat-authorship', 'claude', 'repeat-authorship', '',
1162 '/repeat-authorship', 'v1', 'test');
1163 insert into messages (
1164 id, session_id, provider, seq, role, kind, content, authorship, record_relation
1165 ) values
1166 (101, 'claude:repeat-authorship', 'claude', 0, 'user', 'conversation',
1167 'durable human phrase', 'human', 'original'),
1168 (102, 'claude:repeat-authorship', 'claude', 1, 'user', 'conversation',
1169 'durable human phrase', 'human', 'original'),
1170 (103, 'claude:repeat-authorship', 'claude', 2, 'user', 'conversation',
1171 'durable agent phrase', 'agent', 'original'),
1172 (104, 'claude:repeat-authorship', 'claude', 3, 'user', 'harness_notice',
1173 'durable harness phrase', 'harness', 'original'),
1174 (105, 'claude:repeat-authorship', 'claude', 4, 'user', 'conversation',
1175 'durable mirror phrase', 'human', 'mirror'),
1176 (106, 'claude:repeat-authorship', 'claude', 5, 'user', 'conversation',
1177 'durable unknown phrase', 'unknown', 'unknown'),
1178 (107, 'claude:repeat-authorship', 'claude', 6, 'user', 'conversation',
1179 'mixed human phrase generated repeated phrase', 'mixed', 'original'),
1180 (108, 'claude:repeat-authorship', 'claude', 7, 'user', 'conversation',
1181 'mixed human phrase generated repeated phrase', 'mixed', 'original');
1182 insert into message_content_parts (
1183 message_id, ordinal, start_char, end_char, authorship, origin
1184 ) values
1185 (107, 0, 0, 18, 'human', 'direct_input'),
1186 (107, 1, 18, 43, 'generated', 'tool_payload'),
1187 (108, 0, 0, 18, 'human', 'direct_input'),
1188 (108, 1, 18, 43, 'generated', 'tool_payload');",
1189 )
1190 .unwrap();
1191 let args = RepeatsArgs {
1192 query: None,
1193 regex: false,
1194 role: None,
1195 provider: None,
1196 session_id: None,
1197 path: None,
1198 dates: DateRange::default(),
1199 context: 0,
1200 limit: 0,
1201 max_groups: Some(0),
1202 max_examples_per_group: Some(0),
1203 min_matches: Some(2),
1204 phrase_min_words: Some(2),
1205 phrase_max_words: Some(4),
1206 format: OutputFormat::Json,
1207 };
1208 let filters = repeat_filters(&db, &args, Some(Role::User)).unwrap();
1209 let hits = db.search_attributable_human_messages("", &filters).unwrap();
1210 assert_eq!(
1211 hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
1212 [0, 1, 6, 7]
1213 );
1214 assert_eq!(hits[2].content, "mixed human phrase");
1215 assert_eq!(hits[3].content, "mixed human phrase");
1216 let groups = repeat_phrase_groups(&hits, 0, 2, 2, 4, 0, 0);
1217 assert!(groups
1218 .iter()
1219 .any(|group| group.repeat == "durable human phrase"));
1220 assert!(groups
1221 .iter()
1222 .any(|group| group.repeat == "mixed human phrase"));
1223 assert!(!groups.iter().any(|group| group.repeat.contains("agent")));
1224 assert!(!groups.iter().any(|group| group.repeat.contains("harness")));
1225 assert!(!groups.iter().any(|group| group.repeat.contains("mirror")));
1226 assert!(!groups.iter().any(|group| group.repeat.contains("unknown")));
1227 assert!(!groups
1228 .iter()
1229 .any(|group| group.repeat.contains("generated")));
1230 }
1231
1232 #[test]
1236 fn repeats_apply_the_regex_query_to_the_mined_messages() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let db = Db::open(&dir.path().join("index.db")).unwrap();
1239 db.execute_batch_for_test(
1240 "insert into sessions (
1241 id, provider, provider_session_id, preview_text,
1242 source_path, parse_version, discovery_source
1243 ) values ('claude:repeat-regex', 'claude', 'repeat-regex', '',
1244 '/repeat-regex', 'v1', 'test');
1245 insert into messages (
1246 id, session_id, provider, seq, role, kind, content, authorship, record_relation
1247 ) values
1248 (201, 'claude:repeat-regex', 'claude', 0, 'user', 'conversation',
1249 '/plan the login fix', 'human', 'original'),
1250 (202, 'claude:repeat-regex', 'claude', 1, 'user', 'conversation',
1251 '/plan the login fix', 'human', 'original'),
1252 (203, 'claude:repeat-regex', 'claude', 2, 'user', 'conversation',
1253 'please rerun the login fix', 'human', 'original');",
1254 )
1255 .unwrap();
1256 let regex_args = |pattern: &str| RepeatsArgs {
1257 query: Some(pattern.to_string()),
1258 regex: true,
1259 role: None,
1260 provider: None,
1261 session_id: None,
1262 path: None,
1263 dates: DateRange::default(),
1264 context: 0,
1265 limit: 0,
1266 max_groups: Some(0),
1267 max_examples_per_group: Some(0),
1268 min_matches: Some(2),
1269 phrase_min_words: Some(2),
1270 phrase_max_words: Some(4),
1271 format: OutputFormat::Json,
1272 };
1273 let slash = regex_args(r"^/\S+");
1274 assert_eq!(repeat_query(&slash), r"^/\S+");
1275 let filters = repeat_filters(&db, &slash, Some(Role::User)).unwrap();
1276 assert_eq!(filters.match_mode, MessageSearchMode::Regex);
1277 let hits = db
1278 .search_attributable_human_messages(repeat_query(&slash), &filters)
1279 .unwrap();
1280 assert_eq!(
1281 hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
1282 [0, 1],
1283 "only the slash-command messages match ^/\\S+"
1284 );
1285
1286 let none = regex_args("zzz_no_match");
1287 let filters = repeat_filters(&db, &none, Some(Role::User)).unwrap();
1288 assert!(db
1289 .search_attributable_human_messages(repeat_query(&none), &filters)
1290 .unwrap()
1291 .is_empty());
1292
1293 let invalid = regex_args("[unclosed");
1294 let filters = repeat_filters(&db, &invalid, Some(Role::User)).unwrap();
1295 let error = db
1296 .search_attributable_human_messages(repeat_query(&invalid), &filters)
1297 .unwrap_err()
1298 .to_string();
1299 assert!(error.contains("invalid regex"), "{error}");
1300 }
1301
1302 #[test]
1303 fn repeat_phrase_groups_find_repeated_phrases_without_builtins() {
1304 let hits = vec![
1305 hit(
1306 10,
1307 Role::User,
1308 "remember to avoid magic values and make the timeout configurable",
1309 ),
1310 hit(
1311 20,
1312 Role::User,
1313 "please avoid magic values and keep the limit configurable",
1314 ),
1315 hit(
1316 30,
1317 Role::User,
1318 "please reuse the existing helper instead of duplicate code",
1319 ),
1320 ];
1321
1322 let groups = repeat_phrase_groups(&hits, 3, 2, 2, 4, 0, 0);
1323
1324 let avoid_magic_values = groups
1325 .iter()
1326 .find(|group| group.repeat == "avoid magic values")
1327 .expect("maximal repeated phrase is discovered from the data");
1328 assert!(groups.iter().all(|group| group.repeat != "avoid magic"));
1329 assert!(groups.iter().all(|group| group.repeat != "magic values"));
1330 assert_eq!(avoid_magic_values.matches, 2);
1331 assert_eq!(avoid_magic_values.sessions, 1);
1332 assert_eq!(
1333 avoid_magic_values
1334 .examples
1335 .iter()
1336 .map(|m| m.seq)
1337 .collect::<Vec<_>>(),
1338 vec![10, 20]
1339 );
1340 assert_eq!(
1341 avoid_magic_values.examples[0].context_command,
1342 "aise messages get claude:test --seq 10 --context 3"
1343 );
1344 }
1345
1346 #[test]
1347 fn repeat_phrase_groups_keep_only_maximal_phrase_for_equal_support() {
1348 let hits = vec![
1349 hit(10, Role::User, "avoid magic values everywhere"),
1350 hit(20, Role::User, "avoid magic values everywhere"),
1351 ];
1352
1353 let groups = repeat_phrase_groups(&hits, 0, 2, 2, 4, 0, 0);
1354
1355 assert_eq!(
1356 groups.len(),
1357 1,
1358 "contained fragments add no distinct evidence"
1359 );
1360 assert_eq!(groups[0].repeat, "avoid magic values everywhere");
1361 assert_eq!(groups[0].matches, 2);
1362 }
1363
1364 #[test]
1365 fn repeat_phrase_mining_uses_user_request_body_not_harness_metadata() {
1366 let content = "<USER_REQUEST>\navoid magic values and keep settings configurable\n</USER_REQUEST><ADDITIONAL_METADATA>\nThe current local time is 2026-06-30T06:49:05Z.\n</ADDITIONAL_METADATA>";
1367
1368 let phrases = phrases_in_message(content, 2, 4);
1369
1370 assert!(phrases.contains("magic values"));
1371 assert!(phrases.contains("avoid magic values"));
1372 assert!(!phrases.contains("current local time"));
1373 assert!(!phrases.contains("additional metadata"));
1374
1375 let member = RepeatGroupExample::from_hit(&hit(1, Role::User, content), 0);
1376 assert!(member.preview.starts_with("avoid magic values"));
1377 assert!(!member.preview.contains("USER_REQUEST"));
1378 }
1379
1380 #[test]
1381 fn repeat_phrase_mining_skips_numeric_noise() {
1382 let phrases = phrases_in_message("local time is 2026 06 30 and version v4", 2, 4);
1383
1384 assert!(!phrases.iter().any(|phrase| phrase.contains("2026")));
1385 assert!(!phrases.iter().any(|phrase| phrase.contains("v4")));
1386 }
1387
1388 #[test]
1389 fn repeat_group_examples_are_bounded_without_losing_aggregate_counts() {
1390 let hits = vec![
1391 hit(10, Role::User, "avoid magic values everywhere"),
1392 hit(20, Role::User, "avoid magic values everywhere"),
1393 hit(30, Role::User, "avoid magic values everywhere"),
1394 ];
1395
1396 let groups = repeat_phrase_groups(&hits, 0, 2, 2, 4, 1, 2);
1397
1398 assert_eq!(groups.len(), 1);
1399 assert_eq!(groups[0].matches, 3);
1400 assert_eq!(groups[0].sessions, 1);
1401 assert_eq!(groups[0].examples.len(), 2);
1402 assert_eq!(
1403 groups[0]
1404 .examples
1405 .iter()
1406 .map(|example| example.seq)
1407 .collect::<Vec<_>>(),
1408 vec![10, 20]
1409 );
1410 let serialized = serde_json::to_value(&groups).unwrap();
1411 assert!(serialized[0].get("examples").is_some());
1412 assert!(serialized[0]["examples"][0].get("matched_text").is_none());
1413
1414 let all_examples = repeat_phrase_groups(&hits, 0, 2, 2, 4, 1, 0);
1415 assert_eq!(all_examples[0].examples.len(), 3);
1416 }
1417
1418 #[test]
1419 fn repeat_group_limit_applies_before_example_materialization() {
1420 let hits = vec![
1421 hit(10, Role::User, "alpha bravo first"),
1422 hit(20, Role::User, "alpha bravo second"),
1423 hit(30, Role::User, "charlie delta third"),
1424 hit(40, Role::User, "charlie delta fourth"),
1425 ];
1426
1427 let groups = repeat_phrase_groups(&hits, 0, 2, 2, 2, 1, 1);
1428
1429 assert_eq!(groups.len(), 1);
1430 assert_eq!(groups[0].examples.len(), 1);
1431 }
1432
1433 #[test]
1438 fn correction_column_names_the_matched_text_not_the_rule() {
1439 let headers = MessageClassificationMatch::headers();
1440 assert!(
1441 headers.contains(&"match"),
1442 "the column must name the matched text: {headers:?}"
1443 );
1444 assert!(
1445 !headers.contains(&"pattern"),
1446 "`pattern` names the rule input and must not return: {headers:?}"
1447 );
1448 }
1449
1450 #[test]
1451 fn categories_match_expected_keywords() {
1452 assert_eq!(
1453 categorize("you forgot to run the tests").as_deref(),
1454 Some("skip_step")
1455 );
1456 assert_eq!(
1457 categorize("that is actually wrong").as_deref(),
1458 Some("misunderstanding")
1459 );
1460 assert_eq!(
1461 categorize("you must also add a test").as_deref(),
1462 Some("incomplete")
1463 );
1464 }
1465
1466 #[test]
1467 fn first_match_wins_and_other_is_last() {
1468 assert_eq!(categorize("stop").as_deref(), Some("other"));
1470 assert_eq!(
1472 categorize("you removed the function").as_deref(),
1473 Some("regression")
1474 );
1475 assert_eq!(categorize("looks great, thanks"), None);
1477 }
1478
1479 #[test]
1480 fn stop_matches_imperative_corrections_not_workflow_phrasing() {
1481 assert_eq!(categorize("stop").as_deref(), Some("other"));
1483 assert_eq!(
1484 categorize("stop falsely marking goals as complete").as_deref(),
1485 Some("other")
1486 );
1487 assert_eq!(
1488 categorize("stop incrementing the version so frequently").as_deref(),
1489 Some("other")
1490 );
1491 assert_eq!(
1492 categorize("please stop, that approach is off").as_deref(),
1493 Some("other")
1494 );
1495 assert_eq!(
1498 categorize("Run this bash command once and stop: grep hi /tmp/x"),
1499 None
1500 );
1501 assert_eq!(
1502 categorize("at your next progress point commit and then stop"),
1503 None
1504 );
1505 assert_eq!(
1506 categorize("keep going dont stop for trivial questions"),
1507 None
1508 );
1509 assert_eq!(
1510 categorize("a clear way to start and stop all the tooling"),
1511 None
1512 );
1513 }
1514
1515 #[test]
1516 fn default_patterns_are_precise_on_labeled_corpus() {
1517 let positives: &[(&str, &str)] = &[
1519 ("you deleted my helper function", "regression"),
1520 ("you broke the build", "regression"),
1521 ("that reverted my changes", "regression"),
1522 ("you forgot to update the test", "skip_step"),
1523 ("you missed the edge case", "skip_step"),
1524 ("don't forget the migration", "skip_step"),
1525 ("that's wrong, the API returns a list", "misunderstanding"),
1526 ("no, that's not what I asked", "misunderstanding"),
1527 ("you're wrong about the types", "misunderstanding"),
1528 ("you also need to handle the error case", "incomplete"),
1529 ("that's not finished, the tests still fail", "incomplete"),
1530 ("stop changing the config", "other"),
1531 ("please stop", "other"),
1532 ];
1533 for (text, want) in positives {
1534 assert_eq!(
1535 categorize(text).as_deref(),
1536 Some(*want),
1537 "true positive: {text:?}"
1538 );
1539 }
1540 let negatives: &[&str] = &[
1542 "let's revert to the design doc approach",
1543 "the rollback procedure is documented in the README",
1544 "this broke down into three subtasks",
1545 "I lost track of which branch we're on",
1546 "actually, let's use a HashMap here",
1547 "wait, let me check the logs first",
1548 "what could go wrong here?",
1549 "no thanks, that's all for now",
1550 "we should have access to the API",
1551 "run the command once and stop",
1552 "the incorrect assumption was already fixed",
1553 ];
1554 for text in negatives {
1555 assert_eq!(
1556 categorize(text),
1557 None,
1558 "true negative must not match: {text:?}"
1559 );
1560 }
1561 }
1562
1563 #[test]
1564 fn planning_commands_config_compiles_to_filters() {
1565 let mut config = Config::default();
1566 assert!(compile_planning_filters(&config, &[]).unwrap().is_empty());
1568 config.analytics.planning_commands = vec!["^/cmd-a".to_string(), "review".to_string()];
1570 let filters = compile_planning_filters(&config, &["^/cmd-b$".to_string()]).unwrap();
1571 assert_eq!(filters.len(), 3);
1572 assert!(filters[0].is_match("/cmd-a"));
1573 assert!(filters[0].is_match("/cmd-a-review"));
1574 assert!(filters[1].is_match("/REVIEW"));
1575 assert!(filters[2].is_match("/cmd-b"));
1576 assert!(!filters[2].is_match("/cmd-b-extra"));
1577 }
1578}