Skip to main content

git_iris/companion/
storage.rs

1//! Persistence layer for Iris Companion
2//!
3//! Stores session and branch data in ~/.iris/repos/{repo-hash}/
4
5use super::{BranchMemory, SessionState};
6use anyhow::{Context, Result};
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11/// Storage backend for companion data
12pub struct CompanionStorage {
13    /// Base directory for this repo's data
14    repo_dir: PathBuf,
15    /// Branches subdirectory
16    branches_dir: PathBuf,
17}
18
19impl CompanionStorage {
20    /// Create a new storage instance for the given repository
21    ///
22    /// # Errors
23    ///
24    /// Returns an error when the companion storage directories cannot be created.
25    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        // Ensure directories exist
32        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    /// Get the base companion directory (~/.iris/)
46    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    /// Hash a path to create a unique identifier
52    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    /// Sanitize branch name for filesystem
62    fn sanitize_branch_name(branch: &str) -> String {
63        branch.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_")
64    }
65
66    /// Get session file path
67    fn session_path(&self) -> PathBuf {
68        self.repo_dir.join("session.json")
69    }
70
71    /// Get branch memory file path
72    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    /// Save session state
78    ///
79    /// # Errors
80    ///
81    /// Returns an error when the session cannot be serialized or written.
82    pub fn save_session(&self, session: &SessionState) -> Result<()> {
83        let path = self.session_path();
84        Self::atomic_write(&path, session)
85    }
86
87    /// Load session state
88    ///
89    /// # Errors
90    ///
91    /// Returns an error when the session file exists but cannot be read or parsed.
92    pub fn load_session(&self) -> Result<Option<SessionState>> {
93        let path = self.session_path();
94        Self::load_json(&path)
95    }
96
97    /// Save branch memory
98    ///
99    /// # Errors
100    ///
101    /// Returns an error when the branch memory cannot be serialized or written.
102    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    /// Load branch memory
108    ///
109    /// # Errors
110    ///
111    /// Returns an error when the branch memory file exists but cannot be read or parsed.
112    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    /// Atomic write using temp file + rename
118    fn atomic_write<T: serde::Serialize>(path: &Path, data: &T) -> Result<()> {
119        let json = serde_json::to_string_pretty(data)?;
120
121        // Write to temp file first
122        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        // Atomic rename
130        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    /// Load JSON file if it exists
142    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    /// List all branch memories for this repo
157    ///
158    /// # Errors
159    ///
160    /// Returns an error when the branch memory directory cannot be read.
161    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    /// Delete session data
180    ///
181    /// # Errors
182    ///
183    /// Returns an error when the session file exists but cannot be removed.
184    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}