Skip to main content

par_term/profile/
dynamic.rs

1//! Dynamic profile source configuration types
2//!
3//! Defines the configuration for fetching profiles from remote URLs,
4//! caching fetched profiles, HTTP fetch logic, merge strategies,
5//! and background fetch management via tokio tasks.
6
7// Re-export configuration types from par-term-config
8pub 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
19// ── Cache storage ──────────────────────────────────────────────────────
20
21/// Get the cache directory for dynamic profiles
22pub 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
30/// Generate a deterministic filename from a URL
31pub 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/// Cache metadata stored alongside profile data
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CacheMeta {
41    /// The source URL this cache entry corresponds to
42    pub url: String,
43    /// When the profiles were last fetched
44    pub last_fetched: SystemTime,
45    /// HTTP ETag header from the server (for conditional requests)
46    pub etag: Option<String>,
47    /// Number of profiles in the cached data
48    pub profile_count: usize,
49}
50
51/// Read cached profiles for a given URL
52pub 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
71/// Write profiles and metadata to cache
72pub 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// ── HTTP fetch and profile parsing ─────────────────────────────────────
105
106/// Result of fetching profiles from a remote source
107#[derive(Debug, Clone)]
108pub struct FetchResult {
109    /// The source URL that was fetched
110    pub url: String,
111    /// Successfully parsed profiles (empty on error)
112    pub profiles: Vec<par_term_config::Profile>,
113    /// HTTP ETag header from the response
114    pub etag: Option<String>,
115    /// Error message if the fetch failed
116    pub error: Option<String>,
117}
118
119/// Fetch profiles from a remote URL
120pub 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
159/// Internal fetch implementation
160fn 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    // Warn if using HTTP with auth headers (credential leaking risk)
166    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    // Create an agent with the source-specific timeout
184    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
226// ── Profile merge logic ────────────────────────────────────────────────
227
228/// Merge dynamic profiles into a ProfileManager
229///
230/// 1. Remove existing dynamic profiles from this URL
231/// 2. For each remote profile, check for name conflicts with local profiles
232/// 3. Apply conflict resolution strategy
233/// 4. Mark merged profiles with Dynamic source
234pub 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    // Remove existing dynamic profiles from this URL
241    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    // Merge remote profiles
254    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// ── Background fetch manager ────────────────────────────────────────
292
293/// Message sent from background fetch tasks to the main thread
294#[derive(Debug, Clone)]
295pub struct DynamicProfileUpdate {
296    /// The source URL that was fetched
297    pub url: String,
298    /// Successfully parsed profiles (empty on error)
299    pub profiles: Vec<par_term_config::Profile>,
300    /// How to resolve conflicts with local profiles
301    pub conflict_resolution: ConflictResolution,
302    /// Error message if the fetch failed
303    pub error: Option<String>,
304}
305
306/// Status of a dynamic profile source
307#[derive(Debug, Clone)]
308pub struct SourceStatus {
309    /// The source URL
310    pub url: String,
311    /// Whether this source is enabled
312    pub enabled: bool,
313    /// When profiles were last successfully fetched
314    pub last_fetch: Option<SystemTime>,
315    /// Last error message (if any)
316    pub last_error: Option<String>,
317    /// Number of profiles from this source
318    pub profile_count: usize,
319    /// Whether a fetch is currently in progress
320    pub fetching: bool,
321}
322
323/// Manages background fetching of dynamic profiles
324///
325/// Spawns tokio tasks that periodically fetch profiles from remote URLs
326/// and sends updates via an mpsc channel for the main thread to process.
327pub struct DynamicProfileManager {
328    /// Channel receiver for updates from background tasks
329    pub update_rx: mpsc::UnboundedReceiver<DynamicProfileUpdate>,
330    /// Channel sender (cloned to background tasks)
331    update_tx: mpsc::UnboundedSender<DynamicProfileUpdate>,
332    /// Status of each source, keyed by URL
333    pub statuses: HashMap<String, SourceStatus>,
334    /// Handles to cancel background tasks
335    task_handles: Vec<tokio::task::JoinHandle<()>>,
336}
337
338impl DynamicProfileManager {
339    /// Create a new DynamicProfileManager with fresh channels
340    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    /// Start background fetch tasks for all enabled sources.
351    ///
352    /// Stops any existing tasks first. For each enabled source:
353    /// 1. Initializes the source status
354    /// 2. Loads cached profiles and sends them via the channel immediately
355    /// 3. Spawns a tokio task that does an initial fetch, then periodic refreshes
356    pub fn start(
357        &mut self,
358        sources: &[DynamicProfileSource],
359        runtime: &Arc<tokio::runtime::Runtime>,
360    ) {
361        // Cancel existing tasks
362        self.stop();
363
364        for source in sources {
365            if !source.enabled || source.url.is_empty() {
366                continue;
367            }
368
369            // Initialize status
370            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            // Load from cache immediately
383            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            // Spawn background fetch task
399            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                // Initial fetch using spawn_blocking since ureq is synchronous
404                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; // Receiver dropped
418                        }
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                // Periodic refresh
430                let mut interval =
431                    tokio::time::interval(Duration::from_secs(source_clone.refresh_interval_secs));
432                interval.tick().await; // Skip first immediate tick
433                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; // Receiver dropped
450                            }
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    /// Stop all background fetch tasks
472    pub fn stop(&mut self) {
473        for handle in self.task_handles.drain(..) {
474            handle.abort();
475        }
476    }
477
478    /// Trigger an immediate refresh of all enabled sources
479    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    /// Trigger an immediate refresh of a specific source
493    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    /// Check for pending updates (non-blocking)
528    pub fn try_recv(&mut self) -> Option<DynamicProfileUpdate> {
529        self.update_rx.try_recv().ok()
530    }
531
532    /// Update source status after receiving an update
533    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    // ── Cache tests ────────────────────────────────────────────────────
634
635    #[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        // Write
664        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        // Read back
675        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    // ── Merge tests ────────────────────────────────────────────────────
688
689    #[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        // Shared Profile should still be Local
715        let shared = manager.find_by_name("Shared Profile").unwrap();
716        assert_eq!(shared.source, ProfileSource::Local);
717
718        // Remote Only should be Dynamic
719        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}