Skip to main content

subx_cli/core/
language.rs

1//! Language detection module.
2//!
3//! Provides utilities to detect language codes from file paths and names,
4//! using directory names, filename patterns, and file extensions.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use subx_cli::core::language::LanguageDetector;
10//! use std::path::Path;
11//!
12//! let detector = LanguageDetector::new();
13//! let code = detector.get_primary_language(Path::new("subtitle.sc.srt")).unwrap();
14//! assert_eq!(code, "sc");
15//! ```
16use regex::Regex;
17use std::collections::HashMap;
18use std::path::Path;
19
20/// Source of detected language information.
21#[derive(Debug, Clone, PartialEq)]
22pub enum LanguageSource {
23    /// Derived from a parent directory name.
24    Directory,
25    /// Derived from the file name pattern.
26    Filename,
27    /// Derived from the file extension or naming convention.
28    Extension,
29}
30impl Default for LanguageDetector {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36/// Detected language information, including code, source, and confidence.
37#[derive(Debug, Clone)]
38pub struct LanguageInfo {
39    /// Standardized language code (e.g., "tc", "sc", "en").
40    pub code: String,
41    /// Origin of the language detection result.
42    pub source: LanguageSource,
43    /// Confidence score of the detection (0.0 to 1.0).
44    pub confidence: f32,
45}
46
47/// Detector for identifying language codes from filesystem paths.
48pub struct LanguageDetector {
49    language_codes: HashMap<String, String>,
50    directory_patterns: Vec<String>,
51    filename_patterns: Vec<Regex>,
52}
53
54impl LanguageDetector {
55    /// Create a new `LanguageDetector` with default language mappings and patterns.
56    ///
57    /// Initializes internal dictionaries and regex patterns for detection.
58    pub fn new() -> Self {
59        //! Do not translate these language codes to English rustdoc!!!
60        let mut language_codes = HashMap::new();
61        // Traditional Chinese
62        language_codes.insert("tc".to_string(), "tc".to_string());
63        language_codes.insert("繁中".to_string(), "tc".to_string()); // Traditional Chinese (zh-Hant)
64        language_codes.insert("繁體".to_string(), "tc".to_string()); // Traditional Chinese (zh-Hant)
65        language_codes.insert("cht".to_string(), "tc".to_string());
66        // Simplified Chinese
67        language_codes.insert("sc".to_string(), "sc".to_string());
68        language_codes.insert("简中".to_string(), "sc".to_string()); // Simplified Chinese (zh-Hans)
69        language_codes.insert("简体".to_string(), "sc".to_string()); // Simplified Chinese (zh-Hans)
70        language_codes.insert("chs".to_string(), "sc".to_string());
71        // Hyphenated/underscored regional aliases the AI may emit when
72        // asked for a "language" hint (BCP 47 short tags and common
73        // English labels). Lower-cased before lookup so these only need
74        // to be the canonical lower form.
75        language_codes.insert("traditional-chinese".to_string(), "tc".to_string());
76        language_codes.insert("traditional_chinese".to_string(), "tc".to_string());
77        language_codes.insert("zh-hant".to_string(), "tc".to_string());
78        language_codes.insert("zh_hant".to_string(), "tc".to_string());
79        language_codes.insert("simplified-chinese".to_string(), "sc".to_string());
80        language_codes.insert("simplified_chinese".to_string(), "sc".to_string());
81        language_codes.insert("zh-hans".to_string(), "sc".to_string());
82        language_codes.insert("zh_hans".to_string(), "sc".to_string());
83        // English
84        language_codes.insert("en".to_string(), "en".to_string());
85        language_codes.insert("英文".to_string(), "en".to_string()); // English
86        language_codes.insert("english".to_string(), "en".to_string());
87        language_codes.insert("eng".to_string(), "en".to_string());
88        // Common ISO 639-2 / ISO 639-3 synonyms for non-Chinese languages.
89        language_codes.insert("ja".to_string(), "ja".to_string());
90        language_codes.insert("jpn".to_string(), "ja".to_string());
91        language_codes.insert("japanese".to_string(), "ja".to_string());
92        language_codes.insert("ko".to_string(), "ko".to_string());
93        language_codes.insert("kor".to_string(), "ko".to_string());
94        language_codes.insert("korean".to_string(), "ko".to_string());
95        language_codes.insert("fr".to_string(), "fr".to_string());
96        language_codes.insert("fra".to_string(), "fr".to_string());
97        language_codes.insert("fre".to_string(), "fr".to_string());
98        language_codes.insert("french".to_string(), "fr".to_string());
99        language_codes.insert("de".to_string(), "de".to_string());
100        language_codes.insert("deu".to_string(), "de".to_string());
101        language_codes.insert("ger".to_string(), "de".to_string());
102        language_codes.insert("german".to_string(), "de".to_string());
103        language_codes.insert("es".to_string(), "es".to_string());
104        language_codes.insert("spa".to_string(), "es".to_string());
105        language_codes.insert("spanish".to_string(), "es".to_string());
106        language_codes.insert("pt".to_string(), "pt".to_string());
107        language_codes.insert("por".to_string(), "pt".to_string());
108        language_codes.insert("portuguese".to_string(), "pt".to_string());
109        language_codes.insert("ru".to_string(), "ru".to_string());
110        language_codes.insert("rus".to_string(), "ru".to_string());
111        language_codes.insert("russian".to_string(), "ru".to_string());
112
113        let filename_patterns = vec![
114            Regex::new(r"\.([a-z]{2,3})\.").unwrap(), // .tc., .sc., .en.
115            Regex::new(r"_([a-z]{2,3})\.").unwrap(),  // _tc., _sc., _en.
116            Regex::new(r"-([a-z]{2,3})\.").unwrap(),  // -tc., -sc., -en.
117        ];
118
119        Self {
120            language_codes,
121            directory_patterns: vec!["tc".to_string(), "sc".to_string(), "en".to_string()],
122            filename_patterns,
123        }
124    }
125    /// Detect a single language information from the given path.
126    ///
127    /// # Behavior
128    ///
129    /// Attempts detection by directory name first, then by filename pattern.
130    pub fn detect_from_path(&self, path: &Path) -> Option<LanguageInfo> {
131        if let Some(lang) = self.detect_from_directory(path) {
132            return Some(lang);
133        }
134        if let Some(lang) = self.detect_from_filename(path) {
135            return Some(lang);
136        }
137        None
138    }
139
140    /// Normalize a raw language label (typically supplied by an AI model)
141    /// into the project's canonical short code.
142    ///
143    /// # Behavior
144    ///
145    /// - The input is lower-cased before lookup.
146    /// - Known synonyms are collapsed via the internal `language_codes` map
147    ///   (e.g. `"english"`/`"eng"`/`"EN"` → `"en"`,
148    ///   `"cht"`/`"繁中"`/`"traditional-chinese"` is matched against the same
149    ///   map; for the latter, only entries the detector knows are mapped).
150    /// - Unknown but otherwise valid `[A-Za-z0-9_-]{1,16}` tokens are passed
151    ///   through verbatim after lower-casing.
152    /// - Returns `None` for empty input.
153    ///
154    /// # Arguments
155    ///
156    /// * `raw` - The raw language label to normalize.
157    ///
158    /// # Returns
159    ///
160    /// `Some(code)` with the canonical or pass-through code, or `None` when
161    /// the input is empty.
162    pub fn normalize(&self, raw: &str) -> Option<String> {
163        let trimmed = raw.trim();
164        if trimmed.is_empty() {
165            return None;
166        }
167        let lower = trimmed.to_lowercase();
168        if let Some(code) = self.language_codes.get(&lower) {
169            return Some(code.clone());
170        }
171        // Also probe the original casing in case the caller passed a non-ASCII
172        // synonym (e.g. `"繁中"`) whose lower-case form is identical.
173        if let Some(code) = self.language_codes.get(trimmed) {
174            return Some(code.clone());
175        }
176        Some(lower)
177    }
178
179    /// Return the primary detected language code for the provided path.
180    ///
181    /// # Returns
182    ///
183    /// `Some(code)` if detected, otherwise `None`.
184    pub fn get_primary_language(&self, path: &Path) -> Option<String> {
185        self.detect_all_languages(path)
186            .into_iter()
187            .next()
188            .map(|lang| lang.code)
189    }
190
191    /// Collect all potential language detections from the path.
192    ///
193    /// Sorts results by confidence and removes duplicates by code.
194    pub fn detect_all_languages(&self, path: &Path) -> Vec<LanguageInfo> {
195        let mut langs = Vec::new();
196        if let Some(dir_lang) = self.detect_from_directory(path) {
197            langs.push(dir_lang);
198        }
199        if let Some(file_lang) = self.detect_from_filename(path) {
200            langs.push(file_lang);
201        }
202        langs.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap());
203        langs.dedup_by(|a, b| a.code == b.code);
204        langs
205    }
206
207    fn detect_from_directory(&self, path: &Path) -> Option<LanguageInfo> {
208        for comp in path.components() {
209            if let Some(s) = comp.as_os_str().to_str() {
210                let key = s.to_lowercase();
211                if let Some(code) = self.language_codes.get(&key) {
212                    return Some(LanguageInfo {
213                        code: code.clone(),
214                        source: LanguageSource::Directory,
215                        confidence: 0.9,
216                    });
217                }
218            }
219        }
220        None
221    }
222
223    fn detect_from_filename(&self, path: &Path) -> Option<LanguageInfo> {
224        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
225            for re in &self.filename_patterns {
226                if let Some(cap) = re.captures(name) {
227                    if let Some(m) = cap.get(1) {
228                        if let Some(code) = self.language_codes.get(m.as_str()) {
229                            return Some(LanguageInfo {
230                                code: code.clone(),
231                                source: LanguageSource::Filename,
232                                confidence: 0.8,
233                            });
234                        }
235                    }
236                }
237            }
238        }
239        None
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use std::path::Path;
247
248    #[test]
249    fn test_directory_language_detection() {
250        let det = LanguageDetector::new();
251        let p = Path::new("tc/subtitle.srt");
252        let lang = det.get_primary_language(p).unwrap();
253        assert_eq!(lang, "tc");
254    }
255
256    #[test]
257    fn test_filename_language_detection() {
258        let det = LanguageDetector::new();
259        let p = Path::new("subtitle.sc.ass");
260        let lang = det.get_primary_language(p).unwrap();
261        assert_eq!(lang, "sc");
262    }
263
264    #[test]
265    fn test_no_language_detection() {
266        let det = LanguageDetector::new();
267        let p = Path::new("subtitle.ass");
268        assert!(det.get_primary_language(p).is_none());
269    }
270}