1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::HashMap;
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12pub mod errors;
13pub use errors::{GitSheetsError, Result};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Snapshot {
22 pub id: String,
24 pub timestamp: DateTime<Utc>,
26 pub message: Option<String>,
28 pub table: Table,
30 pub hashes: TableHashes,
32 pub dependencies: Vec<Dependency>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Table {
39 pub headers: Vec<String>,
41 pub rows: Vec<Vec<String>>,
43 pub primary_key: Option<Vec<usize>>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct TableHashes {
50 pub table_hash: String,
52 pub header_hashes: HashMap<String, String>,
54 pub row_hashes: Option<Vec<String>>,
56}
57
58impl TableHashes {
59 pub fn compute(table: &Table) -> Self {
61 let mut hasher = Sha256::new();
62
63 for header in &table.headers {
65 hasher.update(header.as_bytes());
66 }
67 for row in &table.rows {
68 for cell in row {
69 hasher.update(cell.as_bytes());
70 }
71 }
72
73 let table_hash = format!("{:x}", hasher.finalize());
74
75 let mut header_hashes = HashMap::new();
77 for (idx, header) in table.headers.iter().enumerate() {
78 let mut hasher = Sha256::new();
79 hasher.update(header.as_bytes());
80
81 for row in &table.rows {
83 if idx < row.len() {
84 hasher.update(row[idx].as_bytes());
85 }
86 }
87
88 header_hashes.insert(header.clone(), format!("{:x}", hasher.finalize()));
89 }
90
91 Self {
92 table_hash,
93 header_hashes,
94 row_hashes: None,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Dependency {
102 pub name: String,
104 pub path: Option<PathBuf>,
106 pub hash: String,
108}
109
110impl Snapshot {
115 pub fn new(table: Table, message: Option<String>) -> Self {
117 let hashes = TableHashes::compute(&table);
118 let id = format!("{}-{}", Utc::now().timestamp(), &hashes.table_hash[..8]);
119
120 Self {
121 id,
122 timestamp: Utc::now(),
123 message,
124 table,
125 hashes,
126 dependencies: Vec::new(),
127 }
128 }
129
130 pub fn add_dependency(&mut self, name: String, path: Option<PathBuf>, hash: String) {
132 self.dependencies.push(Dependency { name, path, hash });
133 }
134
135 pub fn save(&self, path: &Path) -> Result<()> {
137 let toml_string = toml::to_string_pretty(self)?;
138 fs::write(path, toml_string)?;
139 Ok(())
140 }
141
142 pub fn load(path: &Path) -> Result<Snapshot> {
144 let content = fs::read_to_string(path)?;
145 let snapshot: Snapshot = toml::from_str(&content)?;
146 Ok(snapshot)
147 }
148
149 pub fn verify(&self) -> bool {
151 let computed = TableHashes::compute(&self.table);
152 computed.table_hash == self.hashes.table_hash
153 }
154
155 pub fn verify_dependencies(&self) -> Result<()> {
157 for dep in &self.dependencies {
158 if let Some(dep_path) = &dep.path {
159 let content = fs::read_to_string(dep_path)?;
160 let computed_hash = Self::compute_hash(&content);
161 if computed_hash != dep.hash {
162 return Err(GitSheetsError::DependencyHashMismatch(format!(
163 "Dependency '{}' hash mismatch",
164 dep.name
165 )));
166 }
167 }
168 }
169 Ok(())
170 }
171
172 fn compute_hash(content: &str) -> String {
174 let mut hasher = Sha256::new();
175 hasher.update(content.as_bytes());
176 format!("{:x}", hasher.finalize())
177 }
178}
179
180impl Table {
185 pub fn from_csv(path: &Path) -> Result<Self> {
187 let mut reader = csv::Reader::from_path(path)?;
188
189 let headers: Vec<String> = reader
191 .headers()?
192 .iter()
193 .map(|h| h.trim().to_string())
194 .collect();
195
196 let mut rows = Vec::new();
198 for result in reader.records() {
199 let record = result?;
200 let row: Vec<String> = record.iter().map(|cell| cell.trim().to_string()).collect();
201 rows.push(row);
202 }
203
204 if rows.is_empty() {
205 return Err(GitSheetsError::EmptyTable);
206 }
207
208 Ok(Self {
209 headers,
210 rows,
211 primary_key: None,
212 })
213 }
214
215 pub fn set_primary_key(&mut self, column_indices: Vec<usize>) {
217 self.primary_key = Some(column_indices);
218 }
219
220 pub fn get_row_key(&self, row_idx: usize) -> Result<Vec<String>> {
222 let pk_indices = self
223 .primary_key
224 .as_ref()
225 .ok_or_else(|| GitSheetsError::NoPrimaryKey)?;
226
227 let row = self.rows.get(row_idx).ok_or_else(|| {
228 GitSheetsError::InvalidRowIndex(format!(
229 "Row index {} exceeds row count {}",
230 row_idx,
231 self.rows.len()
232 ))
233 })?;
234
235 let pk_values: Vec<String> = pk_indices
236 .iter()
237 .filter_map(|&idx| row.get(idx).cloned())
238 .collect();
239
240 if pk_values.is_empty() {
241 return Err(GitSheetsError::NoPrimaryKey);
242 }
243
244 Ok(pk_values)
245 }
246}
247
248pub struct GitSheetsRepo {
254 pub path: PathBuf,
256 pub git_repo: Option<git2::Repository>,
258}
259
260impl GitSheetsRepo {
261 pub fn init(path: PathBuf) -> Result<GitSheetsRepo> {
263 let repo_path = path.canonicalize()?;
264
265 std::fs::create_dir_all(repo_path.join("snapshots"))?;
267 std::fs::create_dir_all(repo_path.join("diffs"))?;
268
269 let gitignore_path = repo_path.join(".gitignore");
271 if !gitignore_path.exists() {
272 let mut gitignore = std::fs::File::create(gitignore_path)?;
273 writeln!(gitignore, "snapshots/")?;
274 writeln!(gitignore, "diffs/")?;
275 writeln!(gitignore, "*.toml")?;
276 writeln!(gitignore, "*.json")?;
277 }
278
279 Ok(GitSheetsRepo {
280 path: repo_path,
281 git_repo: None,
282 })
283 }
284
285 pub fn open(path: &str) -> Result<GitSheetsRepo> {
287 let repo_path = PathBuf::from(path).canonicalize()?;
288
289 if !repo_path.join("snapshots").exists() {
290 return Err(GitSheetsError::FileSystemError(
291 "Not a git-sheets repository".to_string(),
292 ));
293 }
294
295 Ok(GitSheetsRepo {
296 path: repo_path,
297 git_repo: None,
298 })
299 }
300
301 pub fn commit_snapshot(&self) -> Result<()> {
303 Ok(())
306 }
307
308 pub fn has_changes(&self) -> bool {
310 false
312 }
313}