Skip to main content

rec/import/
detect.rs

1//! Format detection and session name generation for imports.
2
3use crate::error::{RecError, Result};
4use crate::models::session::validate_session_name;
5use std::fmt;
6use std::path::Path;
7
8/// Supported import file formats.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ImportFormat {
11    BashScript,
12    BashHistory,
13    ZshHistory,
14    FishHistory,
15}
16
17impl fmt::Display for ImportFormat {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        match self {
20            ImportFormat::BashScript => write!(f, "bash-script"),
21            ImportFormat::BashHistory => write!(f, "bash-history"),
22            ImportFormat::ZshHistory => write!(f, "zsh-history"),
23            ImportFormat::FishHistory => write!(f, "fish-history"),
24        }
25    }
26}
27
28/// Detect the import format from file extension and content.
29///
30/// Detection order:
31/// 1. `.cast` extension → error (asciinema not supported)
32/// 2. `.sh` or `.bash` extension → `BashScript`
33/// 3. Shebang lines (`#!/bin/bash`, `#!/bin/sh`, `#!/usr/bin/env bash`) → `BashScript`
34/// 4. Lines matching `: TIMESTAMP:DURATION;CMD` → `ZshHistory`
35/// 5. Lines starting with `- cmd: ` → `FishHistory`
36/// 6. Default fallback → `BashHistory`
37///
38/// # Errors
39///
40/// Returns an error if the file is an unsupported format (e.g., `.cast` files).
41///
42/// # Panics
43///
44/// Panics if the internal regex pattern is invalid (should never happen).
45pub fn detect_format(path: &str, content: &str) -> Result<ImportFormat> {
46    use std::sync::OnceLock;
47    static ZSH_RE: OnceLock<regex::Regex> = OnceLock::new();
48    let path = Path::new(path);
49
50    // Check extension first
51    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
52        match ext {
53            "cast" => {
54                return Err(RecError::InvalidSession(
55                    "Asciinema .cast files are not currently supported. \
56                     Use bash scripts or shell history files instead."
57                        .to_string(),
58                ));
59            }
60            "sh" | "bash" => return Ok(ImportFormat::BashScript),
61            _ => {}
62        }
63    }
64
65    // Sniff content (first 10 lines)
66    let first_lines: Vec<&str> = content.lines().take(10).collect();
67
68    // Check for shebang
69    if let Some(first) = first_lines.first() {
70        if first.starts_with("#!") && (first.contains("bash") || first.contains("/sh")) {
71            return Ok(ImportFormat::BashScript);
72        }
73    }
74
75    // Check for zsh extended history format
76    let zsh_re = ZSH_RE.get_or_init(|| regex::Regex::new(r"^: \d+:\d+;").unwrap());
77    for line in &first_lines {
78        if zsh_re.is_match(line) {
79            return Ok(ImportFormat::ZshHistory);
80        }
81    }
82
83    // Check for fish history format
84    for line in &first_lines {
85        if line.starts_with("- cmd: ") {
86            return Ok(ImportFormat::FishHistory);
87        }
88    }
89
90    // Default: bash history
91    Ok(ImportFormat::BashHistory)
92}
93
94/// Generate a session name from a file path.
95///
96/// Rules:
97/// - Uses the filename (not the full path)
98/// - Strips leading dot (hidden files like `.bash_history` → `bash_history`)
99/// - Replaces non-alphanumeric characters (except `-` and `_`) with `-`
100/// - Collapses consecutive hyphens
101/// - Trims leading/trailing hyphens
102/// - Lowercases the result
103/// - Falls back to `imported-session` if result is empty or invalid
104#[must_use]
105pub fn session_name_from_path(path: &Path) -> String {
106    let filename = path
107        .file_name()
108        .and_then(|n| n.to_str())
109        .unwrap_or("imported");
110
111    // Strip leading dot
112    let filename = filename.strip_prefix('.').unwrap_or(filename);
113
114    // Replace non-alphanumeric (except - and _) with hyphen
115    let sanitized: String = filename
116        .chars()
117        .map(|c| {
118            if c.is_alphanumeric() || c == '-' || c == '_' {
119                c
120            } else {
121                '-'
122            }
123        })
124        .collect();
125
126    // Collapse consecutive hyphens
127    let mut result = String::new();
128    let mut prev_hyphen = false;
129    for c in sanitized.chars() {
130        if c == '-' {
131            if !prev_hyphen {
132                result.push(c);
133            }
134            prev_hyphen = true;
135        } else {
136            result.push(c);
137            prev_hyphen = false;
138        }
139    }
140
141    // Trim leading/trailing hyphens and lowercase
142    let result = result.trim_matches('-').to_lowercase();
143
144    // Validate; fall back if empty or invalid
145    if result.is_empty() || validate_session_name(&result).is_err() {
146        return "imported-session".to_string();
147    }
148
149    result
150}