use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopNamingConfig {
#[serde(default = "default_format")]
pub format: String,
#[serde(default = "default_max_length")]
pub max_length: usize,
}
fn default_format() -> String {
"human-readable".to_string()
}
fn default_max_length() -> usize {
50
}
impl Default for LoopNamingConfig {
fn default() -> Self {
Self {
format: default_format(),
max_length: default_max_length(),
}
}
}
pub struct LoopNameGenerator {
config: LoopNamingConfig,
}
impl LoopNameGenerator {
pub fn new(config: LoopNamingConfig) -> Self {
Self { config }
}
pub fn from_config(config: &LoopNamingConfig) -> Self {
Self::new(config.clone())
}
pub fn generate(&self, prompt: &str) -> String {
if self.config.format == "timestamp" {
return generate_timestamp_id();
}
let keywords = self.extract_keywords(prompt);
let suffix = self.generate_suffix();
let keyword_part = if keywords.is_empty() {
"loop".to_string()
} else {
keywords.join("-")
};
let name = format!("{}-{}", keyword_part, suffix);
self.truncate_to_max_length(&name)
}
pub fn generate_unique(&self, prompt: &str, exists: impl Fn(&str) -> bool) -> String {
if self.config.format == "timestamp" {
return generate_timestamp_id();
}
let keywords = self.extract_keywords(prompt);
let keyword_part = if keywords.is_empty() {
"loop".to_string()
} else {
keywords.join("-")
};
for _ in 0..3 {
let suffix = self.generate_suffix();
let name = format!("{}-{}", keyword_part, suffix);
let name = self.truncate_to_max_length(&name);
if !exists(&name) {
return name;
}
}
generate_timestamp_id()
}
pub fn generate_memorable(&self) -> String {
self.generate_suffix()
}
pub fn generate_memorable_unique(&self, exists: impl Fn(&str) -> bool) -> String {
for _ in 0..10 {
let name = self.generate_suffix();
if !exists(&name) {
return name;
}
std::thread::sleep(std::time::Duration::from_micros(1));
}
generate_timestamp_id()
}
fn extract_keywords(&self, prompt: &str) -> Vec<String> {
let words: Vec<&str> = prompt
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.collect();
let mut keywords = Vec::new();
for word in &words {
let lower = word.to_lowercase();
if ACTION_VERBS.contains(&lower.as_str()) && keywords.len() < 3 {
keywords.push(lower);
}
}
for word in &words {
let lower = word.to_lowercase();
if !STOP_WORDS.contains(&lower.as_str())
&& !keywords.contains(&lower)
&& keywords.len() < 3
&& lower.len() >= 2
{
keywords.push(lower);
}
}
keywords
.into_iter()
.map(|w| sanitize_for_git(&w))
.filter(|w| !w.is_empty())
.take(3)
.collect()
}
fn generate_suffix(&self) -> String {
use std::time::SystemTime;
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let adj_idx = (nanos % ADJECTIVES.len() as u128) as usize;
let noun_idx = ((nanos / 1000) % NOUNS.len() as u128) as usize;
format!("{}-{}", ADJECTIVES[adj_idx], NOUNS[noun_idx])
}
fn truncate_to_max_length(&self, name: &str) -> String {
if name.len() <= self.config.max_length {
return name.to_string();
}
let mut result = String::new();
for part in name.split('-') {
let candidate = if result.is_empty() {
part.to_string()
} else {
format!("{}-{}", result, part)
};
if candidate.len() <= self.config.max_length {
result = candidate;
} else {
break;
}
}
if result.is_empty() {
name.chars().take(self.config.max_length).collect()
} else {
result
}
}
}
fn generate_timestamp_id() -> String {
use std::time::SystemTime;
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let random_suffix: u16 = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| (d.as_nanos() & 0xFFFF) as u16)
.unwrap_or(0);
format!("ralph-{}-{:04x}", timestamp, random_suffix)
}
pub fn sanitize_for_git(text: &str) -> String {
let result: String = text
.to_lowercase()
.replace([' ', '_'], "-")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
let mut prev_hyphen = false;
let result: String = result
.chars()
.filter(|c| {
if *c == '-' {
if prev_hyphen {
return false;
}
prev_hyphen = true;
} else {
prev_hyphen = false;
}
true
})
.collect();
result.trim_matches('-').to_string()
}
const ACTION_VERBS: &[&str] = &[
"add",
"fix",
"update",
"remove",
"delete",
"implement",
"create",
"refactor",
"move",
"rename",
"change",
"modify",
"improve",
"optimize",
"clean",
"rewrite",
"replace",
"merge",
"split",
"extract",
"inline",
"simplify",
"consolidate",
"migrate",
"upgrade",
"downgrade",
"enable",
"disable",
"configure",
"setup",
"init",
"build",
"test",
"debug",
"deploy",
"release",
];
const STOP_WORDS: &[&str] = &[
"a", "an", "the", "to", "for", "of", "in", "on", "at", "by", "with", "from", "as", "is", "are",
"was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will",
"would", "could", "should", "may", "might", "must", "shall", "can", "need", "it", "its",
"this", "that", "these", "those", "i", "you", "he", "she", "we", "they", "me", "him", "her",
"us", "them", "my", "your", "his", "our", "their", "what", "which", "who", "whom", "when",
"where", "why", "how", "all", "each", "every", "both", "few", "more", "most", "other", "some",
"such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "just", "also",
"and", "but", "or", "if", "then", "else", "please", "make", "sure", "get", "let", "put",
];
const ADJECTIVES: &[&str] = &[
"swift", "clever", "bright", "calm", "bold", "keen", "quick", "brave", "fair", "wise", "warm",
"cool", "crisp", "fresh", "clear", "sharp", "smooth", "steady", "gentle", "agile", "nimble",
"lively", "merry", "jolly", "happy", "lucky", "eager", "ready", "able", "noble", "grand",
"prime", "pure", "true", "neat", "tidy", "clean", "sleek", "slick", "smart", "savvy", "snappy",
"zippy", "zesty", "peppy", "perky", "chipper", "chirpy", "cheery", "sunny", "breezy",
];
const NOUNS: &[&str] = &[
"peacock", "badger", "falcon", "otter", "robin", "maple", "brook", "cedar", "willow", "finch",
"heron", "aspen", "birch", "crane", "egret", "lark", "sparrow", "raven", "hawk", "owl", "fox",
"deer", "wolf", "bear", "lion", "tiger", "eagle", "dove", "swan", "gull", "wren", "jay",
"pine", "oak", "elm", "fern", "moss", "reed", "sage", "mint", "rose", "lily", "iris", "daisy",
"tulip", "orchid", "lotus", "ivy", "palm", "cork", "teak",
];
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn test_sanitize_for_git() {
assert_eq!(sanitize_for_git("Hello World"), "hello-world");
assert_eq!(sanitize_for_git("fix_the_bug"), "fix-the-bug");
assert_eq!(sanitize_for_git(" spaces "), "spaces");
assert_eq!(sanitize_for_git("multiple---hyphens"), "multiple-hyphens");
assert_eq!(sanitize_for_git("special!@#chars"), "specialchars");
assert_eq!(sanitize_for_git("MixedCase"), "mixedcase");
assert_eq!(sanitize_for_git("123numbers"), "123numbers");
assert_eq!(sanitize_for_git("-leading-trailing-"), "leading-trailing");
}
#[test]
fn test_extract_keywords_prioritizes_verbs() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let keywords = generator.extract_keywords("Fix the header alignment issue");
assert!(keywords.contains(&"fix".to_string()));
assert!(keywords.contains(&"header".to_string()));
}
#[test]
fn test_extract_keywords_filters_stop_words() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let keywords = generator.extract_keywords("Add a new feature to the system");
assert!(!keywords.contains(&"a".to_string()));
assert!(!keywords.contains(&"the".to_string()));
assert!(!keywords.contains(&"to".to_string()));
assert!(keywords.contains(&"add".to_string()));
}
#[test]
fn test_extract_keywords_limits_to_three() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let keywords =
generator.extract_keywords("Fix header footer sidebar navigation menu content layout");
assert!(keywords.len() <= 3);
}
#[test]
fn test_generate_produces_valid_name() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let name = generator.generate("Fix the header alignment");
assert!(!name.is_empty());
assert!(name.contains("fix") || name.contains("header"));
assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
}
#[test]
fn test_generate_empty_prompt() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let name = generator.generate("");
assert!(name.starts_with("loop-"));
}
#[test]
fn test_generate_only_stop_words() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let name = generator.generate("the a an to for of in on");
assert!(name.starts_with("loop-"));
}
#[test]
fn test_generate_respects_max_length() {
let config = LoopNamingConfig {
format: "human-readable".to_string(),
max_length: 30,
};
let generator = LoopNameGenerator::new(config);
let name = generator.generate("Implement the authentication system with OAuth2 support");
assert!(name.len() <= 30);
}
#[test]
fn test_timestamp_format() {
let config = LoopNamingConfig {
format: "timestamp".to_string(),
max_length: 50,
};
let generator = LoopNameGenerator::new(config);
let name = generator.generate("Fix header");
assert!(name.starts_with("ralph-"));
assert!(name.len() > 20);
}
#[test]
fn test_generate_unique_avoids_collisions() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let mut generated = HashSet::new();
let name1 = generator.generate_unique("Fix header", |n| generated.contains(n));
generated.insert(name1.clone());
assert!(!name1.is_empty());
assert!(name1.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
}
#[test]
fn test_generate_unique_falls_back_to_timestamp() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let name = generator.generate_unique("Fix header", |_| true);
assert!(name.starts_with("ralph-"));
}
#[test]
fn test_default_config() {
let config = LoopNamingConfig::default();
assert_eq!(config.format, "human-readable");
assert_eq!(config.max_length, 50);
}
#[test]
fn test_generate_memorable() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let name = generator.generate_memorable();
let parts: Vec<&str> = name.split('-').collect();
assert_eq!(parts.len(), 2, "Expected adjective-noun format: {}", name);
assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
}
#[test]
fn test_generate_memorable_unique() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let mut generated = HashSet::new();
let name1 = generator.generate_memorable_unique(|n| generated.contains(n));
generated.insert(name1.clone());
let parts: Vec<&str> = name1.split('-').collect();
assert_eq!(parts.len(), 2, "Expected adjective-noun format: {}", name1);
assert!(name1.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
}
#[test]
fn test_generate_memorable_unique_falls_back_to_timestamp() {
let generator = LoopNameGenerator::new(LoopNamingConfig::default());
let name = generator.generate_memorable_unique(|_| true);
assert!(name.starts_with("ralph-"));
}
}