1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9
10use crate::app::CleanupMode;
11
12const KEEP_STORE_VERSION: u32 = 1;
13
14#[derive(Debug, Clone)]
15pub struct KeepStore {
16 path: PathBuf,
17 labels_by_mode: BTreeMap<String, BTreeSet<String>>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21struct KeepStoreFile {
22 version: u32,
23 labels_by_mode: BTreeMap<String, BTreeSet<String>>,
24}
25
26impl KeepStore {
27 pub fn load(repo: &Path) -> Result<Self> {
28 let path = keep_store_path(repo)?;
29 if !path.exists() {
30 return Ok(Self {
31 path,
32 labels_by_mode: BTreeMap::new(),
33 });
34 }
35
36 let contents = fs::read_to_string(&path)
37 .with_context(|| format!("failed to read keep-label store {}", path.display()))?;
38 let parsed = serde_json::from_str::<KeepStoreFile>(&contents)
39 .with_context(|| format!("failed to parse keep-label store {}", path.display()))?;
40
41 if parsed.version != KEEP_STORE_VERSION {
42 bail!(
43 "unsupported keep-label store version {} in {}",
44 parsed.version,
45 path.display()
46 );
47 }
48
49 Ok(Self {
50 path,
51 labels_by_mode: parsed.labels_by_mode,
52 })
53 }
54
55 pub fn path(&self) -> &Path {
56 &self.path
57 }
58
59 pub fn is_saved(&self, mode: CleanupMode, branch: &str) -> bool {
60 self.labels_by_mode
61 .get(mode.key())
62 .is_some_and(|branches| branches.contains(branch))
63 }
64
65 pub fn replace_mode<I, S>(&mut self, mode: CleanupMode, branches: I) -> Result<()>
66 where
67 I: IntoIterator<Item = S>,
68 S: Into<String>,
69 {
70 let saved = branches
71 .into_iter()
72 .map(Into::into)
73 .collect::<BTreeSet<_>>();
74 if saved.is_empty() {
75 self.labels_by_mode.remove(mode.key());
76 } else {
77 self.labels_by_mode.insert(mode.key().to_string(), saved);
78 }
79
80 self.persist()
81 }
82
83 fn persist(&self) -> Result<()> {
84 if self.labels_by_mode.is_empty() {
85 match fs::remove_file(&self.path) {
86 Ok(()) => {}
87 Err(error) if error.kind() == ErrorKind::NotFound => {}
88 Err(error) => {
89 return Err(error).with_context(|| {
90 format!("failed to remove keep-label store {}", self.path.display())
91 });
92 }
93 }
94 return Ok(());
95 }
96
97 let parent = self
98 .path
99 .parent()
100 .context("keep-label store path missing parent directory")?;
101 fs::create_dir_all(parent)
102 .with_context(|| format!("failed to create keep-label dir {}", parent.display()))?;
103
104 let file = KeepStoreFile {
105 version: KEEP_STORE_VERSION,
106 labels_by_mode: self.labels_by_mode.clone(),
107 };
108 let contents =
109 serde_json::to_string_pretty(&file).context("failed to serialize keep-label store")?;
110 fs::write(&self.path, format!("{contents}\n"))
111 .with_context(|| format!("failed to write keep-label store {}", self.path.display()))
112 }
113}
114
115fn keep_store_path(repo: &Path) -> Result<PathBuf> {
116 let output = Command::new("git")
117 .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
118 .current_dir(repo)
119 .stdout(Stdio::piped())
120 .stderr(Stdio::piped())
121 .output()
122 .context("failed to resolve git common dir")?;
123
124 if !output.status.success() {
125 bail!(
126 "failed to resolve git common dir: {}",
127 String::from_utf8_lossy(&output.stderr).trim()
128 );
129 }
130
131 let common_dir =
132 String::from_utf8(output.stdout).context("git returned non-utf8 common dir output")?;
133 Ok(PathBuf::from(common_dir.trim()).join("git-broom/keep-labels.json"))
134}