es_fluent_cli/
core.rs

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