1pub mod actions;
3
4pub use actions::MavenClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{debug, error, info, warn};
11
12use crate::client::http_fetch::FetchClient;
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::maven::actions::MAVEN_CLIENT_CONNECTED_EVENT;
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ClientLlmResult;
18use crate::logging::emit::Log;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23
24pub struct MavenFetch {
31 pub event_data: serde_json::Value,
32 pub summary: String,
33}
34
35enum Applied {
37 Executed(String),
39 Disconnect,
41}
42
43#[derive(Clone, Copy)]
45enum Dispatch {
46 Spawn,
50 Await,
54}
55
56fn truncate_document(body: String) -> String {
66 const MAX_DOCUMENT_BYTES: usize = 16 * 1024;
67 if body.len() <= MAX_DOCUMENT_BYTES {
68 return body;
69 }
70 crate::utils::truncate_for_log(&body, MAX_DOCUMENT_BYTES)
71}
72
73#[cfg(not(target_arch = "wasm32"))]
83static SHARED_HTTP_CLIENT: tokio::sync::OnceCell<reqwest::Client> =
84 tokio::sync::OnceCell::const_new();
85
86#[cfg(not(target_arch = "wasm32"))]
87async fn reqwest_client() -> Result<reqwest::Client> {
88 SHARED_HTTP_CLIENT
89 .get_or_try_init(|| async {
90 tokio::task::spawn_blocking(|| {
91 reqwest::Client::builder()
92 .timeout(std::time::Duration::from_secs(30))
93 .user_agent("NetGet-Maven/1.0")
94 .build()
95 })
96 .await
97 .context("building the HTTP client panicked")?
98 .context("failed to build the HTTP client")
99 })
100 .await
101 .cloned()
102}
103
104async fn shared_http_client() -> Result<FetchClient> {
108 #[cfg(not(target_arch = "wasm32"))]
109 let client = FetchClient::from_reqwest(reqwest_client().await?);
110 #[cfg(target_arch = "wasm32")]
111 let client = FetchClient::transport(std::time::Duration::from_secs(30))
112 .with_user_agent("NetGet-Maven/1.0");
113 Ok(client)
114}
115
116const MAX_FOLLOWUP_DEPTH: u8 = 6;
133
134pub struct MavenClient;
136
137impl MavenClient {
138 async fn apply_action(
148 client_id: ClientId,
149 result: ClientActionResult,
150 dispatch: Dispatch,
151 depth: u8,
152 app_state: &Arc<AppState>,
153 llm_client: &OllamaClient,
154 status_tx: &mpsc::UnboundedSender<String>,
155 ) -> Result<Applied> {
156 let (name, data) = match result {
157 ClientActionResult::Custom { name, data } => (name, data),
158 ClientActionResult::Disconnect => return Ok(Applied::Disconnect),
159 ClientActionResult::NoAction => return Ok(Applied::Executed("no_action".to_string())),
160 ClientActionResult::WaitForMore => {
161 return Ok(Applied::Executed("wait_for_more".to_string()))
162 }
163 ClientActionResult::SendData(_) => {
164 return Ok(Applied::Executed(
165 "Maven owns no socket; raw send_data cannot be put on the wire".to_string(),
166 ))
167 }
168 ClientActionResult::Multiple(_) => {
169 return Ok(Applied::Executed(
170 "Maven produces no Multiple results; nothing executed".to_string(),
171 ))
172 }
173 };
174
175 let group_id = data["group_id"].as_str().unwrap_or_default().to_string();
176 let artifact_id = data["artifact_id"].as_str().unwrap_or_default().to_string();
177 let version = data["version"].as_str().unwrap_or_default().to_string();
178 let packaging = data["packaging"].as_str().map(|s| s.to_string());
179
180 match name.as_str() {
181 "maven_download_artifact" => {
182 let coords = format!("{group_id}:{artifact_id}:{version}");
183 match dispatch {
184 Dispatch::Spawn => {
185 Self::spawn_operation(
186 client_id,
187 app_state,
188 Self::download_artifact(
189 client_id,
190 depth + 1,
191 group_id,
192 artifact_id,
193 version,
194 packaging,
195 app_state.clone(),
196 llm_client.clone(),
197 status_tx.clone(),
198 ),
199 )
200 .await;
201 Ok(Applied::Executed(format!(
202 "download_artifact {coords} dispatched"
203 )))
204 }
205 Dispatch::Await => {
206 let fetch = Self::perform_download_artifact(
207 client_id,
208 group_id,
209 artifact_id,
210 version,
211 packaging,
212 app_state,
213 status_tx,
214 )
215 .await?;
216 let detail = format!("download_artifact {coords} -> {}", fetch.summary);
217 Self::spawn_notify(
218 client_id,
219 depth + 1,
220 app_state,
221 &crate::client::maven::actions::MAVEN_CLIENT_ARTIFACT_DOWNLOADED_EVENT,
222 fetch.event_data,
223 llm_client,
224 status_tx,
225 )
226 .await;
227 Ok(Applied::Executed(detail))
228 }
229 }
230 }
231 "maven_download_pom" => {
232 let coords = format!("{group_id}:{artifact_id}:{version}");
233 match dispatch {
234 Dispatch::Spawn => {
235 Self::spawn_operation(
236 client_id,
237 app_state,
238 Self::download_pom(
239 client_id,
240 depth + 1,
241 group_id,
242 artifact_id,
243 version,
244 app_state.clone(),
245 llm_client.clone(),
246 status_tx.clone(),
247 ),
248 )
249 .await;
250 Ok(Applied::Executed(format!(
251 "download_pom {coords} dispatched"
252 )))
253 }
254 Dispatch::Await => {
255 let fetch = Self::perform_download_pom(
256 client_id,
257 group_id,
258 artifact_id,
259 version,
260 app_state,
261 status_tx,
262 )
263 .await?;
264 let detail = format!("download_pom {coords} -> {}", fetch.summary);
265 Self::spawn_notify(
266 client_id,
267 depth + 1,
268 app_state,
269 &crate::client::maven::actions::MAVEN_CLIENT_POM_RECEIVED_EVENT,
270 fetch.event_data,
271 llm_client,
272 status_tx,
273 )
274 .await;
275 Ok(Applied::Executed(detail))
276 }
277 }
278 }
279 "maven_search_versions" => {
280 let coords = format!("{group_id}:{artifact_id}");
281 match dispatch {
282 Dispatch::Spawn => {
283 Self::spawn_operation(
284 client_id,
285 app_state,
286 Self::search_versions(
287 client_id,
288 depth + 1,
289 group_id,
290 artifact_id,
291 app_state.clone(),
292 llm_client.clone(),
293 status_tx.clone(),
294 ),
295 )
296 .await;
297 Ok(Applied::Executed(format!(
298 "search_versions {coords} dispatched"
299 )))
300 }
301 Dispatch::Await => {
302 let fetch = Self::perform_search_versions(
303 client_id,
304 group_id,
305 artifact_id,
306 app_state,
307 status_tx,
308 )
309 .await?;
310 let detail = format!("search_versions {coords} -> {}", fetch.summary);
311 Self::spawn_notify(
312 client_id,
313 depth + 1,
314 app_state,
315 &crate::client::maven::actions::MAVEN_CLIENT_METADATA_RECEIVED_EVENT,
316 fetch.event_data,
317 llm_client,
318 status_tx,
319 )
320 .await;
321 Ok(Applied::Executed(detail))
322 }
323 }
324 }
325 other => {
326 warn!("Unknown Maven custom action: {}", other);
327 Ok(Applied::Executed(format!(
328 "unknown Maven custom result '{other}' was not executed"
329 )))
330 }
331 }
332 }
333
334 async fn spawn_operation(
336 client_id: ClientId,
337 app_state: &Arc<AppState>,
338 operation: impl std::future::Future<Output = Result<()>> + Send + 'static,
339 ) {
340 let handle = tokio::spawn(async move {
343 if let Err(e) = operation.await {
344 error!("Maven client {} operation failed: {}", client_id, e);
345 }
346 });
347 app_state.register_client_task(client_id, handle).await;
348 }
349
350 async fn spawn_notify(
353 client_id: ClientId,
354 depth: u8,
355 app_state: &Arc<AppState>,
356 event_type: &'static crate::protocol::EventType,
357 event_data: serde_json::Value,
358 llm_client: &OllamaClient,
359 status_tx: &mpsc::UnboundedSender<String>,
360 ) {
361 let state = app_state.clone();
362 let llm = llm_client.clone();
363 let tx = status_tx.clone();
364 let handle = tokio::spawn(async move {
365 Self::notify_maven(client_id, depth, event_type, event_data, state, llm, tx).await;
366 });
367 app_state.register_client_task(client_id, handle).await;
368 }
369
370 async fn notify_maven(
373 client_id: ClientId,
374 depth: u8,
375 event_type: &'static crate::protocol::EventType,
376 event_data: serde_json::Value,
377 app_state: Arc<AppState>,
378 llm_client: OllamaClient,
379 status_tx: mpsc::UnboundedSender<String>,
380 ) {
381 if depth > MAX_FOLLOWUP_DEPTH {
382 warn!(
383 "Maven client {} cut a follow-up chain at depth {} (limit {}): the model \
384 kept answering each response event with another repository operation. \
385 The chain is stopped, not the client.",
386 client_id, depth, MAX_FOLLOWUP_DEPTH
387 );
388 let _ = status_tx.send(format!(
389 "[WARN] Maven client {} follow-up chain cut at depth {}",
390 client_id, MAX_FOLLOWUP_DEPTH
391 ));
392 return;
393 }
394
395 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
396 return;
397 };
398
399 let protocol = Arc::new(crate::client::maven::actions::MavenClientProtocol::new());
400 let event = Event::new(event_type, event_data);
401
402 let memory = app_state
403 .get_memory_for_client(client_id)
404 .await
405 .unwrap_or_default();
406
407 match call_llm_for_client(
408 &llm_client,
409 &app_state,
410 client_id.to_string(),
411 &instruction,
412 &memory,
413 Some(&event),
414 protocol.as_ref(),
415 &status_tx,
416 )
417 .await
418 {
419 Ok(ClientLlmResult {
420 actions,
421 memory_updates,
422 }) => {
423 if let Some(mem) = memory_updates {
424 app_state.set_memory_for_client(client_id, mem).await;
425 }
426
427 for action in actions {
429 match protocol.as_ref().execute_action(action) {
430 Ok(ClientActionResult::Custom { name, data }) => {
431 Self::execute_maven_action(
432 client_id,
433 depth,
434 name,
435 data,
436 app_state.clone(),
437 llm_client.clone(),
438 status_tx.clone(),
439 );
440 }
441 Ok(ClientActionResult::Disconnect) => {
442 info!("Maven client {} disconnecting", client_id);
445 app_state
446 .update_client_status(client_id, ClientStatus::Disconnected)
447 .await;
448 }
449 Ok(other) => {
450 debug!("Maven client {} action result: {:?}", client_id, other);
451 }
452 Err(e) => {
453 warn!(
456 "Maven client {} could not execute the model's action: {}",
457 client_id, e
458 );
459 }
460 }
461 }
462 }
463 Err(e) => {
464 error!("LLM error for Maven client {}: {}", client_id, e);
465 }
466 }
467 }
468
469 fn execute_maven_action(
476 client_id: ClientId,
477 depth: u8,
478 name: String,
479 data: serde_json::Value,
480 app_state: Arc<AppState>,
481 llm_client: OllamaClient,
482 status_tx: mpsc::UnboundedSender<String>,
483 ) {
484 let registrar = app_state.clone();
485 let handle = tokio::spawn(async move {
486 let result = ClientActionResult::Custom { name, data };
487 if let Err(e) = Self::apply_action(
488 client_id,
489 result,
490 Dispatch::Await,
491 depth,
492 &app_state,
493 &llm_client,
494 &status_tx,
495 )
496 .await
497 {
498 error!("Maven client {} action failed: {}", client_id, e);
499 }
500 });
501 tokio::spawn(async move {
504 registrar.register_client_task(client_id, handle).await;
505 });
506 }
507
508 async fn command_loop(
510 mut command_rx: mpsc::Receiver<ClientCommand>,
511 client_id: ClientId,
512 app_state: Arc<AppState>,
513 llm_client: OllamaClient,
514 status_tx: mpsc::UnboundedSender<String>,
515 ) {
516 use crate::llm::actions::protocol_trait::Protocol;
517 let protocol = crate::client::maven::actions::MavenClientProtocol::new();
518
519 while let Some(command) = command_rx.recv().await {
520 let action = command.action.clone();
521 let outcome = match protocol.execute_action(action.clone()) {
522 Err(e) => Ok(ClientSendOutcome::Rejected {
523 error: e.to_string(),
524 }),
525 Ok(result) => match Self::apply_action(
528 client_id,
529 result,
530 Dispatch::Await,
531 0,
532 &app_state,
533 &llm_client,
534 &status_tx,
535 )
536 .await
537 {
538 Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
539 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
540 Err(e) => Err(e),
541 },
542 };
543
544 let outcome_json = match &outcome {
545 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
546 Err(e) => serde_json::json!({"error": e.to_string()}),
547 };
548 app_state
549 .record_access_log(
550 AccessLogOwner::Client(client_id.as_u32()),
551 protocol.protocol_name(),
552 None,
553 "injected_action",
554 action,
555 vec![outcome_json],
556 )
557 .await;
558
559 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
560 if let Err(e) = &outcome {
561 error!("Maven client {} injected action failed: {}", client_id, e);
562 let _ = status_tx.send(format!(
563 "[WARN] Client {} injected action failed: {}",
564 client_id, e
565 ));
566 }
567 let _ = status_tx.send("__UPDATE_UI__".to_string());
568 crate::client::command_support::reply(command, outcome);
569
570 if disconnect {
571 app_state
572 .update_client_status(client_id, ClientStatus::Disconnected)
573 .await;
574 break;
575 }
576 }
577
578 info!("Maven client {} command loop finished", client_id);
579 app_state.remove_client_handle(client_id).await;
580 let _ = status_tx.send("__UPDATE_UI__".to_string());
581 }
582
583 pub async fn connect_with_llm_actions(
585 repository_url: String,
586 _llm_client: OllamaClient,
587 app_state: Arc<AppState>,
588 status_tx: mpsc::UnboundedSender<String>,
589 client_id: ClientId,
590 ) -> Result<SocketAddr> {
591 let repo_url = if repository_url.is_empty()
594 || repository_url == "maven"
595 || repository_url == "maven-central"
596 {
597 "https://repo.maven.apache.org/maven2".to_string()
598 } else if repository_url.contains("://") {
599 repository_url
600 } else {
601 let scheme = if cfg!(target_arch = "wasm32") {
608 "http"
609 } else {
610 "https"
611 };
612 format!("{scheme}://{}", repository_url.trim_end_matches('/'))
613 };
614 crate::client::http_fetch::check_url(&repo_url)?;
617
618 info!(
619 "Maven client {} initialized for repository: {}",
620 client_id, repo_url
621 );
622
623 app_state
625 .with_client_mut(client_id, |client| {
626 client.set_protocol_field(
627 "http_client".to_string(),
628 serde_json::json!("initialized"),
629 );
630 client.set_protocol_field(
631 "repository_url".to_string(),
632 serde_json::json!(repo_url.clone()),
633 );
634 })
635 .await;
636
637 app_state
639 .update_client_status(client_id, ClientStatus::Connected)
640 .await;
641 Log::new(Some(&status_tx)).info(format!(
642 "Maven client {} connected to repository: {}",
643 client_id, repo_url
644 ));
645 let _ = status_tx.send("__UPDATE_UI__".to_string());
646
647 let command_rx =
652 crate::client::command_support::register_command_channel(&app_state, client_id).await;
653
654 let command_task = tokio::spawn(Self::command_loop(
660 command_rx,
661 client_id,
662 app_state.clone(),
663 _llm_client.clone(),
664 status_tx.clone(),
665 ));
666 app_state
667 .register_client_task(client_id, command_task)
668 .await;
669
670 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
672 let protocol = Arc::new(crate::client::maven::actions::MavenClientProtocol::new());
673 let event = Event::new(
674 &MAVEN_CLIENT_CONNECTED_EVENT,
675 serde_json::json!({
676 "repository_url": repo_url,
677 }),
678 );
679
680 let memory = app_state
681 .get_memory_for_client(client_id)
682 .await
683 .unwrap_or_default();
684
685 match call_llm_for_client(
686 &_llm_client,
687 &app_state,
688 client_id.to_string(),
689 &instruction,
690 &memory,
691 Some(&event),
692 protocol.as_ref(),
693 &status_tx,
694 )
695 .await
696 {
697 Ok(ClientLlmResult {
698 actions,
699 memory_updates,
700 }) => {
701 if let Some(mem) = memory_updates {
703 app_state.set_memory_for_client(client_id, mem).await;
704 }
705
706 for action in actions {
709 match protocol.as_ref().execute_action(action) {
710 Ok(result) => match Self::apply_action(
711 client_id,
712 result,
713 Dispatch::Spawn,
714 0,
715 &app_state,
716 &_llm_client,
717 &status_tx,
718 )
719 .await
720 {
721 Ok(Applied::Disconnect) => {
722 info!("Maven client {} disconnecting", client_id);
723 }
724 Ok(Applied::Executed(detail)) => {
725 info!("Maven client {} after connect: {}", client_id, detail);
726 }
727 Err(e) => {
728 error!("Maven client {} action failed: {}", client_id, e);
729 }
730 },
731 Err(e) => {
732 error!("Maven client {} rejected action: {}", client_id, e);
733 }
734 }
735 }
736 }
737 Err(e) => {
738 error!("LLM error for Maven client {}: {}", client_id, e);
739 }
740 }
741 }
742
743 Ok("0.0.0.0:0".parse().unwrap())
745 }
746
747 pub fn artifact_url(
753 repository_url: &str,
754 group_id: &str,
755 artifact_id: &str,
756 version: &str,
757 packaging: &str,
758 ) -> String {
759 let group_path = group_id.replace('.', "/");
760 format!(
761 "{}/{}/{}/{}/{}-{}.{}",
762 repository_url.trim_end_matches('/'),
763 group_path,
764 artifact_id,
765 version,
766 artifact_id,
767 version,
768 packaging
769 )
770 }
771
772 pub fn pom_url(
774 repository_url: &str,
775 group_id: &str,
776 artifact_id: &str,
777 version: &str,
778 ) -> String {
779 Self::artifact_url(repository_url, group_id, artifact_id, version, "pom")
780 }
781
782 pub fn metadata_url(repository_url: &str, group_id: &str, artifact_id: &str) -> String {
784 let group_path = group_id.replace('.', "/");
785 format!(
786 "{}/{}/{}/maven-metadata.xml",
787 repository_url.trim_end_matches('/'),
788 group_path,
789 artifact_id
790 )
791 }
792
793 #[allow(clippy::too_many_arguments)]
795 pub async fn download_artifact(
796 client_id: ClientId,
797 depth: u8,
798 group_id: String,
799 artifact_id: String,
800 version: String,
801 packaging: Option<String>,
802 app_state: Arc<AppState>,
803 llm_client: OllamaClient,
804 status_tx: mpsc::UnboundedSender<String>,
805 ) -> Result<()> {
806 let fetch = Self::perform_download_artifact(
807 client_id,
808 group_id,
809 artifact_id,
810 version,
811 packaging,
812 &app_state,
813 &status_tx,
814 )
815 .await?;
816 Self::notify_maven(
817 client_id,
818 depth,
819 &crate::client::maven::actions::MAVEN_CLIENT_ARTIFACT_DOWNLOADED_EVENT,
820 fetch.event_data,
821 app_state,
822 llm_client,
823 status_tx,
824 )
825 .await;
826 Ok(())
827 }
828
829 pub async fn perform_download_artifact(
832 client_id: ClientId,
833 group_id: String,
834 artifact_id: String,
835 version: String,
836 packaging: Option<String>,
837 app_state: &AppState,
838 status_tx: &mpsc::UnboundedSender<String>,
839 ) -> Result<MavenFetch> {
840 let packaging = packaging.unwrap_or_else(|| "jar".to_string());
841 let repository_url = Self::repository_url(app_state, client_id).await?;
842
843 let artifact_url = Self::artifact_url(
844 &repository_url,
845 &group_id,
846 &artifact_id,
847 &version,
848 &packaging,
849 );
850
851 Log::new(Some(status_tx)).info(format!(
852 "Maven client {} downloading artifact {}:{}:{} from {}",
853 client_id, group_id, artifact_id, version, artifact_url
854 ));
855
856 match Self::http_client().await?.get(&artifact_url).send().await {
857 Ok(response) => {
858 let status = response.status();
859 let status_code = status.as_u16();
860
861 if status.is_success() {
862 let content_length = response.content_length().unwrap_or(0);
863 let body_bytes = response
870 .bytes()
871 .await
872 .context("reading the artifact body")?;
873
874 info!(
875 "Maven client {} artifact downloaded: {} bytes",
876 client_id,
877 body_bytes.len()
878 );
879
880 Ok(MavenFetch {
881 summary: format!("HTTP {} ({} bytes)", status_code, body_bytes.len()),
882 event_data: serde_json::json!({
883 "group_id": group_id,
884 "artifact_id": artifact_id,
885 "version": version,
886 "packaging": packaging,
887 "url": artifact_url,
888 "size_bytes": body_bytes.len(),
889 "content_length": content_length,
890 }),
891 })
892 } else {
893 let error_msg = format!("Artifact not found: HTTP {}", status_code);
894 Log::new(Some(status_tx))
895 .error(format!("Maven client {} error: {}", client_id, error_msg));
896 Err(anyhow::anyhow!(error_msg))
897 }
898 }
899 Err(e) => {
900 Log::new(Some(status_tx))
901 .error(format!("Maven client {} download failed: {}", client_id, e));
902 Err(e)
903 }
904 }
905 }
906
907 pub async fn download_pom(
909 client_id: ClientId,
910 depth: u8,
911 group_id: String,
912 artifact_id: String,
913 version: String,
914 app_state: Arc<AppState>,
915 llm_client: OllamaClient,
916 status_tx: mpsc::UnboundedSender<String>,
917 ) -> Result<()> {
918 let fetch = Self::perform_download_pom(
919 client_id,
920 group_id,
921 artifact_id,
922 version,
923 &app_state,
924 &status_tx,
925 )
926 .await?;
927 Self::notify_maven(
928 client_id,
929 depth,
930 &crate::client::maven::actions::MAVEN_CLIENT_POM_RECEIVED_EVENT,
931 fetch.event_data,
932 app_state,
933 llm_client,
934 status_tx,
935 )
936 .await;
937 Ok(())
938 }
939
940 pub async fn perform_download_pom(
942 client_id: ClientId,
943 group_id: String,
944 artifact_id: String,
945 version: String,
946 app_state: &AppState,
947 status_tx: &mpsc::UnboundedSender<String>,
948 ) -> Result<MavenFetch> {
949 let repository_url = Self::repository_url(app_state, client_id).await?;
950 let pom_url = Self::pom_url(&repository_url, &group_id, &artifact_id, &version);
951
952 Log::new(Some(status_tx)).info(format!(
953 "Maven client {} downloading POM {}:{}:{} from {}",
954 client_id, group_id, artifact_id, version, pom_url
955 ));
956
957 match Self::http_client().await?.get(&pom_url).send().await {
958 Ok(response) => {
959 let status = response.status();
960 let status_code = status.as_u16();
961
962 if status.is_success() {
963 let pom_content =
966 truncate_document(response.text().await.context("reading the POM body")?);
967
968 info!(
969 "Maven client {} POM downloaded: {} bytes",
970 client_id,
971 pom_content.len()
972 );
973
974 Ok(MavenFetch {
975 summary: format!("HTTP {} ({} bytes)", status_code, pom_content.len()),
976 event_data: serde_json::json!({
977 "group_id": group_id,
978 "artifact_id": artifact_id,
979 "version": version,
980 "url": pom_url,
981 "pom_content": pom_content,
982 }),
983 })
984 } else {
985 let error_msg = format!("POM not found: HTTP {}", status_code);
986 Log::new(Some(status_tx))
987 .error(format!("Maven client {} error: {}", client_id, error_msg));
988 Err(anyhow::anyhow!(error_msg))
989 }
990 }
991 Err(e) => {
992 Log::new(Some(status_tx)).error(format!(
993 "Maven client {} POM download failed: {}",
994 client_id, e
995 ));
996 Err(e)
997 }
998 }
999 }
1000
1001 pub async fn search_versions(
1003 client_id: ClientId,
1004 depth: u8,
1005 group_id: String,
1006 artifact_id: String,
1007 app_state: Arc<AppState>,
1008 llm_client: OllamaClient,
1009 status_tx: mpsc::UnboundedSender<String>,
1010 ) -> Result<()> {
1011 let fetch =
1012 Self::perform_search_versions(client_id, group_id, artifact_id, &app_state, &status_tx)
1013 .await?;
1014 Self::notify_maven(
1015 client_id,
1016 depth,
1017 &crate::client::maven::actions::MAVEN_CLIENT_METADATA_RECEIVED_EVENT,
1018 fetch.event_data,
1019 app_state,
1020 llm_client,
1021 status_tx,
1022 )
1023 .await;
1024 Ok(())
1025 }
1026
1027 pub async fn perform_search_versions(
1029 client_id: ClientId,
1030 group_id: String,
1031 artifact_id: String,
1032 app_state: &AppState,
1033 status_tx: &mpsc::UnboundedSender<String>,
1034 ) -> Result<MavenFetch> {
1035 let repository_url = Self::repository_url(app_state, client_id).await?;
1036 let metadata_url = Self::metadata_url(&repository_url, &group_id, &artifact_id);
1037
1038 Log::new(Some(status_tx)).info(format!(
1039 "Maven client {} searching versions {}:{} via {}",
1040 client_id, group_id, artifact_id, metadata_url
1041 ));
1042
1043 match Self::http_client().await?.get(&metadata_url).send().await {
1044 Ok(response) => {
1045 let status = response.status();
1046 let status_code = status.as_u16();
1047
1048 if status.is_success() {
1049 let metadata_content = truncate_document(
1052 response
1053 .text()
1054 .await
1055 .context("reading the maven-metadata.xml body")?,
1056 );
1057
1058 info!(
1059 "Maven client {} metadata received: {} bytes",
1060 client_id,
1061 metadata_content.len()
1062 );
1063
1064 Ok(MavenFetch {
1065 summary: format!(
1066 "HTTP {} ({} bytes of maven-metadata.xml)",
1067 status_code,
1068 metadata_content.len()
1069 ),
1070 event_data: serde_json::json!({
1071 "group_id": group_id,
1072 "artifact_id": artifact_id,
1073 "url": metadata_url,
1074 "metadata_content": metadata_content,
1075 }),
1076 })
1077 } else {
1078 let error_msg = format!("Metadata not found: HTTP {}", status_code);
1079 Log::new(Some(status_tx))
1080 .error(format!("Maven client {} error: {}", client_id, error_msg));
1081 Err(anyhow::anyhow!(error_msg))
1082 }
1083 }
1084 Err(e) => {
1085 Log::new(Some(status_tx)).error(format!(
1086 "Maven client {} metadata fetch failed: {}",
1087 client_id, e
1088 ));
1089 Err(e)
1090 }
1091 }
1092 }
1093
1094 async fn repository_url(app_state: &AppState, client_id: ClientId) -> Result<String> {
1096 app_state
1097 .with_client_mut(client_id, |client| {
1098 client
1099 .get_protocol_field("repository_url")
1100 .and_then(|v| v.as_str())
1101 .map(|s| s.to_string())
1102 })
1103 .await
1104 .flatten()
1105 .context("No repository URL found")
1106 }
1107
1108 async fn http_client() -> Result<FetchClient> {
1113 shared_http_client().await
1114 }
1115}