Skip to main content

es_fluent_cli/utils/
ftl.rs

1//! Shared FTL file operations for CLI commands.
2
3use crate::ftl::{extract_message_keys, parse_ftl_file};
4use anyhow::Result;
5use fluent_syntax::ast;
6use std::collections::HashSet;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Build the path to the main FTL file for a crate in a locale.
11pub fn main_ftl_path(assets_dir: &Path, locale: &str, crate_name: &str) -> PathBuf {
12    assets_dir.join(locale).join(format!("{}.ftl", crate_name))
13}
14
15/// Build a path to the FTL output directory for a locale.
16pub fn locale_output_dir(assets_dir: &Path, locale: &str) -> PathBuf {
17    assets_dir.join(locale)
18}
19
20/// Discover all FTL files for a given locale and crate, including main and namespaced files.
21pub fn discover_ftl_files(
22    assets_dir: &Path,
23    locale: &str,
24    crate_name: &str,
25) -> Result<Vec<FtlFileInfo>> {
26    let mut files = Vec::new();
27    let locale_dir = assets_dir.join(locale);
28
29    // Check main FTL file
30    let main_file = locale_dir.join(format!("{}.ftl", crate_name));
31    if main_file.exists() {
32        files.push(FtlFileInfo::new(
33            main_file.clone(),
34            PathBuf::from(format!("{}.ftl", crate_name)),
35        ));
36    }
37
38    // Discover namespaced FTL files in subdirectories
39    let crate_subdir = locale_dir.join(crate_name);
40    if crate_subdir.exists() && crate_subdir.is_dir() {
41        discover_ftl_files_recursive(&crate_subdir, &locale_dir, &mut files)?;
42    }
43
44    Ok(files)
45}
46
47/// Recursively discover FTL files in subdirectories.
48fn discover_ftl_files_recursive(
49    dir: &Path,
50    base_dir: &Path,
51    files: &mut Vec<FtlFileInfo>,
52) -> Result<()> {
53    for entry in fs::read_dir(dir)? {
54        let entry = entry?;
55        let path = entry.path();
56
57        if path.is_dir() {
58            // Recurse into subdirectories
59            discover_ftl_files_recursive(&path, base_dir, files)?;
60        } else if path.extension().is_some_and(|e| e == "ftl") {
61            // Calculate relative path from base_dir
62            let relative_path = path.strip_prefix(base_dir).map_err(|_| {
63                anyhow::anyhow!("Failed to calculate relative path for {}", path.display())
64            })?;
65
66            files.push(FtlFileInfo::new(path.clone(), relative_path.to_path_buf()));
67        }
68    }
69
70    Ok(())
71}
72
73/// Information about a discovered FTL file.
74#[derive(Clone, Debug)]
75pub struct FtlFileInfo {
76    /// Absolute path to FTL file
77    pub abs_path: PathBuf,
78    /// Relative path from locale directory (e.g., "crate_name.ftl" or "crate_name/ui.ftl")
79    pub relative_path: PathBuf,
80}
81
82impl FtlFileInfo {
83    /// Create a new FtlFileInfo with absolute and relative paths
84    pub fn new(abs_path: PathBuf, relative_path: PathBuf) -> Self {
85        Self {
86            abs_path,
87            relative_path,
88        }
89    }
90}
91
92/// A fully loaded FTL file with content and metadata.
93#[derive(Clone, Debug)]
94pub struct LoadedFtlFile {
95    /// Absolute path to FTL file
96    pub abs_path: PathBuf,
97    /// Relative path from locale directory
98    pub relative_path: PathBuf,
99    /// The parsed resource
100    pub resource: ast::Resource<String>,
101    /// Extracted message keys
102    pub keys: HashSet<String>,
103}
104
105/// Load and parse FTL files, returning a list of loaded file info.
106pub fn load_ftl_files(files: Vec<FtlFileInfo>) -> Result<Vec<LoadedFtlFile>> {
107    let mut loaded_files = Vec::new();
108
109    for file_info in files {
110        if file_info.abs_path.exists() {
111            let resource = parse_ftl_file(&file_info.abs_path)?;
112            let keys = extract_message_keys(&resource);
113
114            loaded_files.push(LoadedFtlFile {
115                abs_path: file_info.abs_path.clone(),
116                relative_path: file_info.relative_path.clone(),
117                resource,
118                keys,
119            });
120        }
121    }
122
123    Ok(loaded_files)
124}
125
126/// Discover and load all FTL files for a locale and crate.
127pub fn discover_and_load_ftl_files(
128    assets_dir: &Path,
129    locale: &str,
130    crate_name: &str,
131) -> Result<Vec<LoadedFtlFile>> {
132    let files = discover_ftl_files(assets_dir, locale, crate_name)?;
133    load_ftl_files(files)
134}
135
136/// Parse an FTL file and return both the resource and any parse errors.
137pub fn parse_ftl_file_with_errors(
138    ftl_path: &Path,
139) -> Result<(
140    fluent_syntax::ast::Resource<String>,
141    Vec<fluent_syntax::parser::ParserError>,
142)> {
143    if !ftl_path.exists() {
144        return Ok((
145            fluent_syntax::ast::Resource { body: Vec::new() },
146            Vec::new(),
147        ));
148    }
149
150    let content = fs::read_to_string(ftl_path)?;
151
152    if content.trim().is_empty() {
153        return Ok((
154            fluent_syntax::ast::Resource { body: Vec::new() },
155            Vec::new(),
156        ));
157    }
158
159    match fluent_syntax::parser::parse(content) {
160        Ok(res) => Ok((res, Vec::new())),
161        Err((res, errors)) => Ok((res, errors)),
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use assert_fs::TempDir;
168
169    use super::*;
170    use std::fs;
171
172    #[test]
173    fn test_ftl_file_info_new() {
174        let abs_path = PathBuf::from("/test/example.ftl");
175        let relative_path = PathBuf::from("example.ftl");
176        let info = FtlFileInfo::new(abs_path.clone(), relative_path.clone());
177
178        assert_eq!(info.abs_path, abs_path);
179        assert_eq!(info.relative_path, relative_path);
180    }
181
182    #[test]
183    fn test_discover_ftl_files_main_only() {
184        let temp_dir = TempDir::new().unwrap();
185        let locale_dir = temp_dir.path().join("en");
186        fs::create_dir_all(&locale_dir).unwrap();
187
188        // Create main FTL file
189        let main_ftl = locale_dir.join("test-crate.ftl");
190        fs::write(&main_ftl, "hello = Hello\nworld = World").unwrap();
191
192        let files = discover_ftl_files(temp_dir.path(), "en", "test-crate").unwrap();
193
194        assert_eq!(files.len(), 1);
195        assert_eq!(files[0].relative_path, PathBuf::from("test-crate.ftl"));
196        assert_eq!(files[0].abs_path, main_ftl);
197    }
198
199    #[test]
200    fn test_discover_ftl_files_with_namespace() {
201        let temp_dir = TempDir::new().unwrap();
202        let locale_dir = temp_dir.path().join("en");
203        let crate_dir = locale_dir.join("test-crate");
204        fs::create_dir_all(&crate_dir).unwrap();
205
206        // Create main FTL file
207        let main_ftl = locale_dir.join("test-crate.ftl");
208        fs::write(&main_ftl, "hello = Hello").unwrap();
209
210        // Create namespaced FTL file
211        let namespace_ftl = crate_dir.join("ui.ftl");
212        fs::write(&namespace_ftl, "button = Click").unwrap();
213
214        let files = discover_ftl_files(temp_dir.path(), "en", "test-crate").unwrap();
215
216        assert_eq!(files.len(), 2);
217
218        // Check main file
219        let main_file = files
220            .iter()
221            .find(|f| f.relative_path == PathBuf::from("test-crate.ftl"))
222            .unwrap();
223        assert_eq!(main_file.abs_path, main_ftl);
224
225        // Check namespace file
226        let ns_file = files
227            .iter()
228            .find(|f| f.relative_path == PathBuf::from("test-crate/ui.ftl"))
229            .unwrap();
230        assert_eq!(ns_file.abs_path, namespace_ftl);
231    }
232
233    #[test]
234    fn test_discover_and_load_ftl_files() {
235        let temp_dir = TempDir::new().unwrap();
236        let locale_dir = temp_dir.path().join("en");
237        fs::create_dir_all(&locale_dir).unwrap();
238
239        // Create FTL file with content
240        let ftl_path = locale_dir.join("test-crate.ftl");
241        fs::write(&ftl_path, "hello = Hello { $name }").unwrap();
242
243        let loaded_files =
244            discover_and_load_ftl_files(temp_dir.path(), "en", "test-crate").unwrap();
245
246        assert_eq!(loaded_files.len(), 1);
247        assert_eq!(
248            loaded_files[0].relative_path,
249            PathBuf::from("test-crate.ftl")
250        );
251        assert_eq!(loaded_files[0].abs_path, ftl_path);
252        assert!(loaded_files[0].resource.body.len() > 0);
253        assert!(loaded_files[0].keys.contains("hello"));
254    }
255}