Skip to main content

ctx/
utils.rs

1//! Shared utility functions for ctx.
2//!
3//! This module contains helper functions used across multiple command modules.
4
5use std::collections::BTreeSet;
6
7/// Stopwords excluded from lexical token matching: articles, prepositions, and
8/// generic task verbs that carry no file-identifying signal (so "add a new X"
9/// matches on "X", not on "add"/"new").
10const LEXICAL_STOPWORDS: &[&str] = &[
11    "a", "an", "the", "of", "in", "on", "to", "for", "with", "and", "or", "by", "as", "at", "is",
12    "are", "be", "it", "this", "that", "from", "into", "via", "add", "new", "make", "use", "using",
13];
14
15/// Split a string into normalized lexical tokens for path / identifier matching.
16///
17/// Lowercases, splits on any non-alphanumeric character **and** on camelCase
18/// boundaries (so `parseSolidity` → `parse`, `solidity`), drops tokens of length
19/// ≤ 1, and removes a small stopword set. Used to score how strongly a task
20/// description lexically overlaps a file path or symbol name — a high-precision
21/// relevance signal that embedding similarity can miss.
22pub fn lexical_tokens(s: &str) -> BTreeSet<String> {
23    let mut tokens = BTreeSet::new();
24    let mut current = String::new();
25    // Was the previous char a lowercase letter or digit? Used to detect the
26    // lower→upper camelCase boundary that starts a new word.
27    let mut prev_lower_or_digit = false;
28
29    for ch in s.chars() {
30        if ch.is_alphanumeric() {
31            if ch.is_uppercase() && prev_lower_or_digit && !current.is_empty() {
32                push_token(&mut tokens, &current);
33                current.clear();
34            }
35            current.extend(ch.to_lowercase());
36            prev_lower_or_digit = ch.is_lowercase() || ch.is_numeric();
37        } else {
38            if !current.is_empty() {
39                push_token(&mut tokens, &current);
40                current.clear();
41            }
42            prev_lower_or_digit = false;
43        }
44    }
45    if !current.is_empty() {
46        push_token(&mut tokens, &current);
47    }
48    tokens
49}
50
51/// Insert a normalized token, dropping length-≤-1 tokens and stopwords.
52fn push_token(tokens: &mut BTreeSet<String>, tok: &str) {
53    if tok.len() > 1 && !LEXICAL_STOPWORDS.contains(&tok) {
54        tokens.insert(tok.to_string());
55    }
56}
57
58/// Truncate a string with ellipsis, respecting UTF-8 char boundaries.
59///
60/// If the string is longer than `max` characters, it will be truncated
61/// to `max - 3` characters followed by "...".
62///
63/// # Examples
64///
65/// ```ignore
66/// assert_eq!(truncate_str("hello world", 8), "hello...");
67/// assert_eq!(truncate_str("short", 10), "short");
68/// ```
69pub fn truncate_str(s: &str, max: usize) -> String {
70    if s.len() <= max {
71        s.to_string()
72    } else {
73        let target = max.saturating_sub(3);
74        let mut end = target;
75        while end > 0 && !s.is_char_boundary(end) {
76            end -= 1;
77        }
78        format!("{}...", &s[..end])
79    }
80}
81
82/// Truncate a path from the beginning, respecting UTF-8 char boundaries.
83///
84/// Unlike `truncate_str`, this keeps the end of the string (which typically
85/// contains the filename) and truncates from the beginning.
86///
87/// # Examples
88///
89/// ```ignore
90/// assert_eq!(truncate_path("/very/long/path/to/file.rs", 15), "...to/file.rs");
91/// assert_eq!(truncate_path("short.rs", 20), "short.rs");
92/// ```
93pub fn truncate_path(s: &str, max: usize) -> String {
94    if s.len() <= max {
95        s.to_string()
96    } else {
97        let target = s.len() - max + 3;
98        let mut start = target;
99        while start < s.len() && !s.is_char_boundary(start) {
100            start += 1;
101        }
102        format!("...{}", &s[start..])
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn toks(s: &str) -> Vec<String> {
111        lexical_tokens(s).into_iter().collect()
112    }
113
114    #[test]
115    fn test_lexical_tokens_path_splits_on_separators() {
116        // Path splits on '/' and '.'; tokens are lowercased, deduped, sorted.
117        assert_eq!(
118            toks("src/embeddings/openai.rs"),
119            vec!["embeddings", "openai", "rs", "src"]
120        );
121    }
122
123    #[test]
124    fn test_lexical_tokens_underscore_splits_each_word() {
125        // snake_case splits into individual words (no combined "run_sql" token).
126        assert_eq!(toks("run_sql_duckdb"), vec!["duckdb", "run", "sql"]);
127    }
128
129    #[test]
130    fn test_lexical_tokens_camelcase() {
131        // camelCase and PascalCase split into words, lowercased.
132        assert_eq!(toks("parseSolidity"), vec!["parse", "solidity"]);
133        assert_eq!(toks("SmartConfig"), vec!["config", "smart"]);
134        // digits stay attached to their word
135        assert_eq!(toks("v1Symbols"), vec!["symbols", "v1"]);
136    }
137
138    #[test]
139    fn test_lexical_tokens_stopwords_and_length() {
140        // Stopwords and single chars are dropped; result is deduped + sorted.
141        assert_eq!(
142            toks("add a new output format to ctx sql"),
143            vec!["ctx", "format", "output", "sql"]
144        );
145        assert_eq!(
146            toks("generate embeddings with openai"),
147            vec!["embeddings", "generate", "openai"]
148        );
149    }
150
151    #[test]
152    fn test_truncate_str_ascii() {
153        // No truncation needed
154        assert_eq!(truncate_str("hello", 10), "hello");
155        assert_eq!(truncate_str("hello", 5), "hello");
156
157        // Truncation needed
158        assert_eq!(truncate_str("hello world", 8), "hello...");
159        assert_eq!(truncate_str("abcdefghij", 7), "abcd...");
160    }
161
162    #[test]
163    fn test_truncate_str_unicode() {
164        // Box drawing (─ is 3 bytes)
165        let box_line = "┌────────────────────┐";
166        let result = truncate_str(box_line, 10);
167        assert!(result.ends_with("..."));
168        // Should not panic
169
170        // Emoji (🎉 is 4 bytes)
171        let emoji = "Hello 🎉🎊🎁 World";
172        let result = truncate_str(emoji, 10);
173        assert!(result.ends_with("..."));
174
175        // Chinese (each char is 3 bytes)
176        let chinese = "你好世界测试";
177        let result = truncate_str(chinese, 8);
178        assert!(result.ends_with("..."));
179    }
180
181    #[test]
182    fn test_truncate_path_ascii() {
183        // No truncation needed
184        assert_eq!(truncate_path("src/main.rs", 20), "src/main.rs");
185
186        // Truncation needed - keeps end of path
187        let result = truncate_path("/very/long/path/to/file.rs", 15);
188        assert!(result.starts_with("..."));
189        assert!(result.contains("file.rs"));
190    }
191
192    #[test]
193    fn test_truncate_path_unicode() {
194        // Path with Unicode
195        let path = "/home/用户/项目/文件.rs";
196        let result = truncate_path(path, 15);
197        assert!(result.starts_with("..."));
198        // Should not panic
199
200        // Path with emoji folder names
201        let path = "/home/📁/🎉/file.rs";
202        let result = truncate_path(path, 12);
203        assert!(result.starts_with("..."));
204    }
205
206    #[test]
207    fn test_truncate_edge_cases() {
208        // Very short max
209        assert_eq!(truncate_str("hello", 3), "...");
210        assert_eq!(truncate_str("hi", 3), "hi");
211
212        // Empty string
213        assert_eq!(truncate_str("", 10), "");
214        assert_eq!(truncate_path("", 10), "");
215    }
216}