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#[derive(Clone, Debug, Getters)]
14#[getset(get = "pub")]
15pub struct CrateInfo {
16 name: String,
18 manifest_dir: PathBuf,
20 src_dir: PathBuf,
22 i18n_output_path: PathBuf,
24}
25
26#[derive(Clone, Debug)]
28pub enum BuildOutcome {
29 Success {
31 duration: Duration,
33 },
34 Failure {
36 error_message: String,
38 duration: Duration,
40 },
41}
42
43pub 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
107pub 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
120pub 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
147pub 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
241pub 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}