zeroclaw_runtime/rpc/
locales.rs1use ::zeroclaw_log::Instrument as _;
17use zeroclaw_api::jsonrpc::error_codes::*;
18use zeroclaw_api::jsonrpc::{
19 FetchedCatalog, JsonRpcError, LocaleOption, LocalesFetchRequest, LocalesFetchResponse,
20 LocalesListResponse,
21};
22
23fn rpc_err(code: i32, msg: impl Into<String>) -> JsonRpcError {
24 JsonRpcError {
25 code,
26 message: msg.into(),
27 data: None,
28 }
29}
30
31fn locale_span(tui_id: Option<&str>) -> ::zeroclaw_log::Span {
33 ::zeroclaw_log::info_span!(
34 target: "zeroclaw_log_internal_scope",
35 "zeroclaw_scope",
36 session_key = %tui_id.unwrap_or("rpc"),
37 channel = "rpc",
38 )
39}
40
41pub fn handle_locales_list(tui_id: Option<&str>) -> Result<serde_json::Value, JsonRpcError> {
43 let span = locale_span(tui_id);
44 let _guard = span.enter();
45 let locales: Vec<LocaleOption> = crate::i18n::available_locales()
46 .iter()
47 .map(|o| LocaleOption {
48 code: o.code.clone(),
49 label: o.label.clone(),
50 })
51 .collect();
52 ::zeroclaw_log::record!(
53 DEBUG,
54 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
55 .with_attrs(::serde_json::json!({ "count": locales.len() })),
56 "locales/list served from embedded registry"
57 );
58 serde_json::to_value(LocalesListResponse { locales })
59 .map_err(|e| rpc_err(INTERNAL_ERROR, e.to_string()))
60}
61
62pub async fn handle_locales_fetch(
64 params: &serde_json::Value,
65 tui_id: Option<&str>,
66) -> Result<serde_json::Value, JsonRpcError> {
67 let span = locale_span(tui_id);
68 async move {
69 let req: LocalesFetchRequest = serde_json::from_value(params.clone())
70 .map_err(|e| rpc_err(INVALID_PARAMS, e.to_string()))?;
71
72 let locale = match validate_locale(&req.locale) {
74 Ok(l) => l,
75 Err(e) => {
76 ::zeroclaw_log::record!(
77 WARN,
78 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
79 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
80 .with_attrs(::serde_json::json!({ "locale": req.locale })),
81 "locales/fetch rejected: locale not in registry or invalid shape"
82 );
83 return Err(e);
84 }
85 };
86
87 let selected: Vec<&(&str, &str, &str)> = if req.catalog.is_empty() {
89 zeroclaw_config::schema::FTL_CATALOGS.iter().collect()
90 } else {
91 let mut out = Vec::new();
92 for name in &req.catalog {
93 match zeroclaw_config::schema::FTL_CATALOGS
94 .iter()
95 .find(|(n, _, _)| n == name)
96 {
97 Some(entry) => out.push(entry),
98 None => {
99 ::zeroclaw_log::record!(
100 WARN,
101 ::zeroclaw_log::Event::new(
102 module_path!(),
103 ::zeroclaw_log::Action::Reject
104 )
105 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
106 .with_attrs(::serde_json::json!({ "catalog": name })),
107 "locales/fetch rejected: unknown catalog"
108 );
109 return Err(rpc_err(INVALID_PARAMS, format!("unknown catalog '{name}'")));
110 }
111 }
112 }
113 out
114 };
115
116 ::zeroclaw_log::record!(
117 INFO,
118 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
119 ::serde_json::json!({
120 "locale": locale,
121 "catalogs": selected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>(),
122 })
123 ),
124 "locales/fetch started"
125 );
126
127 let version = env!("CARGO_PKG_VERSION");
128 let refs = [format!("v{version}"), "master".to_string()];
129 let client = reqwest::Client::new();
130
131 let mut catalogs = Vec::new();
132 let mut skipped = Vec::new();
133 for (name, path_tmpl, out_name) in selected {
134 let repo_path = path_tmpl.replace("{locale}", &locale);
135 let mut content: Option<String> = None;
136 for git_ref in &refs {
137 let url = format!(
138 "https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/{git_ref}/{repo_path}"
139 );
140 let resp = match client.get(&url).send().await {
141 Ok(r) => r,
142 Err(e) => {
143 ::zeroclaw_log::record!(
144 WARN,
145 ::zeroclaw_log::Event::new(
146 module_path!(),
147 ::zeroclaw_log::Action::Note
148 )
149 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
150 .with_attrs(::serde_json::json!({ "catalog": name, "url": url })),
151 "locales/fetch network error"
152 );
153 return Err(rpc_err(INTERNAL_ERROR, e.to_string()));
154 }
155 };
156 if resp.status().is_success() {
157 content = Some(
158 resp.text()
159 .await
160 .map_err(|e| rpc_err(INTERNAL_ERROR, e.to_string()))?,
161 );
162 break;
163 }
164 }
165 match content {
166 Some(c) => catalogs.push(FetchedCatalog {
167 name: (*name).to_string(),
168 filename: (*out_name).to_string(),
169 content: c,
170 }),
171 None => {
172 ::zeroclaw_log::record!(
173 DEBUG,
174 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
175 .with_attrs(::serde_json::json!({ "catalog": name, "locale": locale })),
176 "locales/fetch: catalogue not on upstream, skipped"
177 );
178 skipped.push((*name).to_string());
179 }
180 }
181 }
182
183 ::zeroclaw_log::record!(
184 INFO,
185 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
186 ::serde_json::json!({
187 "locale": locale,
188 "fetched": catalogs.len(),
189 "skipped": skipped,
190 })
191 ),
192 "locales/fetch completed"
193 );
194
195 serde_json::to_value(LocalesFetchResponse {
196 locale,
197 catalogs,
198 skipped,
199 })
200 .map_err(|e| rpc_err(INTERNAL_ERROR, e.to_string()))
201 }
202 .instrument(span)
203 .await
204}
205
206fn validate_locale(locale: &str) -> Result<String, JsonRpcError> {
209 let ok_shape = !locale.is_empty()
210 && locale.len() <= 16
211 && locale
212 .chars()
213 .all(|c| c.is_ascii_alphanumeric() || c == '-');
214 if !ok_shape {
215 return Err(rpc_err(
216 INVALID_PARAMS,
217 format!("invalid locale '{locale}'"),
218 ));
219 }
220 if !crate::i18n::available_locales()
221 .iter()
222 .any(|o| o.code == locale)
223 {
224 return Err(rpc_err(
225 INVALID_PARAMS,
226 format!("locale '{locale}' not in registry"),
227 ));
228 }
229 Ok(locale.to_string())
230}