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    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        // Ensure directories exist
28        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    /// Get the base companion directory (~/.iris/)
42    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    /// Hash a path to create a unique identifier
48    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    /// Sanitize branch name for filesystem
58    fn sanitize_branch_name(branch: &str) -> String {
59        branch.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_")
60    }
61
62    /// Get session file path
63    fn session_path(&self) -> PathBuf {
64        self.repo_dir.join("session.json")
65    }
66
67    /// Get branch memory file path
68    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    /// Save session state
74    pub fn save_session(&self, session: &SessionState) -> Result<()> {
75        let path = self.session_path();
76        Self::atomic_write(&path, session)
77    }
78
79    /// Load session state
80    pub fn load_session(&self) -> Result<Option<SessionState>> {
81        let path = self.session_path();
82        Self::load_json(&path)
83    }
84
85    /// Save branch memory
86    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    /// Load branch memory
92    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    /// Atomic write using temp file + rename
98    fn atomic_write<T: serde::Serialize>(path: &Path, data: &T) -> Result<()> {
99        let json = serde_json::to_string_pretty(data)?;
100
101        // Write to temp file first
102        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        // Atomic rename
110        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    /// Load JSON file if it exists
122    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    /// List all branch memories for this repo
137    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    /// Delete session data
156    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}