Skip to main content

ralph_core/
loop_name.rs

1//! Human-readable loop name generation.
2//!
3//! Generates descriptive names for loops/worktrees derived from prompt text,
4//! combined with adjective-noun suffixes for uniqueness.
5//!
6//! Example outputs:
7//! - `fix-header-swift-peacock`
8//! - `add-auth-clever-badger`
9//! - `refactor-api-calm-falcon`
10
11use serde::{Deserialize, Serialize};
12
13/// Configuration for loop naming.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct LoopNamingConfig {
16    /// Naming format: "human-readable" or "timestamp".
17    #[serde(default = "default_format")]
18    pub format: String,
19
20    /// Maximum length for generated names.
21    #[serde(default = "default_max_length")]
22    pub max_length: usize,
23}
24
25fn default_format() -> String {
26    "human-readable".to_string()
27}
28
29fn default_max_length() -> usize {
30    50
31}
32
33impl Default for LoopNamingConfig {
34    fn default() -> Self {
35        Self {
36            format: default_format(),
37            max_length: default_max_length(),
38        }
39    }
40}
41
42/// Generator for human-readable loop names.
43pub struct LoopNameGenerator {
44    config: LoopNamingConfig,
45}
46
47impl LoopNameGenerator {
48    /// Create a new generator with the given configuration.
49    pub fn new(config: LoopNamingConfig) -> Self {
50        Self { config }
51    }
52
53    /// Create a generator from config, using defaults if not configured.
54    pub fn from_config(config: &LoopNamingConfig) -> Self {
55        Self::new(config.clone())
56    }
57
58    /// Generate a name from a prompt.
59    ///
60    /// Returns a name in the format: `keywords-adjective-noun`
61    /// For example: `fix-header-swift-peacock`
62    pub fn generate(&self, prompt: &str) -> String {
63        if self.config.format == "timestamp" {
64            return generate_timestamp_id();
65        }
66
67        let keywords = self.extract_keywords(prompt);
68        let suffix = self.generate_suffix();
69
70        let keyword_part = if keywords.is_empty() {
71            "loop".to_string()
72        } else {
73            keywords.join("-")
74        };
75
76        let name = format!("{}-{}", keyword_part, suffix);
77        self.truncate_to_max_length(&name)
78    }
79
80    /// Generate a unique name, using `exists` to check for collisions.
81    ///
82    /// Tries up to 3 times with different suffixes before falling back
83    /// to timestamp format.
84    pub fn generate_unique(&self, prompt: &str, exists: impl Fn(&str) -> bool) -> String {
85        if self.config.format == "timestamp" {
86            return generate_timestamp_id();
87        }
88
89        let keywords = self.extract_keywords(prompt);
90        let keyword_part = if keywords.is_empty() {
91            "loop".to_string()
92        } else {
93            keywords.join("-")
94        };
95
96        // Try up to 3 times with different suffixes
97        for _ in 0..3 {
98            let suffix = self.generate_suffix();
99            let name = format!("{}-{}", keyword_part, suffix);
100            let name = self.truncate_to_max_length(&name);
101
102            if !exists(&name) {
103                return name;
104            }
105        }
106
107        // Fallback to timestamp format
108        generate_timestamp_id()
109    }
110
111    /// Generate a memorable name (adjective-noun only, no keywords).
112    ///
113    /// Returns a name like "bright-maple" or "swift-falcon".
114    pub fn generate_memorable(&self) -> String {
115        self.generate_suffix()
116    }
117
118    /// Generate a unique memorable name, using `exists` to check for collisions.
119    ///
120    /// Tries up to 10 times with different suffixes before falling back
121    /// to timestamp format.
122    pub fn generate_memorable_unique(&self, exists: impl Fn(&str) -> bool) -> String {
123        // Try up to 10 times with different suffixes
124        for _ in 0..10 {
125            let name = self.generate_suffix();
126            if !exists(&name) {
127                return name;
128            }
129            // Small delay to get different nanosecond value
130            std::thread::sleep(std::time::Duration::from_micros(1));
131        }
132
133        // Fallback to timestamp format (very unlikely with 50*50 = 2500 combinations)
134        generate_timestamp_id()
135    }
136
137    /// Extract keywords from a prompt.
138    fn extract_keywords(&self, prompt: &str) -> Vec<String> {
139        let words: Vec<&str> = prompt
140            .split(|c: char| !c.is_alphanumeric())
141            .filter(|s| !s.is_empty())
142            .collect();
143
144        let mut keywords = Vec::new();
145
146        // Prioritize action verbs
147        for word in &words {
148            let lower = word.to_lowercase();
149            if ACTION_VERBS.contains(&lower.as_str()) && keywords.len() < 3 {
150                keywords.push(lower);
151            }
152        }
153
154        // Then add other significant words
155        for word in &words {
156            let lower = word.to_lowercase();
157            if !STOP_WORDS.contains(&lower.as_str())
158                && !keywords.contains(&lower)
159                && keywords.len() < 3
160                && lower.len() >= 2
161            {
162                keywords.push(lower);
163            }
164        }
165
166        // Sanitize each keyword
167        keywords
168            .into_iter()
169            .map(|w| sanitize_for_git(&w))
170            .filter(|w| !w.is_empty())
171            .take(3)
172            .collect()
173    }
174
175    /// Generate a random adjective-noun suffix.
176    fn generate_suffix(&self) -> String {
177        use std::time::SystemTime;
178
179        // Use nanoseconds for randomness
180        let nanos = SystemTime::now()
181            .duration_since(SystemTime::UNIX_EPOCH)
182            .map(|d| d.as_nanos())
183            .unwrap_or(0);
184
185        let adj_idx = (nanos % ADJECTIVES.len() as u128) as usize;
186        let noun_idx = ((nanos / 1000) % NOUNS.len() as u128) as usize;
187
188        format!("{}-{}", ADJECTIVES[adj_idx], NOUNS[noun_idx])
189    }
190
191    /// Truncate name to max length, preserving word boundaries where possible.
192    fn truncate_to_max_length(&self, name: &str) -> String {
193        if name.len() <= self.config.max_length {
194            return name.to_string();
195        }
196
197        // Try to truncate at a word boundary
198        let mut result = String::new();
199        for part in name.split('-') {
200            let candidate = if result.is_empty() {
201                part.to_string()
202            } else {
203                format!("{}-{}", result, part)
204            };
205
206            if candidate.len() <= self.config.max_length {
207                result = candidate;
208            } else {
209                break;
210            }
211        }
212
213        // If we couldn't fit even one word, just truncate
214        if result.is_empty() {
215            name.chars().take(self.config.max_length).collect()
216        } else {
217            result
218        }
219    }
220}
221
222/// Generate a timestamp-based ID (legacy format).
223fn generate_timestamp_id() -> String {
224    use std::time::SystemTime;
225
226    let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
227
228    // Generate 4-character random hex suffix
229    let random_suffix: u16 = SystemTime::now()
230        .duration_since(SystemTime::UNIX_EPOCH)
231        .map(|d| (d.as_nanos() & 0xFFFF) as u16)
232        .unwrap_or(0);
233
234    format!("ralph-{}-{:04x}", timestamp, random_suffix)
235}
236
237/// Sanitize text for git branch/worktree names.
238pub fn sanitize_for_git(text: &str) -> String {
239    let result: String = text
240        .to_lowercase()
241        .replace([' ', '_'], "-")
242        .chars()
243        .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
244        .collect();
245
246    // Collapse multiple hyphens
247    let mut prev_hyphen = false;
248    let result: String = result
249        .chars()
250        .filter(|c| {
251            if *c == '-' {
252                if prev_hyphen {
253                    return false;
254                }
255                prev_hyphen = true;
256            } else {
257                prev_hyphen = false;
258            }
259            true
260        })
261        .collect();
262
263    // Trim leading/trailing hyphens
264    result.trim_matches('-').to_string()
265}
266
267/// Action verbs to prioritize in keyword extraction.
268const ACTION_VERBS: &[&str] = &[
269    "add",
270    "fix",
271    "update",
272    "remove",
273    "delete",
274    "implement",
275    "create",
276    "refactor",
277    "move",
278    "rename",
279    "change",
280    "modify",
281    "improve",
282    "optimize",
283    "clean",
284    "rewrite",
285    "replace",
286    "merge",
287    "split",
288    "extract",
289    "inline",
290    "simplify",
291    "consolidate",
292    "migrate",
293    "upgrade",
294    "downgrade",
295    "enable",
296    "disable",
297    "configure",
298    "setup",
299    "init",
300    "build",
301    "test",
302    "debug",
303    "deploy",
304    "release",
305];
306
307/// Stop words to filter out of prompts.
308const STOP_WORDS: &[&str] = &[
309    "a", "an", "the", "to", "for", "of", "in", "on", "at", "by", "with", "from", "as", "is", "are",
310    "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will",
311    "would", "could", "should", "may", "might", "must", "shall", "can", "need", "it", "its",
312    "this", "that", "these", "those", "i", "you", "he", "she", "we", "they", "me", "him", "her",
313    "us", "them", "my", "your", "his", "our", "their", "what", "which", "who", "whom", "when",
314    "where", "why", "how", "all", "each", "every", "both", "few", "more", "most", "other", "some",
315    "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "just", "also",
316    "and", "but", "or", "if", "then", "else", "please", "make", "sure", "get", "let", "put",
317];
318
319/// Adjectives for suffix generation.
320const ADJECTIVES: &[&str] = &[
321    "swift", "clever", "bright", "calm", "bold", "keen", "quick", "brave", "fair", "wise", "warm",
322    "cool", "crisp", "fresh", "clear", "sharp", "smooth", "steady", "gentle", "agile", "nimble",
323    "lively", "merry", "jolly", "happy", "lucky", "eager", "ready", "able", "noble", "grand",
324    "prime", "pure", "true", "neat", "tidy", "clean", "sleek", "slick", "smart", "savvy", "snappy",
325    "zippy", "zesty", "peppy", "perky", "chipper", "chirpy", "cheery", "sunny", "breezy",
326];
327
328/// Nouns for suffix generation.
329const NOUNS: &[&str] = &[
330    "peacock", "badger", "falcon", "otter", "robin", "maple", "brook", "cedar", "willow", "finch",
331    "heron", "aspen", "birch", "crane", "egret", "lark", "sparrow", "raven", "hawk", "owl", "fox",
332    "deer", "wolf", "bear", "lion", "tiger", "eagle", "dove", "swan", "gull", "wren", "jay",
333    "pine", "oak", "elm", "fern", "moss", "reed", "sage", "mint", "rose", "lily", "iris", "daisy",
334    "tulip", "orchid", "lotus", "ivy", "palm", "cork", "teak",
335];
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::collections::HashSet;
341
342    #[test]
343    fn test_sanitize_for_git() {
344        assert_eq!(sanitize_for_git("Hello World"), "hello-world");
345        assert_eq!(sanitize_for_git("fix_the_bug"), "fix-the-bug");
346        assert_eq!(sanitize_for_git("  spaces  "), "spaces");
347        assert_eq!(sanitize_for_git("multiple---hyphens"), "multiple-hyphens");
348        assert_eq!(sanitize_for_git("special!@#chars"), "specialchars");
349        assert_eq!(sanitize_for_git("MixedCase"), "mixedcase");
350        assert_eq!(sanitize_for_git("123numbers"), "123numbers");
351        assert_eq!(sanitize_for_git("-leading-trailing-"), "leading-trailing");
352    }
353
354    #[test]
355    fn test_extract_keywords_prioritizes_verbs() {
356        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
357
358        let keywords = generator.extract_keywords("Fix the header alignment issue");
359        assert!(keywords.contains(&"fix".to_string()));
360        assert!(keywords.contains(&"header".to_string()));
361    }
362
363    #[test]
364    fn test_extract_keywords_filters_stop_words() {
365        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
366
367        let keywords = generator.extract_keywords("Add a new feature to the system");
368        assert!(!keywords.contains(&"a".to_string()));
369        assert!(!keywords.contains(&"the".to_string()));
370        assert!(!keywords.contains(&"to".to_string()));
371        assert!(keywords.contains(&"add".to_string()));
372    }
373
374    #[test]
375    fn test_extract_keywords_limits_to_three() {
376        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
377
378        let keywords =
379            generator.extract_keywords("Fix header footer sidebar navigation menu content layout");
380        assert!(keywords.len() <= 3);
381    }
382
383    #[test]
384    fn test_generate_produces_valid_name() {
385        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
386
387        let name = generator.generate("Fix the header alignment");
388        assert!(!name.is_empty());
389        // Should contain keywords
390        assert!(name.contains("fix") || name.contains("header"));
391        // Should be valid for git
392        assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
393    }
394
395    #[test]
396    fn test_generate_empty_prompt() {
397        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
398
399        let name = generator.generate("");
400        assert!(name.starts_with("loop-"));
401    }
402
403    #[test]
404    fn test_generate_only_stop_words() {
405        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
406
407        let name = generator.generate("the a an to for of in on");
408        assert!(name.starts_with("loop-"));
409    }
410
411    #[test]
412    fn test_generate_respects_max_length() {
413        let config = LoopNamingConfig {
414            format: "human-readable".to_string(),
415            max_length: 30,
416        };
417        let generator = LoopNameGenerator::new(config);
418
419        let name = generator.generate("Implement the authentication system with OAuth2 support");
420        assert!(name.len() <= 30);
421    }
422
423    #[test]
424    fn test_timestamp_format() {
425        let config = LoopNamingConfig {
426            format: "timestamp".to_string(),
427            max_length: 50,
428        };
429        let generator = LoopNameGenerator::new(config);
430
431        let name = generator.generate("Fix header");
432        assert!(name.starts_with("ralph-"));
433        // Format: ralph-YYYYMMDD-HHMMSS-XXXX
434        assert!(name.len() > 20);
435    }
436
437    #[test]
438    fn test_generate_unique_avoids_collisions() {
439        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
440
441        let mut generated = HashSet::new();
442
443        // First call should succeed
444        let name1 = generator.generate_unique("Fix header", |n| generated.contains(n));
445        generated.insert(name1.clone());
446
447        // This is a bit tricky to test since suffixes are time-based
448        // Just verify it generates a valid name
449        assert!(!name1.is_empty());
450        assert!(name1.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
451    }
452
453    #[test]
454    fn test_generate_unique_falls_back_to_timestamp() {
455        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
456
457        // Always say name exists to force fallback
458        let name = generator.generate_unique("Fix header", |_| true);
459
460        // Should fall back to timestamp format
461        assert!(name.starts_with("ralph-"));
462    }
463
464    #[test]
465    fn test_default_config() {
466        let config = LoopNamingConfig::default();
467        assert_eq!(config.format, "human-readable");
468        assert_eq!(config.max_length, 50);
469    }
470
471    #[test]
472    fn test_generate_memorable() {
473        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
474
475        let name = generator.generate_memorable();
476
477        // Should be adjective-noun format (e.g., "bright-maple")
478        let parts: Vec<&str> = name.split('-').collect();
479        assert_eq!(parts.len(), 2, "Expected adjective-noun format: {}", name);
480
481        // Should be valid for git
482        assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
483    }
484
485    #[test]
486    fn test_generate_memorable_unique() {
487        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
488
489        let mut generated = HashSet::new();
490
491        // First call should succeed
492        let name1 = generator.generate_memorable_unique(|n| generated.contains(n));
493        generated.insert(name1.clone());
494
495        // Verify format
496        let parts: Vec<&str> = name1.split('-').collect();
497        assert_eq!(parts.len(), 2, "Expected adjective-noun format: {}", name1);
498        assert!(name1.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
499    }
500
501    #[test]
502    fn test_generate_memorable_unique_falls_back_to_timestamp() {
503        let generator = LoopNameGenerator::new(LoopNamingConfig::default());
504
505        // Always say name exists to force fallback
506        let name = generator.generate_memorable_unique(|_| true);
507
508        // Should fall back to timestamp format
509        assert!(name.starts_with("ralph-"));
510    }
511}