1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum ShareProfile {
25 Public,
28 #[default]
31 Team,
32 Personal,
35 Custom,
37}
38
39impl ShareProfile {
40 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 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 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 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 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, anonymize_project_names: false, redact_hostnames: false, redact_emails: true, 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, custom_patterns: patterns_for_personal(),
117 ..Default::default()
118 },
119 Self::Custom => RedactionConfig::default(),
120 }
121 }
122
123 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#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ProfilePreferences {
151 #[serde(default)]
153 pub default_profile: ShareProfile,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub custom_config: Option<SerializableRedactionConfig>,
158
159 #[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 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 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 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 fn default_path() -> Result<PathBuf> {
210 let data_dir = crate::default_data_dir();
211 Ok(data_dir.join("profile_prefs.toml"))
212 }
213
214 pub fn set_last_used(&mut self, profile: ShareProfile) {
216 self.last_used = Some(profile);
217 }
218
219 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#[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
379pub 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
398pub 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 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 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#[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 assert!(public.redact_usernames);
487 assert!(public.anonymize_project_names);
488 assert!(public.redact_hostnames);
489 assert!(public.redact_emails);
490
491 assert!(!team.redact_usernames);
493 assert!(!team.anonymize_project_names);
494 assert!(!team.redact_hostnames);
495 assert!(team.redact_emails);
496
497 assert!(!personal.redact_home_paths);
499 assert!(!personal.redact_emails);
500 assert!(!personal.redact_hostnames);
501
502 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 assert!(public.custom_patterns.len() >= 10);
525
526 assert!(team.custom_patterns.len() < public.custom_patterns.len());
528
529 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}