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 final_path.exists() => {
251                let backup_path = unique_atomic_backup_path(final_path);
252                std::fs::rename(final_path, &backup_path).with_context(|| {
253                    let _ = std::fs::remove_file(temp_path);
254                    format!(
255                        "Failed preparing backup {} before replacing {} after {}",
256                        backup_path.display(),
257                        final_path.display(),
258                        first_err
259                    )
260                })?;
261
262                match std::fs::rename(temp_path, final_path) {
263                    Ok(()) => {
264                        let _ = std::fs::remove_file(&backup_path);
265                        sync_parent_directory(final_path)?;
266                        Ok(())
267                    }
268                    Err(second_err) => match std::fs::rename(&backup_path, final_path) {
269                        Ok(()) => {
270                            let _ = std::fs::remove_file(temp_path);
271                            sync_parent_directory(final_path)?;
272                            anyhow::bail!(
273                                "Failed replacing {} with {}: {}; restored original preferences",
274                                final_path.display(),
275                                temp_path.display(),
276                                second_err
277                            );
278                        }
279                        Err(restore_err) => {
280                            anyhow::bail!(
281                                "Failed replacing {} with {}: {}; restore error: {}; temp file retained at {}",
282                                final_path.display(),
283                                temp_path.display(),
284                                second_err,
285                                restore_err,
286                                temp_path.display()
287                            );
288                        }
289                    },
290                }
291            }
292            Err(err) => Err(err).with_context(|| {
293                format!(
294                    "Failed to rename {} to {}",
295                    temp_path.display(),
296                    final_path.display()
297                )
298            }),
299        }
300    } else {
301        std::fs::rename(temp_path, final_path).with_context(|| {
302            format!(
303                "Failed to rename {} to {}",
304                temp_path.display(),
305                final_path.display()
306            )
307        })?;
308        sync_parent_directory(final_path)
309    }
310}
311
312#[cfg(not(windows))]
313fn sync_parent_directory(path: &std::path::Path) -> Result<()> {
314    let Some(parent) = path.parent() else {
315        return Ok(());
316    };
317    std::fs::File::open(parent)
318        .with_context(|| format!("Failed to open {} for sync", parent.display()))?
319        .sync_all()
320        .with_context(|| format!("Failed to sync {}", parent.display()))
321}
322
323#[cfg(windows)]
324fn sync_parent_directory(_path: &std::path::Path) -> Result<()> {
325    Ok(())
326}
327
328fn unique_atomic_temp_path(path: &std::path::Path) -> PathBuf {
329    unique_atomic_sidecar_path(path, "tmp", "profile_prefs.toml")
330}
331
332fn unique_atomic_backup_path(path: &std::path::Path) -> PathBuf {
333    unique_atomic_sidecar_path(path, "bak", "profile_prefs.toml")
334}
335
336fn unique_atomic_sidecar_path(
337    path: &std::path::Path,
338    suffix: &str,
339    fallback_name: &str,
340) -> PathBuf {
341    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
342
343    let timestamp = std::time::SystemTime::now()
344        .duration_since(std::time::UNIX_EPOCH)
345        .unwrap_or_default()
346        .as_nanos();
347    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
348    let file_name = path
349        .file_name()
350        .and_then(|name| name.to_str())
351        .unwrap_or(fallback_name);
352
353    path.with_file_name(format!(
354        ".{file_name}.{suffix}.{}.{}.{}",
355        std::process::id(),
356        timestamp,
357        nonce
358    ))
359}
360
361/// Serializable version of RedactionConfig for persistence.
362///
363/// This excludes compiled regex patterns since they can't be serialized.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct SerializableRedactionConfig {
366    pub redact_home_paths: bool,
367    pub redact_usernames: bool,
368    pub anonymize_project_names: bool,
369    pub redact_hostnames: bool,
370    pub redact_emails: bool,
371    pub block_on_critical_secrets: bool,
372    #[serde(default)]
373    pub custom_pattern_names: Vec<String>,
374}
375
376impl Default for SerializableRedactionConfig {
377    fn default() -> Self {
378        Self {
379            redact_home_paths: true,
380            redact_usernames: true,
381            anonymize_project_names: false,
382            redact_hostnames: false,
383            redact_emails: true,
384            block_on_critical_secrets: true,
385            custom_pattern_names: Vec::new(),
386        }
387    }
388}
389
390/// Render a comparison table of all profiles for display.
391pub fn render_profile_comparison() -> String {
392    let mut output = String::new();
393
394    output.push_str("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n");
395    output.push_str("β”‚ Setting              β”‚ Public  β”‚ Team    β”‚ Personal β”‚\n");
396    output.push_str("β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€\n");
397    output.push_str("β”‚ Redact home paths    β”‚    βœ“    β”‚    βœ“    β”‚    βœ—     β”‚\n");
398    output.push_str("β”‚ Redact usernames     β”‚    βœ“    β”‚    βœ—    β”‚    βœ—     β”‚\n");
399    output.push_str("β”‚ Anonymize projects   β”‚    βœ“    β”‚    βœ—    β”‚    βœ—     β”‚\n");
400    output.push_str("β”‚ Redact hostnames     β”‚    βœ“    β”‚    βœ—    β”‚    βœ—     β”‚\n");
401    output.push_str("β”‚ Redact emails        β”‚    βœ“    β”‚    βœ“    β”‚    βœ—     β”‚\n");
402    output.push_str("β”‚ Block critical       β”‚    βœ“    β”‚    βœ“    β”‚    βœ“     β”‚\n");
403    output.push_str("β”‚ Pattern categories   β”‚   All   β”‚ Externalβ”‚ Critical β”‚\n");
404    output.push_str("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n");
405
406    output
407}
408
409/// Render profile comparison for terminal with ANSI colors.
410pub fn render_profile_comparison_colored() -> String {
411    use console::style;
412
413    let mut output = String::new();
414
415    let check = style("βœ“").green().to_string();
416    let cross = style("βœ—").red().to_string();
417
418    output.push_str(&format!(
419        "{}",
420        style("Profile Comparison").bold().underlined()
421    ));
422    output.push('\n');
423    output.push('\n');
424
425    let headers = ["Setting", "🌐 Public", "πŸ‘₯ Team", "πŸ”’ Personal"];
426    let rows = [
427        ("Redact home paths", true, true, false),
428        ("Redact usernames", true, false, false),
429        ("Anonymize projects", true, false, false),
430        ("Redact hostnames", true, false, false),
431        ("Redact emails", true, true, false),
432        ("Block critical secrets", true, true, true),
433    ];
434
435    // Header
436    output.push_str(&format!(
437        "  {:<22} {:^10} {:^10} {:^10}\n",
438        headers[0], headers[1], headers[2], headers[3]
439    ));
440    output.push_str(&format!("  {}\n", "─".repeat(54)));
441
442    // Rows
443    for (setting, public, team, personal) in rows {
444        let p = if public { &check } else { &cross };
445        let t = if team { &check } else { &cross };
446        let pe = if personal { &check } else { &cross };
447        output.push_str(&format!(
448            "  {:<22} {:^10} {:^10} {:^10}\n",
449            setting, p, t, pe
450        ));
451    }
452
453    output
454}
455
456/// Information about a profile for display in selection UI.
457#[derive(Debug, Clone)]
458pub struct ProfileInfo {
459    pub profile: ShareProfile,
460    pub name: String,
461    pub description: String,
462    pub icon: String,
463    pub pattern_count: usize,
464}
465
466impl ProfileInfo {
467    pub fn from_profile(profile: ShareProfile) -> Self {
468        let config = profile.to_redaction_config();
469        Self {
470            profile,
471            name: profile.name().to_string(),
472            description: profile.description().to_string(),
473            icon: profile.icon().to_string(),
474            pattern_count: config.custom_patterns.len(),
475        }
476    }
477
478    pub fn all() -> Vec<Self> {
479        ShareProfile::all()
480            .iter()
481            .map(|&p| Self::from_profile(p))
482            .collect()
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_profile_configs_differ() {
492        let public = ShareProfile::Public.to_redaction_config();
493        let team = ShareProfile::Team.to_redaction_config();
494        let personal = ShareProfile::Personal.to_redaction_config();
495
496        // Public is most restrictive
497        assert!(public.redact_usernames);
498        assert!(public.anonymize_project_names);
499        assert!(public.redact_hostnames);
500        assert!(public.redact_emails);
501
502        // Team keeps some context
503        assert!(!team.redact_usernames);
504        assert!(!team.anonymize_project_names);
505        assert!(!team.redact_hostnames);
506        assert!(team.redact_emails);
507
508        // Personal is least restrictive
509        assert!(!personal.redact_home_paths);
510        assert!(!personal.redact_emails);
511        assert!(!personal.redact_hostnames);
512
513        // All block critical secrets
514        assert!(public.block_on_critical_secrets);
515        assert!(team.block_on_critical_secrets);
516        assert!(personal.block_on_critical_secrets);
517    }
518
519    #[test]
520    fn test_profile_descriptions_not_empty() {
521        for profile in ShareProfile::all() {
522            assert!(!profile.name().is_empty());
523            assert!(!profile.description().is_empty());
524            assert!(!profile.icon().is_empty());
525        }
526    }
527
528    #[test]
529    fn test_public_has_most_patterns() {
530        let public = ShareProfile::Public.to_redaction_config();
531        let team = ShareProfile::Team.to_redaction_config();
532        let personal = ShareProfile::Personal.to_redaction_config();
533
534        // Public should have the most patterns
535        assert!(public.custom_patterns.len() >= 10);
536
537        // Team should have fewer than public
538        assert!(team.custom_patterns.len() < public.custom_patterns.len());
539
540        // Personal should have the fewest
541        assert!(personal.custom_patterns.len() <= 6);
542    }
543
544    #[test]
545    fn test_profile_from_str() {
546        use std::str::FromStr;
547        assert_eq!(ShareProfile::from_str("public"), Ok(ShareProfile::Public));
548        assert_eq!(ShareProfile::from_str("PUBLIC"), Ok(ShareProfile::Public));
549        assert_eq!(ShareProfile::from_str("Team"), Ok(ShareProfile::Team));
550        assert_eq!(
551            ShareProfile::from_str("personal"),
552            Ok(ShareProfile::Personal)
553        );
554        assert_eq!(ShareProfile::from_str("custom"), Ok(ShareProfile::Custom));
555        assert!(ShareProfile::from_str("invalid").is_err());
556    }
557
558    #[test]
559    fn test_profile_labels_are_parse_spellings() {
560        use std::str::FromStr;
561
562        for profile in ShareProfile::all() {
563            assert_eq!(ShareProfile::from_str(profile.label()), Ok(*profile));
564        }
565    }
566
567    #[test]
568    fn test_profile_display() {
569        assert_eq!(format!("{}", ShareProfile::Public), "🌐 Public");
570        assert_eq!(format!("{}", ShareProfile::Team), "πŸ‘₯ Team");
571    }
572
573    #[test]
574    fn test_default_profile() {
575        let prefs = ProfilePreferences::default();
576        assert_eq!(prefs.default_profile, ShareProfile::Team);
577        assert!(prefs.last_used.is_none());
578    }
579
580    #[test]
581    fn test_effective_profile() {
582        let mut prefs = ProfilePreferences::default();
583        assert_eq!(prefs.effective_profile(), ShareProfile::Team);
584
585        prefs.set_last_used(ShareProfile::Public);
586        assert_eq!(prefs.effective_profile(), ShareProfile::Public);
587    }
588
589    #[test]
590    fn test_comparison_table_renders() {
591        let table = render_profile_comparison();
592        assert!(table.contains("Public"));
593        assert!(table.contains("Team"));
594        assert!(table.contains("Personal"));
595        assert!(table.contains("βœ“"));
596        assert!(table.contains("βœ—"));
597    }
598
599    #[test]
600    fn test_profile_info_all() {
601        let infos = ProfileInfo::all();
602        assert_eq!(infos.len(), 4);
603        assert!(infos.iter().any(|i| i.profile == ShareProfile::Public));
604        assert!(infos.iter().any(|i| i.profile == ShareProfile::Custom));
605    }
606
607    #[test]
608    fn test_serializable_config_default() {
609        let config = SerializableRedactionConfig::default();
610        assert!(config.redact_home_paths);
611        assert!(config.block_on_critical_secrets);
612    }
613
614    #[test]
615    fn test_profile_serialization() {
616        let prefs = ProfilePreferences {
617            default_profile: ShareProfile::Public,
618            custom_config: None,
619            last_used: Some(ShareProfile::Team),
620        };
621
622        let serialized = toml::to_string(&prefs).unwrap();
623        let deserialized: ProfilePreferences = toml::from_str(&serialized).unwrap();
624
625        assert_eq!(deserialized.default_profile, ShareProfile::Public);
626        assert_eq!(deserialized.last_used, Some(ShareProfile::Team));
627    }
628
629    #[test]
630    fn test_preferences_path_uses_default_data_dir() {
631        let path = ProfilePreferences::default_path().expect("default path");
632        assert_eq!(path, crate::default_data_dir().join("profile_prefs.toml"));
633    }
634
635    #[test]
636    fn test_unique_atomic_temp_path_changes_each_call() {
637        let final_path = std::path::Path::new("/tmp/profile_prefs.toml");
638        let first = unique_atomic_temp_path(final_path);
639        let second = unique_atomic_temp_path(final_path);
640        assert_ne!(first, second);
641    }
642
643    #[test]
644    fn test_replace_file_from_temp_overwrites_existing_file() {
645        use tempfile::TempDir;
646
647        let temp_dir = TempDir::new().unwrap();
648        let final_path = temp_dir.path().join("profile_prefs.toml");
649        let first_tmp = temp_dir.path().join("first.tmp");
650        let second_tmp = temp_dir.path().join("second.tmp");
651
652        std::fs::write(&first_tmp, "default_profile = \"team\"\n").unwrap();
653        replace_file_from_temp(&first_tmp, &final_path).unwrap();
654        assert!(final_path.exists());
655        assert!(!first_tmp.exists());
656
657        std::fs::write(&second_tmp, "default_profile = \"public\"\n").unwrap();
658        replace_file_from_temp(&second_tmp, &final_path).unwrap();
659
660        let content = std::fs::read_to_string(&final_path).unwrap();
661        assert!(content.contains("public"));
662    }
663
664    #[cfg(unix)]
665    #[test]
666    fn test_write_preferences_temp_file_refuses_existing_symlink() {
667        use std::os::unix::fs::symlink;
668        use tempfile::TempDir;
669
670        let temp_dir = TempDir::new().unwrap();
671        let protected = temp_dir.path().join("protected.toml");
672        let temp_path = temp_dir.path().join(".profile_prefs.toml.tmp");
673
674        std::fs::write(&protected, "default_profile = \"team\"\n").unwrap();
675        symlink(&protected, &temp_path).unwrap();
676
677        let err = write_preferences_temp_file(&temp_path, "default_profile = \"public\"\n")
678            .expect_err("pre-existing temp symlink must be rejected");
679
680        assert!(
681            err.to_string()
682                .contains("Failed to create temporary preferences file"),
683            "error should identify refused temp creation: {err}"
684        );
685        assert_eq!(
686            std::fs::read_to_string(&protected).unwrap(),
687            "default_profile = \"team\"\n"
688        );
689        assert!(
690            std::fs::symlink_metadata(&temp_path)
691                .unwrap()
692                .file_type()
693                .is_symlink(),
694            "failed temp write should leave the existing symlink untouched"
695        );
696    }
697}