git_iris/companion/
storage.rs1use super::{BranchMemory, SessionState};
6use anyhow::{Context, Result};
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11pub struct CompanionStorage {
13 repo_dir: PathBuf,
15 branches_dir: PathBuf,
17}
18
19impl CompanionStorage {
20 pub fn new(repo_path: &Path) -> Result<Self> {
26 let base_dir = Self::base_dir()?;
27 let repo_hash = Self::hash_path(repo_path);
28 let repo_dir = base_dir.join("repos").join(&repo_hash);
29 let branches_dir = repo_dir.join("branches");
30
31 fs::create_dir_all(&branches_dir).with_context(|| {
33 format!(
34 "Failed to create companion directory: {}",
35 branches_dir.display()
36 )
37 })?;
38
39 Ok(Self {
40 repo_dir,
41 branches_dir,
42 })
43 }
44
45 fn base_dir() -> Result<PathBuf> {
47 let home = dirs::home_dir().context("Could not determine home directory")?;
48 Ok(home.join(".iris"))
49 }
50
51 fn hash_path(path: &Path) -> String {
53 use std::collections::hash_map::DefaultHasher;
54 use std::hash::{Hash, Hasher};
55
56 let mut hasher = DefaultHasher::new();
57 path.to_string_lossy().hash(&mut hasher);
58 format!("{:016x}", hasher.finish())
59 }
60
61 fn sanitize_branch_name(branch: &str) -> String {
63 branch.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_")
64 }
65
66 fn session_path(&self) -> PathBuf {
68 self.repo_dir.join("session.json")
69 }
70
71 fn branch_path(&self, branch: &str) -> PathBuf {
73 let safe_name = Self::sanitize_branch_name(branch);
74 self.branches_dir.join(format!("{safe_name}.json"))
75 }
76
77 pub fn save_session(&self, session: &SessionState) -> Result<()> {
83 let path = self.session_path();
84 Self::atomic_write(&path, session)
85 }
86
87 pub fn load_session(&self) -> Result<Option<SessionState>> {
93 let path = self.session_path();
94 Self::load_json(&path)
95 }
96
97 pub fn save_branch_memory(&self, memory: &BranchMemory) -> Result<()> {
103 let path = self.branch_path(&memory.branch_name);
104 Self::atomic_write(&path, memory)
105 }
106
107 pub fn load_branch_memory(&self, branch: &str) -> Result<Option<BranchMemory>> {
113 let path = self.branch_path(branch);
114 Self::load_json(&path)
115 }
116
117 fn atomic_write<T: serde::Serialize>(path: &Path, data: &T) -> Result<()> {
119 let json = serde_json::to_string_pretty(data)?;
120
121 let temp_path = path.with_extension("json.tmp");
123 let mut file = fs::File::create(&temp_path)
124 .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
125 file.write_all(json.as_bytes())?;
126 file.sync_all()?;
127 drop(file);
128
129 fs::rename(&temp_path, path).with_context(|| {
131 format!(
132 "Failed to rename {} to {}",
133 temp_path.display(),
134 path.display()
135 )
136 })?;
137
138 Ok(())
139 }
140
141 fn load_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
143 if !path.exists() {
144 return Ok(None);
145 }
146
147 let content = fs::read_to_string(path)
148 .with_context(|| format!("Failed to read {}", path.display()))?;
149
150 let data: T = serde_json::from_str(&content)
151 .with_context(|| format!("Failed to parse {}", path.display()))?;
152
153 Ok(Some(data))
154 }
155
156 pub fn list_branches(&self) -> Result<Vec<String>> {
162 let mut branches = Vec::new();
163
164 if self.branches_dir.exists() {
165 for entry in fs::read_dir(&self.branches_dir)? {
166 let entry = entry?;
167 let path = entry.path();
168 if path.extension().is_some_and(|e| e == "json")
169 && let Some(stem) = path.file_stem()
170 {
171 branches.push(stem.to_string_lossy().to_string());
172 }
173 }
174 }
175
176 Ok(branches)
177 }
178
179 pub fn clear_session(&self) -> Result<()> {
185 let path = self.session_path();
186 if path.exists() {
187 fs::remove_file(&path)?;
188 }
189 Ok(())
190 }
191}