1use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct LoopNamingConfig {
16 #[serde(default = "default_format")]
18 pub format: String,
19
20 #[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
42pub struct LoopNameGenerator {
44 config: LoopNamingConfig,
45}
46
47impl LoopNameGenerator {
48 pub fn new(config: LoopNamingConfig) -> Self {
50 Self { config }
51 }
52
53 pub fn from_config(config: &LoopNamingConfig) -> Self {
55 Self::new(config.clone())
56 }
57
58 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 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 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 generate_timestamp_id()
109 }
110
111 pub fn generate_memorable(&self) -> String {
115 self.generate_suffix()
116 }
117
118 pub fn generate_memorable_unique(&self, exists: impl Fn(&str) -> bool) -> String {
123 for _ in 0..10 {
125 let name = self.generate_suffix();
126 if !exists(&name) {
127 return name;
128 }
129 std::thread::sleep(std::time::Duration::from_micros(1));
131 }
132
133 generate_timestamp_id()
135 }
136
137 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 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 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 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 fn generate_suffix(&self) -> String {
177 use std::time::SystemTime;
178
179 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 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 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 result.is_empty() {
215 name.chars().take(self.config.max_length).collect()
216 } else {
217 result
218 }
219 }
220}
221
222fn 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 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
237pub 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 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 result.trim_matches('-').to_string()
265}
266
267const 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
307const 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
319const 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
328const 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 assert!(name.contains("fix") || name.contains("header"));
391 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 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 let name1 = generator.generate_unique("Fix header", |n| generated.contains(n));
445 generated.insert(name1.clone());
446
447 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 let name = generator.generate_unique("Fix header", |_| true);
459
460 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 let parts: Vec<&str> = name.split('-').collect();
479 assert_eq!(parts.len(), 2, "Expected adjective-noun format: {}", name);
480
481 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 let name1 = generator.generate_memorable_unique(|n| generated.contains(n));
493 generated.insert(name1.clone());
494
495 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 let name = generator.generate_memorable_unique(|_| true);
507
508 assert!(name.starts_with("ralph-"));
510 }
511}