Skip to main content

luft_core/
run_dir.rs

1//! Human-readable run directory naming: `{slug}_{unix_timestamp}`.
2//!
3//! The run's internal identifier stays a UUID v7 (stored in checkpoint.json
4//! and event payloads). The on-disk directory name is derived from the
5//! workflow name + timestamp for easy scanning and CLI tab-completion.
6
7use std::path::Path;
8
9/// Convert an arbitrary string into a filesystem-safe slug.
10///
11/// - lowercased
12/// - non-alphanumeric runs collapsed to `-`
13/// - trimmed of leading/trailing `-`
14/// - capped at 50 chars
15pub 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
60/// Derive a slug from the workflow source.
61///
62/// - `--workflow path` → filename without extension
63/// - `--nl "text"`     → slugified text (first 6 words)
64/// - inline script     → `luft-workflow`
65pub 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
78/// Compose a directory name: `{slug}_{timestamp}`.
79pub fn compose(slug: &str, unix_ts: u64) -> String {
80    format!("{slug}_{unix_ts}")
81}
82
83/// Ensure the directory name doesn't collide with an existing directory.
84/// Appends `_2`, `_3`, … as needed.
85pub 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}