1use crate::core::Table;
5use crate::core::{GitSheetsError, Result, Snapshot};
6use crate::diff::{Change, SnapshotDiff};
7use clap::{Parser, Subcommand};
8use std::io::Write;
9use std::path::Path;
10
11#[derive(Parser)]
12#[command(name = "git-sheets")]
13#[command(about = "Version control for spreadsheets", long_about = None)]
14pub struct Cli {
15 #[command(subcommand)]
16 command: Commands,
17}
18
19impl Cli {
20 pub fn execute(&self) -> Result<()> {
22 match &self.command {
23 Commands::Init { path } => {
24 crate::cli::init_repository(&Path::new(path))?;
25 }
26 Commands::Snapshot {
27 file,
28 message,
29 primary_key,
30 auto_commit,
31 } => {
32 crate::cli::create_snapshot(
33 &Path::new(file),
34 message.clone(),
35 primary_key.clone(),
36 *auto_commit,
37 )?;
38 }
39 Commands::Diff { from, to, format } => {
40 let format_str = format.as_ref().map(|s| s.as_str()).unwrap_or("text");
41 crate::cli::show_diff(&Path::new(from), &Path::new(to), format_str)?;
42 }
43 Commands::Verify { file } => {
44 crate::cli::verify_snapshot(&Path::new(file))?;
45 }
46 Commands::Status => {
47 crate::cli::show_status()?;
48 }
49 Commands::Log { limit } => {
50 crate::cli::show_log(*limit)?;
51 }
52 }
53 Ok(())
54 }
55}
56
57#[derive(Subcommand)]
58enum Commands {
59 Init {
61 #[arg(value_name = "PATH")]
63 path: String,
64 },
65
66 Snapshot {
68 #[arg(value_name = "FILE")]
70 file: String,
71
72 #[arg(short, long)]
74 message: Option<String>,
75
76 #[arg(long)]
78 primary_key: Option<String>,
79
80 #[arg(long)]
82 auto_commit: bool,
83 },
84
85 Diff {
87 #[arg(value_name = "FROM")]
89 from: String,
90
91 #[arg(value_name = "TO")]
93 to: String,
94
95 #[arg(short, long)]
97 format: Option<String>,
98 },
99
100 Verify {
102 #[arg(value_name = "FILE")]
104 file: String,
105 },
106
107 Status,
109
110 Log {
112 #[arg(short, long)]
114 limit: Option<usize>,
115 },
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
120pub enum DiffFormat {
121 Json,
123 Git,
125}
126
127fn init_repository(path: &Path) -> Result<()> {
132 println!("Initializing git-sheets repository at {}", path.display());
133
134 std::fs::create_dir_all(path.join("snapshots"))?;
136 std::fs::create_dir_all(path.join("diffs"))?;
137
138 let gitignore_path = path.join(".gitignore");
140 if !gitignore_path.exists() {
141 let mut gitignore = std::fs::File::create(gitignore_path)?;
142 writeln!(gitignore, "snapshots/")?;
143 writeln!(gitignore, "diffs/")?;
144 writeln!(gitignore, "*.toml")?;
145 writeln!(gitignore, "*.json")?;
146 }
147
148 Ok(())
149}
150
151fn create_snapshot(
152 file: &Path,
153 message: Option<String>,
154 primary_key: Option<String>,
155 auto_commit: bool,
156) -> Result<()> {
157 println!("Creating snapshot of {}", file.display());
158
159 let mut table = Table::from_csv(file)?;
161
162 if let Some(pk_str) = primary_key {
164 let pk_indices: Vec<usize> = pk_str
165 .split(',')
166 .map(|s| s.trim().parse::<usize>().unwrap_or(0))
167 .collect();
168 table.set_primary_key(pk_indices);
169 }
170
171 let snapshot = Snapshot::new(table, message);
173
174 let snapshot_path = Path::new("snapshots").join(format!("{}.toml", snapshot.id));
176 snapshot.save(&snapshot_path)?;
177
178 println!("Snapshot created: {}", snapshot.id);
179
180 if auto_commit {
181 println!("Auto-commit would be performed here");
183 }
184
185 Ok(())
186}
187
188fn show_diff(from: &Path, to: &Path, format: &str) -> Result<()> {
189 println!("Computing diff...");
190
191 let snapshot1 = Snapshot::load(from)?;
192 let snapshot2 = Snapshot::load(to)?;
193 let diff = SnapshotDiff::compute(&snapshot1, &snapshot2)?;
194
195 match format {
196 "json" => {
197 diff.save(&Path::new("diffs").join(format!("{}.json", diff.from_id)))?;
198 println!("Diff saved as JSON");
199 }
200 "git" => {
201 println!("--- {}", snapshot1.id);
203 println!("+++ {}", snapshot2.id);
204 for change in &diff.changes {
205 match change {
206 Change::RowAdded { index, data } => {
207 println!("@@ -0 +{} @@", index + 1);
208 println!("+{}", data.join("\t"));
209 }
210 Change::RowRemoved { index, data } => {
211 println!("@@ -{} +0 @@", index + 1);
212 println!("-{}", data.join("\t"));
213 }
214 Change::CellChanged { row, col, old, new } => {
215 println!("@@ -{} +{} @@", row + 1, col + 1);
216 println!("-{}", old);
217 println!("+{}", new);
218 }
219 Change::RowModified {
220 index,
221 old_data,
222 new_data,
223 } => {
224 println!("@@ -{} +{} @@", index + 1, index + 1);
225 println!("-{}", old_data.join("\t"));
226 println!("+{}", new_data.join("\t"));
227 }
228 Change::ColumnAdded { name, index } => {
229 println!("@@ -0 +{} @@", index + 1);
230 println!("+{}", name);
231 }
232 Change::ColumnRemoved { name, index } => {
233 println!("@@ -{} +0 @@", index + 1);
234 println!("-{}", name);
235 }
236 }
237 }
238 }
239 _ => {
240 print_diff_text(&diff);
242 }
243 }
244
245 Ok(())
246}
247
248fn print_diff_text(diff: &SnapshotDiff) {
249 println!("Diff from {} to {}", diff.from_id, diff.to_id);
250 println!("Summary:");
251 println!(" Rows added: {}", diff.summary.rows_added);
252 println!(" Rows removed: {}", diff.summary.rows_removed);
253 println!(" Rows modified: {}", diff.summary.rows_modified);
254
255 if !diff.changes.is_empty() {
256 println!("Changes:");
257 for change in &diff.changes {
258 match change {
259 Change::RowAdded { index, data } => {
260 println!("Row added at {}: {:?}", index, data);
261 }
262 Change::RowRemoved { index, data } => {
263 println!("Row removed at {}: {:?}", index, data);
264 }
265 Change::CellChanged { row, col, old, new } => {
266 println!("Cell changed at ({}, {}): {} -> {}", row, col, old, new);
267 }
268 Change::RowModified {
269 index,
270 old_data,
271 new_data,
272 } => {
273 println!(
274 "Row modified at {}: {:?} -> {:?}",
275 index, old_data, new_data
276 );
277 }
278 Change::ColumnAdded { name, index } => {
279 println!("Column added at {}: {}", index, name);
280 }
281 Change::ColumnRemoved { name, index } => {
282 println!("Column removed at {}: {}", index, name);
283 }
284 }
285 }
286 }
287}
288
289fn verify_snapshot(path: &Path) -> Result<()> {
290 println!("Verifying snapshot: {}", path.display());
291
292 let snapshot = Snapshot::load(path)?;
293
294 if snapshot.verify() {
295 println!("Snapshot integrity verified");
296 } else {
297 println!("Snapshot integrity check failed");
298 return Err(GitSheetsError::FileSystemError(
299 "Snapshot verification failed".to_string(),
300 ));
301 }
302
303 Ok(())
304}
305
306fn show_status() -> Result<()> {
307 println!("Git-sheets status\n");
308
309 let repo_path = Path::new(".");
311 if !repo_path.join("snapshots").exists() {
312 println!("Not a git-sheets repository");
313 return Err(GitSheetsError::FileSystemError(
314 "Not a git-sheets repository".to_string(),
315 ));
316 }
317
318 println!("Repository: {}", repo_path.display());
319 println!("Snapshots directory: snapshots/");
320 println!("Diffs directory: diffs/");
321
322 Ok(())
323}
324
325fn show_log(limit: Option<usize>) -> Result<()> {
326 let snapshots_dir = Path::new("snapshots");
327
328 if !snapshots_dir.exists() {
329 println!("No snapshots directory found");
330 return Err(GitSheetsError::FileSystemError(
331 "No snapshots directory".to_string(),
332 ));
333 }
334
335 let mut snapshot_files: Vec<_> = std::fs::read_dir(snapshots_dir)?
337 .filter_map(|entry| entry.ok())
338 .map(|entry| entry.path())
339 .filter(|path| path.extension().map_or(false, |ext| ext == "toml"))
340 .collect();
341
342 snapshot_files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
344
345 let limit = limit.unwrap_or(snapshot_files.len());
346 let snapshots_to_show = snapshot_files.iter().take(limit);
347
348 println!("Recent snapshots:");
349 for path in snapshots_to_show {
350 let filename = path.file_name().unwrap().to_string_lossy();
351 println!(" {}", filename);
352 }
353
354 Ok(())
355}
356
357pub fn run() -> Result<()> {
358 let cli = Cli::parse();
359
360 match &cli.command {
361 Commands::Init { path } => {
362 init_repository(&Path::new(path))?;
363 }
364 Commands::Snapshot {
365 file,
366 message,
367 primary_key,
368 auto_commit,
369 } => {
370 create_snapshot(
371 &Path::new(file),
372 message.clone(),
373 primary_key.clone(),
374 *auto_commit,
375 )?;
376 }
377 Commands::Diff { from, to, format } => {
378 let format_str = format.as_ref().map(|s| s.as_str()).unwrap_or("text");
379 show_diff(&Path::new(from), &Path::new(to), format_str)?;
380 }
381 Commands::Verify { file } => {
382 verify_snapshot(&Path::new(file))?;
383 }
384 Commands::Status => {
385 show_status()?;
386 }
387 Commands::Log { limit } => {
388 show_log(*limit)?;
389 }
390 }
391
392 Ok(())
393}