Skip to main content

flowscope_export/
naming.rs

1use chrono::{DateTime, Utc};
2
3use crate::ExportFormat;
4
5#[derive(Debug, Clone)]
6pub struct ExportNaming {
7    project_name: String,
8    exported_at: DateTime<Utc>,
9}
10
11impl ExportNaming {
12    pub fn new(project_name: impl Into<String>) -> Self {
13        Self::with_exported_at(project_name, Utc::now())
14    }
15
16    pub fn with_exported_at(project_name: impl Into<String>, exported_at: DateTime<Utc>) -> Self {
17        let mut name = project_name.into();
18        if name.trim().is_empty() {
19            name = "lineage".to_string();
20        }
21        Self {
22            project_name: sanitize_project_name(&name),
23            exported_at,
24        }
25    }
26
27    pub fn exported_at(&self) -> DateTime<Utc> {
28        self.exported_at
29    }
30
31    pub fn project_name(&self) -> &str {
32        &self.project_name
33    }
34
35    pub fn filename(&self, format: ExportFormat) -> String {
36        let timestamp = self.exported_at.format("%Y%m%d-%H%M%S");
37        let (suffix, extension) = format_filename_parts(format);
38        format!(
39            "{}-{}-{}.{}",
40            self.project_name, timestamp, suffix, extension
41        )
42    }
43}
44
45fn format_filename_parts(format: ExportFormat) -> (&'static str, &'static str) {
46    match format {
47        ExportFormat::Json { .. } => ("json", "json"),
48        ExportFormat::Mermaid { view } => match view {
49            crate::MermaidView::All => ("mermaid", "md"),
50            crate::MermaidView::Script => ("mermaid-script", "md"),
51            crate::MermaidView::Table => ("mermaid-table", "md"),
52            crate::MermaidView::Column => ("mermaid-column", "md"),
53            crate::MermaidView::Hybrid => ("mermaid-hybrid", "md"),
54        },
55        ExportFormat::Html => ("report", "html"),
56        ExportFormat::Sql { .. } => ("duckdb", "sql"),
57        ExportFormat::CsvBundle => ("csv", "zip"),
58        ExportFormat::Xlsx => ("xlsx", "xlsx"),
59        ExportFormat::DuckDb => ("duckdb", "duckdb"),
60        ExportFormat::Png => ("png", "png"),
61    }
62}
63
64fn sanitize_project_name(name: &str) -> String {
65    let mut cleaned = String::new();
66    let mut last_dash = false;
67
68    for ch in name.trim().chars() {
69        let normalized = ch.to_ascii_lowercase();
70        if normalized.is_ascii_alphanumeric() {
71            cleaned.push(normalized);
72            last_dash = false;
73        } else if matches!(normalized, '-' | '_' | ' ') && !last_dash {
74            cleaned.push('-');
75            last_dash = true;
76        }
77    }
78
79    let cleaned = cleaned.trim_matches('-').to_string();
80    if cleaned.is_empty() {
81        "lineage".to_string()
82    } else {
83        cleaned
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use chrono::{TimeZone, Utc};
90
91    use super::*;
92
93    #[test]
94    fn test_filename_generation() {
95        let timestamp = Utc.with_ymd_and_hms(2026, 1, 18, 12, 30, 5).unwrap();
96        let naming = ExportNaming::with_exported_at("FlowScope Demo", timestamp);
97        let name = naming.filename(ExportFormat::Json { compact: false });
98        assert_eq!(name, "flowscope-demo-20260118-123005-json.json");
99    }
100
101    #[test]
102    fn test_project_name_sanitization() {
103        let naming = ExportNaming::with_exported_at("  !!! ", Utc::now());
104        assert_eq!(naming.project_name(), "lineage");
105    }
106}