1pub use par_term_config::{ConflictResolution, DynamicProfileSource};
9
10use anyhow::Context;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::{Duration, SystemTime};
17use tokio::sync::mpsc;
18
19pub fn cache_dir() -> PathBuf {
23 dirs::config_dir()
24 .unwrap_or_else(|| PathBuf::from("."))
25 .join("par-term")
26 .join("cache")
27 .join("dynamic_profiles")
28}
29
30pub fn url_to_cache_filename(url: &str) -> String {
32 let mut hasher = Sha256::new();
33 hasher.update(url.as_bytes());
34 let hash = hasher.finalize();
35 format!("{:x}", hash)
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CacheMeta {
41 pub url: String,
43 pub last_fetched: SystemTime,
45 pub etag: Option<String>,
47 pub profile_count: usize,
49}
50
51pub fn read_cache(url: &str) -> anyhow::Result<(Vec<par_term_config::Profile>, CacheMeta)> {
53 let dir = cache_dir();
54 let hash = url_to_cache_filename(url);
55 let data_path = dir.join(format!("{hash}.yaml"));
56 let meta_path = dir.join(format!("{hash}.meta"));
57
58 let data = std::fs::read_to_string(&data_path)
59 .with_context(|| format!("Failed to read cache data from {data_path:?}"))?;
60 let meta_str = std::fs::read_to_string(&meta_path)
61 .with_context(|| format!("Failed to read cache meta from {meta_path:?}"))?;
62
63 let profiles: Vec<par_term_config::Profile> =
64 serde_yaml::from_str(&data).with_context(|| "Failed to parse cached profiles")?;
65 let meta: CacheMeta =
66 serde_json::from_str(&meta_str).with_context(|| "Failed to parse cache metadata")?;
67
68 Ok((profiles, meta))
69}
70
71pub fn write_cache(
73 url: &str,
74 profiles: &[par_term_config::Profile],
75 etag: Option<String>,
76) -> anyhow::Result<()> {
77 let dir = cache_dir();
78 std::fs::create_dir_all(&dir)
79 .with_context(|| format!("Failed to create cache directory {dir:?}"))?;
80
81 let hash = url_to_cache_filename(url);
82 let data_path = dir.join(format!("{hash}.yaml"));
83 let meta_path = dir.join(format!("{hash}.meta"));
84
85 let data = serde_yaml::to_string(profiles)
86 .with_context(|| "Failed to serialize profiles for cache")?;
87 std::fs::write(&data_path, data)
88 .with_context(|| format!("Failed to write cache data to {data_path:?}"))?;
89
90 let meta = CacheMeta {
91 url: url.to_string(),
92 last_fetched: SystemTime::now(),
93 etag,
94 profile_count: profiles.len(),
95 };
96 let meta_str = serde_json::to_string_pretty(&meta)
97 .with_context(|| "Failed to serialize cache metadata")?;
98 std::fs::write(&meta_path, meta_str)
99 .with_context(|| format!("Failed to write cache meta to {meta_path:?}"))?;
100
101 Ok(())
102}
103
104#[derive(Debug, Clone)]
108pub struct FetchResult {
109 pub url: String,
111 pub profiles: Vec<par_term_config::Profile>,
113 pub etag: Option<String>,
115 pub error: Option<String>,
117}
118
119pub fn fetch_profiles(source: &DynamicProfileSource) -> FetchResult {
121 let url = &source.url;
122 crate::debug_info!("DYNAMIC_PROFILE", "Fetching profiles from {}", url);
123
124 match fetch_profiles_inner(source) {
125 Ok((profiles, etag)) => {
126 crate::debug_info!(
127 "DYNAMIC_PROFILE",
128 "Fetched {} profiles from {}",
129 profiles.len(),
130 url
131 );
132 if let Err(e) = write_cache(url, &profiles, etag.clone()) {
133 crate::debug_error!(
134 "DYNAMIC_PROFILE",
135 "Failed to cache profiles from {}: {}",
136 url,
137 e
138 );
139 }
140 FetchResult {
141 url: url.clone(),
142 profiles,
143 etag,
144 error: None,
145 }
146 }
147 Err(e) => {
148 crate::debug_error!("DYNAMIC_PROFILE", "Failed to fetch from {}: {}", url, e);
149 FetchResult {
150 url: url.clone(),
151 profiles: Vec::new(),
152 etag: None,
153 error: Some(e.to_string()),
154 }
155 }
156 }
157}
158
159fn fetch_profiles_inner(
161 source: &DynamicProfileSource,
162) -> anyhow::Result<(Vec<par_term_config::Profile>, Option<String>)> {
163 use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
164
165 if !source.url.starts_with("https://") && !source.url.starts_with("file://") {
167 if source.headers.keys().any(|k| {
168 let lower = k.to_lowercase();
169 lower == "authorization" || lower.contains("token") || lower.contains("secret")
170 }) {
171 anyhow::bail!(
172 "Refusing to send authentication headers over insecure HTTP for {}. Use HTTPS.",
173 source.url
174 );
175 }
176 crate::debug_info!(
177 "DYNAMIC_PROFILE",
178 "Warning: {} uses insecure HTTP. Consider using HTTPS.",
179 source.url
180 );
181 }
182
183 let tls_config = TlsConfig::builder()
185 .provider(TlsProvider::NativeTls)
186 .root_certs(RootCerts::PlatformVerifier)
187 .build();
188
189 let agent: ureq::Agent = ureq::Agent::config_builder()
190 .tls_config(tls_config)
191 .timeout_global(Some(std::time::Duration::from_secs(
192 source.fetch_timeout_secs,
193 )))
194 .build()
195 .into();
196
197 let mut request = agent.get(&source.url);
198
199 for (key, value) in &source.headers {
200 request = request.header(key.as_str(), value.as_str());
201 }
202
203 let mut response = request
204 .call()
205 .with_context(|| format!("HTTP request failed for {}", source.url))?;
206
207 let etag = response
208 .headers()
209 .get("etag")
210 .and_then(|v| v.to_str().ok())
211 .map(|s| s.to_string());
212
213 let body = response
214 .body_mut()
215 .with_config()
216 .limit(source.max_size_bytes as u64)
217 .read_to_string()
218 .with_context(|| format!("Failed to read response body from {}", source.url))?;
219
220 let profiles: Vec<par_term_config::Profile> = serde_yaml::from_str(&body)
221 .with_context(|| format!("Failed to parse YAML from {}", source.url))?;
222
223 Ok((profiles, etag))
224}
225
226pub fn merge_dynamic_profiles(
235 manager: &mut par_term_config::ProfileManager,
236 remote_profiles: &[par_term_config::Profile],
237 url: &str,
238 conflict_resolution: &ConflictResolution,
239) {
240 let to_remove: Vec<par_term_config::ProfileId> = manager
242 .profiles_ordered()
243 .iter()
244 .filter(
245 |p| matches!(&p.source, par_term_config::ProfileSource::Dynamic { url: u, .. } if u == url),
246 )
247 .map(|p| p.id)
248 .collect();
249 for id in &to_remove {
250 manager.remove(id);
251 }
252
253 let now = SystemTime::now();
255 for remote in remote_profiles {
256 let existing = manager.find_by_name(&remote.name);
257 match (existing, conflict_resolution) {
258 (Some(_), ConflictResolution::LocalWins) => {
259 crate::debug_info!("DYNAMIC_PROFILE", "Skipping '{}' (local wins)", remote.name);
260 }
261 (Some(local), ConflictResolution::RemoteWins) => {
262 let local_id = local.id;
263 manager.remove(&local_id);
264 let mut profile = remote.clone();
265 profile.id = uuid::Uuid::new_v4();
266 profile.source = par_term_config::ProfileSource::Dynamic {
267 url: url.to_string(),
268 last_fetched: Some(now),
269 };
270 manager.add(profile);
271 crate::debug_info!(
272 "DYNAMIC_PROFILE",
273 "Remote '{}' overwrites local",
274 remote.name
275 );
276 }
277 (None, _) => {
278 let mut profile = remote.clone();
279 profile.id = uuid::Uuid::new_v4();
280 profile.source = par_term_config::ProfileSource::Dynamic {
281 url: url.to_string(),
282 last_fetched: Some(now),
283 };
284 manager.add(profile);
285 crate::debug_info!("DYNAMIC_PROFILE", "Added remote '{}'", remote.name);
286 }
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
295pub struct DynamicProfileUpdate {
296 pub url: String,
298 pub profiles: Vec<par_term_config::Profile>,
300 pub conflict_resolution: ConflictResolution,
302 pub error: Option<String>,
304}
305
306#[derive(Debug, Clone)]
308pub struct SourceStatus {
309 pub url: String,
311 pub enabled: bool,
313 pub last_fetch: Option<SystemTime>,
315 pub last_error: Option<String>,
317 pub profile_count: usize,
319 pub fetching: bool,
321}
322
323pub struct DynamicProfileManager {
328 pub update_rx: mpsc::UnboundedReceiver<DynamicProfileUpdate>,
330 update_tx: mpsc::UnboundedSender<DynamicProfileUpdate>,
332 pub statuses: HashMap<String, SourceStatus>,
334 task_handles: Vec<tokio::task::JoinHandle<()>>,
336}
337
338impl DynamicProfileManager {
339 pub fn new() -> Self {
341 let (update_tx, update_rx) = mpsc::unbounded_channel();
342 Self {
343 update_rx,
344 update_tx,
345 statuses: HashMap::new(),
346 task_handles: Vec::new(),
347 }
348 }
349
350 pub fn start(
357 &mut self,
358 sources: &[DynamicProfileSource],
359 runtime: &Arc<tokio::runtime::Runtime>,
360 ) {
361 self.stop();
363
364 for source in sources {
365 if !source.enabled || source.url.is_empty() {
366 continue;
367 }
368
369 self.statuses.insert(
371 source.url.clone(),
372 SourceStatus {
373 url: source.url.clone(),
374 enabled: source.enabled,
375 last_fetch: None,
376 last_error: None,
377 profile_count: 0,
378 fetching: false,
379 },
380 );
381
382 if let Ok((profiles, meta)) = read_cache(&source.url) {
384 let update = DynamicProfileUpdate {
385 url: source.url.clone(),
386 profiles,
387 conflict_resolution: source.conflict_resolution.clone(),
388 error: None,
389 };
390 let _ = self.update_tx.send(update);
391
392 if let Some(status) = self.statuses.get_mut(&source.url) {
393 status.last_fetch = Some(meta.last_fetched);
394 status.profile_count = meta.profile_count;
395 }
396 }
397
398 let tx = self.update_tx.clone();
400 let source_clone = source.clone();
401 let url_for_log = source.url.clone();
402 let handle = runtime.spawn(async move {
403 let src = source_clone.clone();
405 let conflict = source_clone.conflict_resolution.clone();
406 match tokio::task::spawn_blocking(move || fetch_profiles(&src)).await {
407 Ok(result) => {
408 if tx
409 .send(DynamicProfileUpdate {
410 url: result.url.clone(),
411 profiles: result.profiles,
412 conflict_resolution: conflict,
413 error: result.error,
414 })
415 .is_err()
416 {
417 return; }
419 }
420 Err(e) => {
421 log::error!(
422 "Dynamic profile fetch task panicked for {}: {}",
423 url_for_log,
424 e
425 );
426 }
427 }
428
429 let mut interval =
431 tokio::time::interval(Duration::from_secs(source_clone.refresh_interval_secs));
432 interval.tick().await; loop {
434 interval.tick().await;
435 let src = source_clone.clone();
436 let source_clone2 = source_clone.clone();
437 let tx_clone = tx.clone();
438 match tokio::task::spawn_blocking(move || fetch_profiles(&src)).await {
439 Ok(result) => {
440 if tx_clone
441 .send(DynamicProfileUpdate {
442 url: result.url.clone(),
443 profiles: result.profiles,
444 conflict_resolution: source_clone2.conflict_resolution.clone(),
445 error: result.error,
446 })
447 .is_err()
448 {
449 break; }
451 }
452 Err(e) => {
453 log::error!(
454 "Dynamic profile fetch task panicked for {}: {}",
455 url_for_log,
456 e
457 );
458 }
459 }
460 }
461 });
462
463 self.task_handles.push(handle);
464
465 if let Some(status) = self.statuses.get_mut(&source.url) {
466 status.fetching = true;
467 }
468 }
469 }
470
471 pub fn stop(&mut self) {
473 for handle in self.task_handles.drain(..) {
474 handle.abort();
475 }
476 }
477
478 pub fn refresh_all(
480 &mut self,
481 sources: &[DynamicProfileSource],
482 runtime: &Arc<tokio::runtime::Runtime>,
483 ) {
484 for source in sources {
485 if !source.enabled || source.url.is_empty() {
486 continue;
487 }
488 self.refresh_source(source, runtime);
489 }
490 }
491
492 pub fn refresh_source(
494 &mut self,
495 source: &DynamicProfileSource,
496 runtime: &Arc<tokio::runtime::Runtime>,
497 ) {
498 let tx = self.update_tx.clone();
499 let source_clone = source.clone();
500 let url_for_log = source.url.clone();
501 runtime.spawn(async move {
502 let conflict = source_clone.conflict_resolution.clone();
503 match tokio::task::spawn_blocking(move || fetch_profiles(&source_clone)).await {
504 Ok(result) => {
505 let _ = tx.send(DynamicProfileUpdate {
506 url: result.url.clone(),
507 profiles: result.profiles,
508 conflict_resolution: conflict,
509 error: result.error,
510 });
511 }
512 Err(e) => {
513 log::error!(
514 "Dynamic profile fetch task panicked for {}: {}",
515 url_for_log,
516 e
517 );
518 }
519 }
520 });
521
522 if let Some(status) = self.statuses.get_mut(&source.url) {
523 status.fetching = true;
524 }
525 }
526
527 pub fn try_recv(&mut self) -> Option<DynamicProfileUpdate> {
529 self.update_rx.try_recv().ok()
530 }
531
532 pub fn update_status(&mut self, update: &DynamicProfileUpdate) {
534 if let Some(status) = self.statuses.get_mut(&update.url) {
535 status.fetching = false;
536 status.last_error = update.error.clone();
537 if update.error.is_none() {
538 status.last_fetch = Some(SystemTime::now());
539 status.profile_count = update.profiles.len();
540 }
541 }
542 }
543}
544
545impl Default for DynamicProfileManager {
546 fn default() -> Self {
547 Self::new()
548 }
549}
550
551impl Drop for DynamicProfileManager {
552 fn drop(&mut self) {
553 self.stop();
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_default_source() {
563 let source = DynamicProfileSource::default();
564
565 assert_eq!(source.url, "");
566 assert!(source.headers.is_empty());
567 assert_eq!(source.refresh_interval_secs, 1800);
568 assert_eq!(source.max_size_bytes, 1_048_576);
569 assert_eq!(source.fetch_timeout_secs, 10);
570 assert!(source.enabled);
571 assert_eq!(source.conflict_resolution, ConflictResolution::LocalWins);
572 }
573
574 #[test]
575 fn test_serialize_deserialize_roundtrip() {
576 let mut headers = HashMap::new();
577 headers.insert("Authorization".to_string(), "Bearer tok123".to_string());
578 headers.insert("X-Custom".to_string(), "value".to_string());
579
580 let source = DynamicProfileSource {
581 url: "https://example.com/profiles.yaml".to_string(),
582 headers,
583 refresh_interval_secs: 900,
584 max_size_bytes: 512_000,
585 fetch_timeout_secs: 15,
586 enabled: false,
587 conflict_resolution: ConflictResolution::RemoteWins,
588 };
589
590 let yaml = serde_yaml::to_string(&source).expect("serialize");
591 let deserialized: DynamicProfileSource = serde_yaml::from_str(&yaml).expect("deserialize");
592
593 assert_eq!(deserialized.url, source.url);
594 assert_eq!(deserialized.headers, source.headers);
595 assert_eq!(
596 deserialized.refresh_interval_secs,
597 source.refresh_interval_secs
598 );
599 assert_eq!(deserialized.max_size_bytes, source.max_size_bytes);
600 assert_eq!(deserialized.fetch_timeout_secs, source.fetch_timeout_secs);
601 assert_eq!(deserialized.enabled, source.enabled);
602 assert_eq!(deserialized.conflict_resolution, source.conflict_resolution);
603 }
604
605 #[test]
606 fn test_deserialize_minimal_yaml() {
607 let yaml = "url: https://example.com/profiles.yaml\n";
608 let source: DynamicProfileSource = serde_yaml::from_str(yaml).expect("deserialize minimal");
609
610 assert_eq!(source.url, "https://example.com/profiles.yaml");
611 assert!(source.headers.is_empty());
612 assert_eq!(source.refresh_interval_secs, 1800);
613 assert_eq!(source.max_size_bytes, 1_048_576);
614 assert_eq!(source.fetch_timeout_secs, 10);
615 assert!(source.enabled);
616 assert_eq!(source.conflict_resolution, ConflictResolution::LocalWins);
617 }
618
619 #[test]
620 fn test_conflict_resolution_display() {
621 assert_eq!(ConflictResolution::LocalWins.display_name(), "Local Wins");
622 assert_eq!(ConflictResolution::RemoteWins.display_name(), "Remote Wins");
623 }
624
625 #[test]
626 fn test_conflict_resolution_variants() {
627 let variants = ConflictResolution::variants();
628 assert_eq!(variants.len(), 2);
629 assert_eq!(variants[0], ConflictResolution::LocalWins);
630 assert_eq!(variants[1], ConflictResolution::RemoteWins);
631 }
632
633 #[test]
636 fn test_url_to_cache_filename_deterministic() {
637 let url = "https://example.com/profiles.yaml";
638 let a = super::url_to_cache_filename(url);
639 let b = super::url_to_cache_filename(url);
640 assert_eq!(a, b);
641 assert!(!a.is_empty());
642 }
643
644 #[test]
645 fn test_url_to_cache_filename_different_urls() {
646 let a = super::url_to_cache_filename("https://example.com/a.yaml");
647 let b = super::url_to_cache_filename("https://example.com/b.yaml");
648 assert_ne!(a, b);
649 }
650
651 #[test]
652 fn test_cache_roundtrip() {
653 let temp = tempfile::tempdir().unwrap();
654 let url = "https://test.example.com/profiles.yaml";
655 let profiles = vec![
656 par_term_config::Profile::new("Remote Profile 1"),
657 par_term_config::Profile::new("Remote Profile 2"),
658 ];
659 let hash = super::url_to_cache_filename(url);
660 let data_path = temp.path().join(format!("{hash}.yaml"));
661 let meta_path = temp.path().join(format!("{hash}.meta"));
662
663 let data = serde_yaml::to_string(&profiles).unwrap();
665 std::fs::write(&data_path, &data).unwrap();
666 let meta = super::CacheMeta {
667 url: url.to_string(),
668 last_fetched: std::time::SystemTime::now(),
669 etag: Some("abc123".to_string()),
670 profile_count: 2,
671 };
672 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
673
674 let read_profiles: Vec<par_term_config::Profile> =
676 serde_yaml::from_str(&std::fs::read_to_string(&data_path).unwrap()).unwrap();
677 assert_eq!(read_profiles.len(), 2);
678 assert_eq!(read_profiles[0].name, "Remote Profile 1");
679
680 let read_meta: super::CacheMeta =
681 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
682 assert_eq!(read_meta.url, url);
683 assert_eq!(read_meta.profile_count, 2);
684 assert_eq!(read_meta.etag, Some("abc123".to_string()));
685 }
686
687 #[test]
690 fn test_merge_local_wins() {
691 use par_term_config::{Profile, ProfileManager, ProfileSource};
692 let mut manager = ProfileManager::new();
693 manager.add(Profile::new("Shared Profile"));
694 manager.add(Profile::new("Local Only"));
695
696 let remote = vec![Profile::new("Shared Profile"), Profile::new("Remote Only")];
697
698 super::merge_dynamic_profiles(
699 &mut manager,
700 &remote,
701 "https://example.com/p.yaml",
702 &super::ConflictResolution::LocalWins,
703 );
704
705 let names: Vec<String> = manager
706 .profiles_ordered()
707 .iter()
708 .map(|p| p.name.clone())
709 .collect();
710 assert!(names.contains(&"Shared Profile".to_string()));
711 assert!(names.contains(&"Local Only".to_string()));
712 assert!(names.contains(&"Remote Only".to_string()));
713
714 let shared = manager.find_by_name("Shared Profile").unwrap();
716 assert_eq!(shared.source, ProfileSource::Local);
717
718 let remote_p = manager.find_by_name("Remote Only").unwrap();
720 assert!(matches!(remote_p.source, ProfileSource::Dynamic { .. }));
721 }
722
723 #[test]
724 fn test_merge_remote_wins() {
725 use par_term_config::{Profile, ProfileManager, ProfileSource};
726 let mut manager = ProfileManager::new();
727 manager.add(Profile::new("Shared Profile"));
728
729 let remote = vec![Profile::new("Shared Profile")];
730
731 super::merge_dynamic_profiles(
732 &mut manager,
733 &remote,
734 "https://example.com/p.yaml",
735 &super::ConflictResolution::RemoteWins,
736 );
737
738 let shared = manager.find_by_name("Shared Profile").unwrap();
739 assert!(matches!(shared.source, ProfileSource::Dynamic { .. }));
740 }
741
742 #[test]
743 fn test_merge_removes_stale_dynamic_profiles() {
744 use par_term_config::{Profile, ProfileManager, ProfileSource};
745 let mut manager = ProfileManager::new();
746 let mut old = Profile::new("Old Remote");
747 old.source = ProfileSource::Dynamic {
748 url: "https://example.com/p.yaml".to_string(),
749 last_fetched: None,
750 };
751 manager.add(old);
752
753 let remote = vec![Profile::new("New Remote")];
754
755 super::merge_dynamic_profiles(
756 &mut manager,
757 &remote,
758 "https://example.com/p.yaml",
759 &super::ConflictResolution::LocalWins,
760 );
761
762 let names: Vec<String> = manager
763 .profiles_ordered()
764 .iter()
765 .map(|p| p.name.clone())
766 .collect();
767 assert!(!names.contains(&"Old Remote".to_string()));
768 assert!(names.contains(&"New Remote".to_string()));
769 }
770}