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 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#[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
390pub 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
409pub 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 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 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#[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 assert!(public.redact_usernames);
498 assert!(public.anonymize_project_names);
499 assert!(public.redact_hostnames);
500 assert!(public.redact_emails);
501
502 assert!(!team.redact_usernames);
504 assert!(!team.anonymize_project_names);
505 assert!(!team.redact_hostnames);
506 assert!(team.redact_emails);
507
508 assert!(!personal.redact_home_paths);
510 assert!(!personal.redact_emails);
511 assert!(!personal.redact_hostnames);
512
513 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 assert!(public.custom_patterns.len() >= 10);
536
537 assert!(team.custom_patterns.len() < public.custom_patterns.len());
539
540 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}