es_fluent_cli/
core.rs

1use crate::error::CliError;
2use cargo_metadata::PackageName;
3use cargo_metadata::{MetadataCommand, Package};
4use es_fluent_generate::FluentParseMode;
5use getset::Getters;
6use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, mpsc};
11use std::time::{Duration, Instant};
12
13/// Information about a crate that uses `es-fluent`.
14#[derive(Clone, Debug, Getters)]
15#[getset(get = "pub")]
16pub struct CrateInfo {
17    /// The name of the crate.
18    name: PackageName,
19    /// The path to the crate's manifest directory.
20    manifest_dir: PathBuf,
21    /// The path to the crate's `src` directory.
22    src_dir: PathBuf,
23    /// The path to the i18n output directory.
24    i18n_output_path: PathBuf,
25}
26
27/// The outcome of a build.
28#[derive(Clone, Debug)]
29pub enum BuildOutcome {
30    /// The build was successful.
31    Success {
32        /// The duration of the build.
33        duration: Duration,
34    },
35    /// The build failed.
36    Failure {
37        /// The error message.
38        error_message: String,
39        /// The duration of the build.
40        duration: Duration,
41    },
42}
43
44/// Discovers all crates in a directory that use `es-fluent`.
45///
46/// This function will search for `Cargo.toml` files in the given directory and
47/// its subdirectories. If a `Cargo.toml` file is found, it will check for an
48/// `i18n.toml` file in the same directory. If both files are found, the crate
49/// will be added to the list of discovered crates.
50pub fn discover_crates(root_dir: &Path) -> Result<Vec<CrateInfo>, CliError> {
51    let mut crates = Vec::new();
52
53    if let Ok(metadata) = MetadataCommand::new().current_dir(root_dir).exec() {
54        for package in metadata.workspace_packages() {
55            let manifest_dir = package.manifest_path.parent().unwrap();
56            if manifest_dir.starts_with(root_dir)
57                && let Some(crate_info) = check_crate_for_i18n(package)?
58            {
59                crates.push(crate_info);
60            }
61        }
62    } else {
63        let manifest_path = root_dir.join("Cargo.toml");
64        if manifest_path.exists() {
65            let metadata = MetadataCommand::new()
66                .manifest_path(&manifest_path)
67                .exec()?;
68
69            if let Some(package) = metadata.packages.first()
70                && let Some(crate_info) = check_crate_for_i18n(package)?
71            {
72                crates.push(crate_info);
73            }
74        }
75    }
76
77    Ok(crates)
78}
79
80fn check_crate_for_i18n(package: &Package) -> Result<Option<CrateInfo>, CliError> {
81    let manifest_dir: PathBuf = package.manifest_path.parent().unwrap().into();
82    let i18n_config_path = manifest_dir.join("i18n.toml");
83
84    if !i18n_config_path.exists() {
85        return Ok(None);
86    }
87
88    let i18n_config = es_fluent_toml::I18nConfig::read_from_path(&i18n_config_path)?;
89
90    let i18n_output_path = {
91        let assets_dir = manifest_dir.join(&i18n_config.assets_dir);
92        assets_dir.join(&i18n_config.fallback_language)
93    };
94
95    let src_dir = manifest_dir.join("src");
96    if !src_dir.exists() {
97        return Ok(None);
98    }
99
100    Ok(Some(CrateInfo {
101        name: package.name.clone(),
102        manifest_dir,
103        src_dir,
104        i18n_output_path,
105    }))
106}
107
108/// Builds all crates that use `es-fluent`.
109pub async fn build_all_crates(
110    crates: &[CrateInfo],
111    mode: FluentParseMode,
112) -> Result<HashMap<String, BuildOutcome>, CliError> {
113    let mut results = HashMap::new();
114    for krate in crates {
115        let outcome = build_crate(krate, mode.clone()).await?;
116        results.insert(krate.name.to_string(), outcome);
117    }
118    Ok(results)
119}
120
121/// Builds a single crate that uses `es-fluent`.
122pub async fn build_crate(
123    krate: &CrateInfo,
124    mode: FluentParseMode,
125) -> Result<BuildOutcome, CliError> {
126    let start = Instant::now();
127    let crate_name = &krate.name;
128
129    let result = (|| -> Result<(), CliError> {
130        if let Some(parent) = krate.i18n_output_path.parent() {
131            std::fs::create_dir_all(parent)?;
132        }
133        let data = es_fluent_sc_parser::parse_directory(&krate.src_dir)?;
134        es_fluent_generate::generate(crate_name, &krate.i18n_output_path, data, mode)?;
135        Ok(())
136    })();
137
138    let duration = start.elapsed();
139    match result {
140        Ok(()) => Ok(BuildOutcome::Success { duration }),
141        Err(e) => Ok(BuildOutcome::Failure {
142            error_message: e.to_string(),
143            duration,
144        }),
145    }
146}
147
148/// Watches all crates that use `es-fluent` for changes.
149///
150/// This function will watch the `src` directory of each crate for changes.
151/// When a change is detected, it will send a `CrateInfo` object to the
152/// `event_tx` channel.
153pub fn watch_crates_sender(
154    crates: &[CrateInfo],
155    event_tx: mpsc::Sender<CrateInfo>,
156    shutdown_signal: Arc<AtomicBool>,
157) -> Result<(), CliError> {
158    let (notify_tx, notify_rx) = mpsc::channel();
159
160    let mut watcher = RecommendedWatcher::new(notify_tx, Config::default())?;
161
162    let mut path_to_crate_map: HashMap<PathBuf, CrateInfo> = HashMap::new();
163
164    for krate in crates {
165        if let Err(e) = watcher.watch(&krate.src_dir, RecursiveMode::Recursive) {
166            log::error!("Failed to watch directory {:?}: {}", krate.src_dir, e);
167
168            return Err(e.into());
169        }
170        path_to_crate_map.insert(krate.src_dir.clone(), krate.clone());
171    }
172
173    let recv_timeout_duration = Duration::from_millis(250);
174
175    loop {
176        if shutdown_signal.load(Ordering::Relaxed) {
177            log::debug!("Shutdown signal received in watcher thread. Exiting.");
178            break;
179        }
180
181        match notify_rx.recv_timeout(recv_timeout_duration) {
182            Ok(Ok(event)) => {
183                if should_rebuild(&event)
184                    && let Some(affected_crate) = find_affected_crate(&event, &path_to_crate_map)
185                    && event_tx.send(affected_crate.clone()).is_err()
186                {
187                    log::error!(
188                        "Watch event channel (AppEvent::FileChange) closed. File watcher stopping."
189                    );
190                    break;
191                }
192            },
193            Ok(Err(e)) => {
194                log::error!("File watch error: {:?}. File watcher stopping.", e);
195                break;
196            },
197            Err(mpsc::RecvTimeoutError::Timeout) => {
198                continue;
199            },
200            Err(mpsc::RecvTimeoutError::Disconnected) => {
201                log::error!("File watcher's internal channel disconnected. File watcher stopping.");
202                break;
203            },
204        }
205    }
206
207    for krate in crates {
208        if let Err(e) = watcher.unwatch(&krate.src_dir) {
209            log::warn!("Failed to unwatch directory {:?}: {}", krate.src_dir, e);
210        }
211    }
212    log::debug!("File watcher thread has completed cleanup and is exiting.");
213    Ok(())
214}
215
216fn should_rebuild(event: &Event) -> bool {
217    matches!(
218        event.kind,
219        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
220    ) && event.paths.iter().any(|path| {
221        path.extension()
222            .and_then(|ext| ext.to_str())
223            .map(|ext| ext == "rs")
224            .unwrap_or(false)
225    })
226}
227
228fn find_affected_crate(
229    event: &Event,
230    path_to_crate: &HashMap<PathBuf, CrateInfo>,
231) -> Option<CrateInfo> {
232    for path in &event.paths {
233        for (watched_path, crate_info) in path_to_crate {
234            if path.starts_with(watched_path) {
235                return Some(crate_info.clone());
236            }
237        }
238    }
239    None
240}
241
242/// Formats a `Duration` into a human-readable string.
243pub fn format_duration(duration: Duration) -> String {
244    if duration.as_nanos() == 0 {
245        return "0s".to_string();
246    }
247    let formatted = humantime::format_duration(duration).to_string();
248    formatted
249        .split(' ')
250        .next()
251        .unwrap_or(&formatted)
252        .to_string()
253}