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> {
22 let base_dir = Self::base_dir()?;
23 let repo_hash = Self::hash_path(repo_path);
24 let repo_dir = base_dir.join("repos").join(&repo_hash);
25 let branches_dir = repo_dir.join("branches");
26
27 fs::create_dir_all(&branches_dir).with_context(|| {
29 format!(
30 "Failed to create companion directory: {}",
31 branches_dir.display()
32 )
33 })?;
34
35 Ok(Self {
36 repo_dir,
37 branches_dir,
38 })
39 }
40
41 fn base_dir() -> Result<PathBuf> {
43 let home = dirs::home_dir().context("Could not determine home directory")?;
44 Ok(home.join(".iris"))
45 }
46
47 fn hash_path(path: &Path) -> String {
49 use std::collections::hash_map::DefaultHasher;
50 use std::hash::{Hash, Hasher};
51
52 let mut hasher = DefaultHasher::new();
53 path.to_string_lossy().hash(&mut hasher);
54 format!("{:016x}", hasher.finish())
55 }
56
57 fn sanitize_branch_name(branch: &str) -> String {
59 branch.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_")
60 }
61
62 fn session_path(&self) -> PathBuf {
64 self.repo_dir.join("session.json")
65 }
66
67 fn branch_path(&self, branch: &str) -> PathBuf {
69 let safe_name = Self::sanitize_branch_name(branch);
70 self.branches_dir.join(format!("{safe_name}.json"))
71 }
72
73 pub fn save_session(&self, session: &SessionState) -> Result<()> {
75 let path = self.session_path();
76 Self::atomic_write(&path, session)
77 }
78
79 pub fn load_session(&self) -> Result<Option<SessionState>> {
81 let path = self.session_path();
82 Self::load_json(&path)
83 }
84
85 pub fn save_branch_memory(&self, memory: &BranchMemory) -> Result<()> {
87 let path = self.branch_path(&memory.branch_name);
88 Self::atomic_write(&path, memory)
89 }
90
91 pub fn load_branch_memory(&self, branch: &str) -> Result<Option<BranchMemory>> {
93 let path = self.branch_path(branch);
94 Self::load_json(&path)
95 }
96
97 fn atomic_write<T: serde::Serialize>(path: &Path, data: &T) -> Result<()> {
99 let json = serde_json::to_string_pretty(data)?;
100
101 let temp_path = path.with_extension("json.tmp");
103 let mut file = fs::File::create(&temp_path)
104 .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
105 file.write_all(json.as_bytes())?;
106 file.sync_all()?;
107 drop(file);
108
109 fs::rename(&temp_path, path).with_context(|| {
111 format!(
112 "Failed to rename {} to {}",
113 temp_path.display(),
114 path.display()
115 )
116 })?;
117
118 Ok(())
119 }
120
121 fn load_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
123 if !path.exists() {
124 return Ok(None);
125 }
126
127 let content = fs::read_to_string(path)
128 .with_context(|| format!("Failed to read {}", path.display()))?;
129
130 let data: T = serde_json::from_str(&content)
131 .with_context(|| format!("Failed to parse {}", path.display()))?;
132
133 Ok(Some(data))
134 }
135
136 pub fn list_branches(&self) -> Result<Vec<String>> {
138 let mut branches = Vec::new();
139
140 if self.branches_dir.exists() {
141 for entry in fs::read_dir(&self.branches_dir)? {
142 let entry = entry?;
143 let path = entry.path();
144 if path.extension().is_some_and(|e| e == "json")
145 && let Some(stem) = path.file_stem()
146 {
147 branches.push(stem.to_string_lossy().to_string());
148 }
149 }
150 }
151
152 Ok(branches)
153 }
154
155 pub fn clear_session(&self) -> Result<()> {
157 let path = self.session_path();
158 if path.exists() {
159 fs::remove_file(&path)?;
160 }
161 Ok(())
162 }
163}