1use std::path::Path;
8
9pub fn slugify(s: &str) -> String {
16 let slug: String = s
17 .trim()
18 .to_lowercase()
19 .chars()
20 .map(|c| if c.is_alphanumeric() { c } else { '-' })
21 .collect::<String>()
22 .lines()
23 .collect::<Vec<&str>>()
24 .join("-");
25 let collapsed = collapse_dashes(&slug);
26 let trimmed = collapsed.trim_matches('-');
27 let capped = if trimmed.len() > 50 {
28 let mut end = 50;
29 while !trimmed.is_char_boundary(end) {
30 end -= 1;
31 }
32 &trimmed[..end]
33 } else {
34 trimmed
35 };
36 if capped.is_empty() {
37 "run".to_string()
38 } else {
39 capped.to_string()
40 }
41}
42
43fn collapse_dashes(s: &str) -> String {
44 let mut result = String::with_capacity(s.len());
45 let mut prev_dash = false;
46 for c in s.chars() {
47 if c == '-' {
48 if !prev_dash {
49 result.push(c);
50 }
51 prev_dash = true;
52 } else {
53 result.push(c);
54 prev_dash = false;
55 }
56 }
57 result
58}
59
60pub fn derive_slug(workflow_path: Option<&Path>, nl: Option<&str>) -> String {
66 if let Some(path) = workflow_path {
67 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
68 return slugify(stem);
69 }
70 }
71 if let Some(text) = nl {
72 let words: Vec<&str> = text.split_whitespace().take(6).collect();
73 return slugify(&words.join(" "));
74 }
75 "luft-workflow".to_string()
76}
77
78pub fn compose(slug: &str, unix_ts: u64) -> String {
80 format!("{slug}_{unix_ts}")
81}
82
83pub fn ensure_unique(base_dir: &Path, dir_name: &str) -> String {
86 if !base_dir.join(dir_name).exists() {
87 return dir_name.to_string();
88 }
89 for n in 2..u64::MAX {
90 let candidate = format!("{dir_name}_{n}");
91 if !base_dir.join(&candidate).exists() {
92 return candidate;
93 }
94 }
95 unreachable!("exhausted suffix space");
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn slugify_basic() {
104 assert_eq!(slugify("Hello World"), "hello-world");
105 assert_eq!(slugify("deep research v2!"), "deep-research-v2");
106 assert_eq!(slugify(" trim "), "trim");
107 }
108
109 #[test]
110 fn slugify_collapse() {
111 assert_eq!(slugify("a---b"), "a-b");
112 assert_eq!(slugify("a b"), "a-b");
113 }
114
115 #[test]
116 fn slugify_empty_fallback() {
117 assert_eq!(slugify("!!!"), "run");
118 assert_eq!(slugify(""), "run");
119 }
120
121 #[test]
122 fn slugify_long() {
123 let long = "a".repeat(100);
124 let result = slugify(&long);
125 assert_eq!(result.len(), 50);
126 }
127
128 #[test]
129 fn derive_from_workflow_path() {
130 assert_eq!(
131 derive_slug(Some(Path::new("scripts/clean.lua")), None),
132 "clean"
133 );
134 assert_eq!(
135 derive_slug(Some(Path::new("examples/deep-research.lua")), None),
136 "deep-research"
137 );
138 }
139
140 #[test]
141 fn derive_from_nl() {
142 let slug = derive_slug(None, Some("research AI trends in 2025"));
143 assert_eq!(slug, "research-ai-trends-in-2025");
144 }
145
146 #[test]
147 fn derive_fallback() {
148 assert_eq!(derive_slug(None, None), "luft-workflow");
149 }
150
151 #[test]
152 fn compose_format() {
153 assert_eq!(compose("clean", 1781980050), "clean_1781980050");
154 }
155
156 #[test]
157 fn ensure_unique_no_collision() {
158 let dir = tempfile::tempdir().unwrap();
159 assert_eq!(ensure_unique(dir.path(), "clean_123"), "clean_123");
160 }
161
162 #[test]
163 fn ensure_unique_with_collision() {
164 let dir = tempfile::tempdir().unwrap();
165 std::fs::create_dir_all(dir.path().join("clean_123")).unwrap();
166 assert_eq!(ensure_unique(dir.path(), "clean_123"), "clean_123_2");
167 }
168}