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