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#[derive(Clone, Debug, Getters)]
15#[getset(get = "pub")]
16pub struct CrateInfo {
17 name: PackageName,
19 manifest_dir: PathBuf,
21 src_dir: PathBuf,
23 i18n_output_path: PathBuf,
25}
26
27#[derive(Clone, Debug)]
29pub enum BuildOutcome {
30 Success {
32 duration: Duration,
34 },
35 Failure {
37 error_message: String,
39 duration: Duration,
41 },
42}
43
44pub 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
108pub 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
121pub 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
148pub 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
242pub 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}