1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum ShareProfile {
26 Public,
29 #[default]
32 Team,
33 Personal,
36 Custom,
38}
39
40impl ShareProfile {
41 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 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 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 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 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, anonymize_project_names: false, redact_hostnames: false, redact_emails: true, 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, custom_patterns: patterns_for_personal(),
118 ..Default::default()
119 },
120 Self::Custom => RedactionConfig::default(),
121 }
122 }
123
124 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#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ProfilePreferences {
152 #[serde(default)]
154 pub default_profile: ShareProfile,
155
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub custom_config: Option<SerializableRedactionConfig>,
159
160 #[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 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 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 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 fn default_path() -> Result<PathBuf> {
209 let data_dir = crate::default_data_dir();
210 Ok(data_dir.join("profile_prefs.toml"))
211 }
212
213 pub fn set_last_used(&mut self, profile: ShareProfile) {
215 self.last_used = Some(profile);
216 }
217
218 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#[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
406pub 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
425pub 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 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 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#[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 assert!(public.redact_usernames);
514 assert!(public.anonymize_project_names);
515 assert!(public.redact_hostnames);
516 assert!(public.redact_emails);
517
518 assert!(!team.redact_usernames);
520 assert!(!team.anonymize_project_names);
521 assert!(!team.redact_hostnames);
522 assert!(team.redact_emails);
523
524 assert!(!personal.redact_home_paths);
526 assert!(!personal.redact_emails);
527 assert!(!personal.redact_hostnames);
528
529 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 assert!(public.custom_patterns.len() >= 10);
552
553 assert!(team.custom_patterns.len() < public.custom_patterns.len());
555
556 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}