Skip to main content

coding_agent_search/pages/
profiles.rs

1//! Share Profiles & Privacy Presets.
2//!
3//! This module provides pre-configured privacy profiles that simplify the redaction
4//! process for common sharing scenarios. Users can select a profile instead of
5//! manually configuring every option.
6//!
7//! ## Available Profiles
8//!
9//! - **Public**: Maximum redaction for public internet sharing
10//! - **Team**: Moderate redaction for internal team sharing
11//! - **Personal**: Minimal redaction for personal backups
12//! - **Custom**: Manual configuration of all options
13
14use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16use std::io::Write;
17use std::path::PathBuf;
18
19use crate::pages::patterns::{patterns_for_personal, patterns_for_public, patterns_for_team};
20use crate::pages::redact::RedactionConfig;
21
22/// Pre-configured privacy profile for sharing sessions.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum ShareProfile {
26    /// Maximum privacy - safe for public internet.
27    /// Redacts usernames, paths, project names, emails, hostnames, and all detected secrets.
28    Public,
29    /// Team/organization sharing - internal refs OK.
30    /// Keeps project context but redacts external credentials and personal info.
31    #[default]
32    Team,
33    /// Personal backup - minimal redaction.
34    /// Only removes critical secrets like private keys and cloud provider credentials.
35    Personal,
36    /// Manual configuration of all options.
37    Custom,
38}
39
40impl ShareProfile {
41    /// Human-readable name of the profile.
42    pub fn name(self) -> &'static str {
43        match self {
44            Self::Public => "Public",
45            Self::Team => "Team",
46            Self::Personal => "Personal",
47            Self::Custom => "Custom",
48        }
49    }
50
51    /// Detailed description of what this profile does.
52    pub fn description(self) -> &'static str {
53        match self {
54            Self::Public => {
55                "Maximum privacy for public sharing. Redacts usernames, paths, project names, emails, hostnames, and all detected secrets."
56            }
57            Self::Team => {
58                "For internal team sharing. Keeps project context but redacts external credentials and personal information."
59            }
60            Self::Personal => {
61                "Personal backup with minimal redaction. Only removes critical secrets like private keys and API keys."
62            }
63            Self::Custom => "Configure each redaction option manually for fine-grained control.",
64        }
65    }
66
67    /// Icon/emoji representing the profile.
68    pub fn icon(self) -> &'static str {
69        match self {
70            Self::Public => "🌐",
71            Self::Team => "πŸ‘₯",
72            Self::Personal => "πŸ”’",
73            Self::Custom => "βš™οΈ",
74        }
75    }
76
77    /// Short label for UI chips/tags.
78    pub fn label(self) -> &'static str {
79        match self {
80            Self::Public => "public",
81            Self::Team => "team",
82            Self::Personal => "personal",
83            Self::Custom => "custom",
84        }
85    }
86
87    /// Convert profile to a RedactionConfig with appropriate settings.
88    pub fn to_redaction_config(self) -> RedactionConfig {
89        match self {
90            Self::Public => RedactionConfig {
91                redact_home_paths: true,
92                redact_usernames: true,
93                anonymize_project_names: true,
94                redact_hostnames: true,
95                redact_emails: true,
96                block_on_critical_secrets: true,
97                custom_patterns: patterns_for_public(),
98                ..Default::default()
99            },
100            Self::Team => RedactionConfig {
101                redact_home_paths: true,
102                redact_usernames: false,        // Team knows usernames
103                anonymize_project_names: false, // Project context useful
104                redact_hostnames: false,        // Internal hostnames OK
105                redact_emails: true,            // External emails redacted
106                block_on_critical_secrets: true,
107                custom_patterns: patterns_for_team(),
108                ..Default::default()
109            },
110            Self::Personal => RedactionConfig {
111                redact_home_paths: false,
112                redact_usernames: false,
113                anonymize_project_names: false,
114                redact_hostnames: false,
115                redact_emails: false,
116                block_on_critical_secrets: true, // Always block critical
117                custom_patterns: patterns_for_personal(),
118                ..Default::default()
119            },
120            Self::Custom => RedactionConfig::default(),
121        }
122    }
123
124    /// Get all available profiles.
125    pub fn all() -> &'static [Self] {
126        &[Self::Public, Self::Team, Self::Personal, Self::Custom]
127    }
128}
129
130impl std::str::FromStr for ShareProfile {
131    type Err = String;
132
133    fn from_str(s: &str) -> Result<Self, Self::Err> {
134        let normalized = s.to_ascii_lowercase();
135        Self::all()
136            .iter()
137            .copied()
138            .find(|profile| profile.label() == normalized)
139            .ok_or_else(|| format!("Unknown profile: {}", s))
140    }
141}
142
143impl std::fmt::Display for ShareProfile {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        write!(f, "{} {}", self.icon(), self.name())
146    }
147}
148
149/// User's profile preferences, persisted across sessions.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ProfilePreferences {
152    /// Default profile to use when starting export.
153    #[serde(default)]
154    pub default_profile: ShareProfile,
155
156    /// Custom overrides when using Custom profile.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub custom_config: Option<SerializableRedactionConfig>,
159
160    /// Last profile used (for UI convenience).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub last_used: Option<ShareProfile>,
163}
164
165impl Default for ProfilePreferences {
166    fn default() -> Self {
167        Self {
168            default_profile: ShareProfile::Team,
169            custom_config: None,
170            last_used: None,
171        }
172    }
173}
174
175impl ProfilePreferences {
176    /// Load preferences from the default location.
177    pub fn load() -> Result<Self> {
178        let path = Self::default_path()?;
179        if !path.exists() {
180            return Ok(Self::default());
181        }
182        let content = std::fs::read_to_string(&path)
183            .with_context(|| format!("Failed to read {}", path.display()))?;
184        let prefs: Self = toml::from_str(&content)
185            .with_context(|| format!("Failed to parse {}", path.display()))?;
186        Ok(prefs)
187    }
188
189    /// Save preferences to the default location.
190    pub fn save(&self) -> Result<()> {
191        let path = Self::default_path()?;
192        if let Some(parent) = path.parent() {
193            std::fs::create_dir_all(parent)
194                .with_context(|| format!("Failed to create {}", parent.display()))?;
195        }
196
197        let content = toml::to_string_pretty(self).context("Failed to serialize preferences")?;
198
199        // Atomic write: write to a unique temp file in the same directory, then replace.
200        let temp_path = unique_atomic_temp_path(&path);
201        write_preferences_temp_file(&temp_path, &content)?;
202        replace_file_from_temp(&temp_path, &path)?;
203
204        Ok(())
205    }
206
207    /// Get the default path for profile preferences.
208    fn default_path() -> Result<PathBuf> {
209        let data_dir = crate::default_data_dir();
210        Ok(data_dir.join("profile_prefs.toml"))
211    }
212
213    /// Update last used profile.
214    pub fn set_last_used(&mut self, profile: ShareProfile) {
215        self.last_used = Some(profile);
216    }
217
218    /// Get the effective profile (last used or default).
219    pub fn effective_profile(&self) -> ShareProfile {
220        self.last_used.unwrap_or(self.default_profile)
221    }
222}
223
224fn write_preferences_temp_file(path: &std::path::Path, content: &str) -> Result<()> {
225    let mut file = std::fs::OpenOptions::new()
226        .write(true)
227        .create_new(true)
228        .open(path)
229        .with_context(|| {
230            format!(
231                "Failed to create temporary preferences file {}",
232                path.display()
233            )
234        })?;
235
236    file.write_all(content.as_bytes())
237        .with_context(|| format!("Failed to write {}", path.display()))?;
238    file.sync_all()
239        .with_context(|| format!("Failed to sync {}", path.display()))?;
240    Ok(())
241}
242
243fn replace_file_from_temp(temp_path: &std::path::Path, final_path: &std::path::Path) -> Result<()> {
244    if cfg!(windows) {
245        match std::fs::rename(temp_path, final_path) {
246            Ok(()) => {
247                sync_parent_directory(final_path)?;
248                Ok(())
249            }
250            Err(first_err) if replacement_path_entry_exists(final_path)? => {
251                replace_file_from_temp_via_backup(temp_path, final_path, &first_err)
252            }
253            Err(err) => Err(err).with_context(|| {
254                format!(
255                    "Failed to rename {} to {}",
256                    temp_path.display(),
257                    final_path.display()
258                )
259            }),
260        }
261    } else {
262        std::fs::rename(temp_path, final_path).with_context(|| {
263            format!(
264                "Failed to rename {} to {}",
265                temp_path.display(),
266                final_path.display()
267            )
268        })?;
269        sync_parent_directory(final_path)
270    }
271}
272
273fn replacement_path_entry_exists(path: &std::path::Path) -> Result<bool> {
274    match std::fs::symlink_metadata(path) {
275        Ok(_) => Ok(true),
276        Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(false),
277        Err(err) => Err(err).with_context(|| format!("Failed to inspect {}", path.display())),
278    }
279}
280
281fn replace_file_from_temp_via_backup(
282    temp_path: &std::path::Path,
283    final_path: &std::path::Path,
284    first_err: &std::io::Error,
285) -> Result<()> {
286    let backup_path = unique_atomic_backup_path(final_path);
287    std::fs::rename(final_path, &backup_path).with_context(|| {
288        let _ = std::fs::remove_file(temp_path);
289        format!(
290            "Failed preparing backup {} before replacing {} after {}",
291            backup_path.display(),
292            final_path.display(),
293            first_err
294        )
295    })?;
296
297    match std::fs::rename(temp_path, final_path) {
298        Ok(()) => {
299            let _ = std::fs::remove_file(&backup_path);
300            sync_parent_directory(final_path)?;
301            Ok(())
302        }
303        Err(second_err) => match std::fs::rename(&backup_path, final_path) {
304            Ok(()) => {
305                let _ = std::fs::remove_file(temp_path);
306                sync_parent_directory(final_path)?;
307                anyhow::bail!(
308                    "Failed replacing {} with {}: {}; restored original preferences",
309                    final_path.display(),
310                    temp_path.display(),
311                    second_err
312                );
313            }
314            Err(restore_err) => {
315                anyhow::bail!(
316                    "Failed replacing {} with {}: {}; restore error: {}; temp file retained at {}",
317                    final_path.display(),
318                    temp_path.display(),
319                    second_err,
320                    restore_err,
321                    temp_path.display()
322                );
323            }
324        },
325    }
326}
327
328#[cfg(not(windows))]
329fn sync_parent_directory(path: &std::path::Path) -> Result<()> {
330    let Some(parent) = path.parent() else {
331        return Ok(());
332    };
333    std::fs::File::open(parent)
334        .with_context(|| format!("Failed to open {} for sync", parent.display()))?
335        .sync_all()
336        .with_context(|| format!("Failed to sync {}", parent.display()))
337}
338
339#[cfg(windows)]
340fn sync_parent_directory(_path: &std::path::Path) -> Result<()> {
341    Ok(())
342}
343
344fn unique_atomic_temp_path(path: &std::path::Path) -> PathBuf {
345    unique_atomic_sidecar_path(path, "tmp", "profile_prefs.toml")
346}
347
348fn unique_atomic_backup_path(path: &std::path::Path) -> PathBuf {
349    unique_atomic_sidecar_path(path, "bak", "profile_prefs.toml")
350}
351
352fn unique_atomic_sidecar_path(
353    path: &std::path::Path,
354    suffix: &str,
355    fallback_name: &str,
356) -> PathBuf {
357    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
358
359    let timestamp = std::time::SystemTime::now()
360        .duration_since(std::time::UNIX_EPOCH)
361        .unwrap_or_default()
362        .as_nanos();
363    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
364    let file_name = path
365        .file_name()
366        .and_then(|name| name.to_str())
367        .unwrap_or(fallback_name);
368
369    path.with_file_name(format!(
370        ".{file_name}.{suffix}.{}.{}.{}",
371        std::process::id(),
372        timestamp,
373        nonce
374    ))
375}
376
377/// Serializable version of RedactionConfig for persistence.
378///
379/// This excludes compiled regex patterns since they can't be serialized.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct SerializableRedactionConfig {
382    pub redact_home_paths: bool,
383    pub redact_usernames: bool,
384    pub anonymize_project_names: bool,
385    pub redact_hostnames: bool,
386    pub redact_emails: bool,
387    pub block_on_critical_secrets: bool,
388    #[serde(default)]
389    pub custom_pattern_names: Vec<String>,
390}
391
392impl Default for SerializableRedactionConfig {
393    fn default() -> Self {
394        Self {
395            redact_home_paths: true,
396            redact_usernames: true,
397            anonymize_project_names: false,
398            redact_hostnames: false,
399            redact_emails: true,
400            block_on_critical_secrets: true,
401            custom_pattern_names: Vec::new(),
402        }
403    }
404}
405
406/// Render a comparison table of all profiles for display.
407pub fn render_profile_comparison() -> String {
408    let mut output = String::new();
409
410    output.push_str("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n");
411    output.push_str("β”‚ Setting              β”‚ Public  β”‚ Team    β”‚ Personal β”‚\n");
412    output.push_str("β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€\n");
413    output.push_str("β”‚ Redact home paths    β”‚    βœ“    β”‚    βœ“    β”‚    βœ—     β”‚\n");
414    output.push_str("β”‚ Redact usernames     β”‚    βœ“    β”‚    βœ—    β”‚    βœ—     β”‚\n");
415    output.push_str("β”‚ Anonymize projects   β”‚    βœ“    β”‚    βœ—    β”‚    βœ—     β”‚\n");
416    output.push_str("β”‚ Redact hostnames     β”‚    βœ“    β”‚    βœ—    β”‚    βœ—     β”‚\n");
417    output.push_str("β”‚ Redact emails        β”‚    βœ“    β”‚    βœ“    β”‚    βœ—     β”‚\n");
418    output.push_str("β”‚ Block critical       β”‚    βœ“    β”‚    βœ“    β”‚    βœ“     β”‚\n");
419    output.push_str("β”‚ Pattern categories   β”‚   All   β”‚ Externalβ”‚ Critical β”‚\n");
420    output.push_str("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n");
421
422    output
423}
424
425/// Render profile comparison for terminal with ANSI colors.
426pub fn render_profile_comparison_colored() -> String {
427    use console::style;
428
429    let mut output = String::new();
430
431    let check = style("βœ“").green().to_string();
432    let cross = style("βœ—").red().to_string();
433
434    output.push_str(&format!(
435        "{}",
436        style("Profile Comparison").bold().underlined()
437    ));
438    output.push('\n');
439    output.push('\n');
440
441    let headers = ["Setting", "🌐 Public", "πŸ‘₯ Team", "πŸ”’ Personal"];
442    let rows = [
443        ("Redact home paths", true, true, false),
444        ("Redact usernames", true, false, false),
445        ("Anonymize projects", true, false, false),
446        ("Redact hostnames", true, false, false),
447        ("Redact emails", true, true, false),
448        ("Block critical secrets", true, true, true),
449    ];
450
451    // Header
452    output.push_str(&format!(
453        "  {:<22} {:^10} {:^10} {:^10}\n",
454        headers[0], headers[1], headers[2], headers[3]
455    ));
456    output.push_str(&format!("  {}\n", "─".repeat(54)));
457
458    // Rows
459    for (setting, public, team, personal) in rows {
460        let p = if public { &check } else { &cross };
461        let t = if team { &check } else { &cross };
462        let pe = if personal { &check } else { &cross };
463        output.push_str(&format!(
464            "  {:<22} {:^10} {:^10} {:^10}\n",
465            setting, p, t, pe
466        ));
467    }
468
469    output
470}
471
472/// Information about a profile for display in selection UI.
473#[derive(Debug, Clone)]
474pub struct ProfileInfo {
475    pub profile: ShareProfile,
476    pub name: String,
477    pub description: String,
478    pub icon: String,
479    pub pattern_count: usize,
480}
481
482impl ProfileInfo {
483    pub fn from_profile(profile: ShareProfile) -> Self {
484        let config = profile.to_redaction_config();
485        Self {
486            profile,
487            name: profile.name().to_string(),
488            description: profile.description().to_string(),
489            icon: profile.icon().to_string(),
490            pattern_count: config.custom_patterns.len(),
491        }
492    }
493
494    pub fn all() -> Vec<Self> {
495        ShareProfile::all()
496            .iter()
497            .map(|&p| Self::from_profile(p))
498            .collect()
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn test_profile_configs_differ() {
508        let public = ShareProfile::Public.to_redaction_config();
509        let team = ShareProfile::Team.to_redaction_config();
510        let personal = ShareProfile::Personal.to_redaction_config();
511
512        // Public is most restrictive
513        assert!(public.redact_usernames);
514        assert!(public.anonymize_project_names);
515        assert!(public.redact_hostnames);
516        assert!(public.redact_emails);
517
518        // Team keeps some context
519        assert!(!team.redact_usernames);
520        assert!(!team.anonymize_project_names);
521        assert!(!team.redact_hostnames);
522        assert!(team.redact_emails);
523
524        // Personal is least restrictive
525        assert!(!personal.redact_home_paths);
526        assert!(!personal.redact_emails);
527        assert!(!personal.redact_hostnames);
528
529        // All block critical secrets
530        assert!(public.block_on_critical_secrets);
531        assert!(team.block_on_critical_secrets);
532        assert!(personal.block_on_critical_secrets);
533    }
534
535    #[test]
536    fn test_profile_descriptions_not_empty() {
537        for profile in ShareProfile::all() {
538            assert!(!profile.name().is_empty());
539            assert!(!profile.description().is_empty());
540            assert!(!profile.icon().is_empty());
541        }
542    }
543
544    #[test]
545    fn test_public_has_most_patterns() {
546        let public = ShareProfile::Public.to_redaction_config();
547        let team = ShareProfile::Team.to_redaction_config();
548        let personal = ShareProfile::Personal.to_redaction_config();
549
550        // Public should have the most patterns
551        assert!(public.custom_patterns.len() >= 10);
552
553        // Team should have fewer than public
554        assert!(team.custom_patterns.len() < public.custom_patterns.len());
555
556        // Personal should have the fewest
557        assert!(personal.custom_patterns.len() <= 6);
558    }
559
560    #[test]
561    fn test_profile_from_str() {
562        use std::str::FromStr;
563        assert_eq!(ShareProfile::from_str("public"), Ok(ShareProfile::Public));
564        assert_eq!(ShareProfile::from_str("PUBLIC"), Ok(ShareProfile::Public));
565        assert_eq!(ShareProfile::from_str("Team"), Ok(ShareProfile::Team));
566        assert_eq!(
567            ShareProfile::from_str("personal"),
568            Ok(ShareProfile::Personal)
569        );
570        assert_eq!(ShareProfile::from_str("custom"), Ok(ShareProfile::Custom));
571        assert!(ShareProfile::from_str("invalid").is_err());
572    }
573
574    #[test]
575    fn test_profile_labels_are_parse_spellings() {
576        use std::str::FromStr;
577
578        for profile in ShareProfile::all() {
579            assert_eq!(ShareProfile::from_str(profile.label()), Ok(*profile));
580        }
581    }
582
583    #[test]
584    fn test_profile_display() {
585        assert_eq!(format!("{}", ShareProfile::Public), "🌐 Public");
586        assert_eq!(format!("{}", ShareProfile::Team), "πŸ‘₯ Team");
587    }
588
589    #[test]
590    fn test_default_profile() {
591        let prefs = ProfilePreferences::default();
592        assert_eq!(prefs.default_profile, ShareProfile::Team);
593        assert!(prefs.last_used.is_none());
594    }
595
596    #[test]
597    fn test_effective_profile() {
598        let mut prefs = ProfilePreferences::default();
599        assert_eq!(prefs.effective_profile(), ShareProfile::Team);
600
601        prefs.set_last_used(ShareProfile::Public);
602        assert_eq!(prefs.effective_profile(), ShareProfile::Public);
603    }
604
605    #[test]
606    fn test_comparison_table_renders() {
607        let table = render_profile_comparison();
608        assert!(table.contains("Public"));
609        assert!(table.contains("Team"));
610        assert!(table.contains("Personal"));
611        assert!(table.contains("βœ“"));
612        assert!(table.contains("βœ—"));
613    }
614
615    #[test]
616    fn test_profile_info_all() {
617        let infos = ProfileInfo::all();
618        assert_eq!(infos.len(), 4);
619        assert!(infos.iter().any(|i| i.profile == ShareProfile::Public));
620        assert!(infos.iter().any(|i| i.profile == ShareProfile::Custom));
621    }
622
623    #[test]
624    fn test_serializable_config_default() {
625        let config = SerializableRedactionConfig::default();
626        assert!(config.redact_home_paths);
627        assert!(config.block_on_critical_secrets);
628    }
629
630    #[test]
631    fn test_profile_serialization() {
632        let prefs = ProfilePreferences {
633            default_profile: ShareProfile::Public,
634            custom_config: None,
635            last_used: Some(ShareProfile::Team),
636        };
637
638        let serialized = toml::to_string(&prefs).unwrap();
639        let deserialized: ProfilePreferences = toml::from_str(&serialized).unwrap();
640
641        assert_eq!(deserialized.default_profile, ShareProfile::Public);
642        assert_eq!(deserialized.last_used, Some(ShareProfile::Team));
643    }
644
645    #[test]
646    fn test_preferences_path_uses_default_data_dir() {
647        let path = ProfilePreferences::default_path().expect("default path");
648        assert_eq!(path, crate::default_data_dir().join("profile_prefs.toml"));
649    }
650
651    #[test]
652    fn test_unique_atomic_temp_path_changes_each_call() {
653        let final_path = std::path::Path::new("/tmp/profile_prefs.toml");
654        let first = unique_atomic_temp_path(final_path);
655        let second = unique_atomic_temp_path(final_path);
656        assert_ne!(first, second);
657    }
658
659    #[test]
660    fn test_unique_atomic_backup_path_changes_each_call() -> Result<()> {
661        let final_path = std::path::Path::new("/tmp/profile_prefs.toml");
662        let first = unique_atomic_backup_path(final_path);
663        let second = unique_atomic_backup_path(final_path);
664
665        if first == second {
666            return Err(anyhow::anyhow!(
667                "profile replacement backup path was reused: {}",
668                first.display()
669            ));
670        }
671
672        Ok(())
673    }
674
675    #[cfg(unix)]
676    #[test]
677    fn test_replacement_path_entry_exists_detects_dangling_symlink() -> Result<()> {
678        use std::os::unix::fs::symlink;
679        use tempfile::TempDir;
680
681        let temp_dir = TempDir::new()?;
682        let link_path = temp_dir.path().join("profile_prefs.toml");
683        let missing_target = temp_dir.path().join("missing-profile-prefs.toml");
684
685        symlink(&missing_target, &link_path)?;
686
687        if link_path.exists() {
688            return Err(anyhow::anyhow!(
689                "Path::exists stopped following the missing target"
690            ));
691        }
692        if !replacement_path_entry_exists(&link_path)? {
693            return Err(anyhow::anyhow!(
694                "replacement path helper missed a dangling symlink entry"
695            ));
696        }
697
698        Ok(())
699    }
700
701    #[test]
702    fn test_replace_file_from_temp_via_backup_overwrites_existing_file() -> Result<()> {
703        use tempfile::TempDir;
704
705        let temp_dir = TempDir::new()?;
706        let final_path = temp_dir.path().join("profile_prefs.toml");
707        let temp_path = temp_dir.path().join("profile_prefs.tmp");
708        let first_err = std::io::Error::from(std::io::ErrorKind::AlreadyExists);
709
710        std::fs::write(&final_path, "default_profile = \"team\"\n")?;
711        std::fs::write(&temp_path, "default_profile = \"public\"\n")?;
712
713        replace_file_from_temp_via_backup(&temp_path, &final_path, &first_err)?;
714
715        let content = std::fs::read_to_string(&final_path)?;
716        if !content.contains("public") {
717            return Err(anyhow::anyhow!(
718                "backup replacement did not publish temp preferences"
719            ));
720        }
721        if temp_path.exists() {
722            return Err(anyhow::anyhow!("preferences temp path was not consumed"));
723        }
724
725        Ok(())
726    }
727
728    #[test]
729    fn test_replace_file_from_temp_overwrites_existing_file() {
730        use tempfile::TempDir;
731
732        let temp_dir = TempDir::new().unwrap();
733        let final_path = temp_dir.path().join("profile_prefs.toml");
734        let first_tmp = temp_dir.path().join("first.tmp");
735        let second_tmp = temp_dir.path().join("second.tmp");
736
737        std::fs::write(&first_tmp, "default_profile = \"team\"\n").unwrap();
738        replace_file_from_temp(&first_tmp, &final_path).unwrap();
739        assert!(final_path.exists());
740        assert!(!first_tmp.exists());
741
742        std::fs::write(&second_tmp, "default_profile = \"public\"\n").unwrap();
743        replace_file_from_temp(&second_tmp, &final_path).unwrap();
744
745        let content = std::fs::read_to_string(&final_path).unwrap();
746        assert!(content.contains("public"));
747    }
748
749    #[cfg(unix)]
750    #[test]
751    fn test_write_preferences_temp_file_refuses_existing_symlink() {
752        use std::os::unix::fs::symlink;
753        use tempfile::TempDir;
754
755        let temp_dir = TempDir::new().unwrap();
756        let protected = temp_dir.path().join("protected.toml");
757        let temp_path = temp_dir.path().join(".profile_prefs.toml.tmp");
758
759        std::fs::write(&protected, "default_profile = \"team\"\n").unwrap();
760        symlink(&protected, &temp_path).unwrap();
761
762        let err = write_preferences_temp_file(&temp_path, "default_profile = \"public\"\n")
763            .expect_err("pre-existing temp symlink must be rejected");
764
765        assert!(
766            err.to_string()
767                .contains("Failed to create temporary preferences file"),
768            "error should identify refused temp creation: {err}"
769        );
770        assert_eq!(
771            std::fs::read_to_string(&protected).unwrap(),
772            "default_profile = \"team\"\n"
773        );
774        assert!(
775            std::fs::symlink_metadata(&temp_path)
776                .unwrap()
777                .file_type()
778                .is_symlink(),
779            "failed temp write should leave the existing symlink untouched"
780        );
781    }
782}