Skip to main content

gitsheets/cli/
mod.rs

1// git-sheets: CLI module - command parsing and implementations
2// A tool for Excel sufferers who deserve better
3
4use 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    /// Execute the command
21    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    /// Initialize a new git-sheets repository
60    Init {
61        /// Path to initialize the repository
62        #[arg(value_name = "PATH")]
63        path: String,
64    },
65
66    /// Create a snapshot of a table
67    Snapshot {
68        /// Table file to snapshot
69        #[arg(value_name = "FILE")]
70        file: String,
71
72        /// Commit message for the snapshot
73        #[arg(short, long)]
74        message: Option<String>,
75
76        /// Set which column(s) form the primary key
77        #[arg(long)]
78        primary_key: Option<String>,
79
80        /// Auto-commit to git after creating snapshot
81        #[arg(long)]
82        auto_commit: bool,
83    },
84
85    /// Show a diff between two snapshots
86    Diff {
87        /// First snapshot file
88        #[arg(value_name = "FROM")]
89        from: String,
90
91        /// Second snapshot file
92        #[arg(value_name = "TO")]
93        to: String,
94
95        /// Output format (json or git)
96        #[arg(short, long)]
97        format: Option<String>,
98    },
99
100    /// Verify integrity of a snapshot
101    Verify {
102        /// Snapshot file to verify
103        #[arg(value_name = "FILE")]
104        file: String,
105    },
106
107    /// Show current status
108    Status,
109
110    /// List all snapshots
111    Log {
112        /// Limit number of snapshots shown
113        #[arg(short, long)]
114        limit: Option<usize>,
115    },
116}
117
118/// Diff output format
119#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
120pub enum DiffFormat {
121    /// JSON format
122    Json,
123    /// Git-style unified diff
124    Git,
125}
126
127// ============================================================================
128// COMMAND IMPLEMENTATIONS
129// ============================================================================
130
131fn init_repository(path: &Path) -> Result<()> {
132    println!("Initializing git-sheets repository at {}", path.display());
133
134    // Create necessary directories
135    std::fs::create_dir_all(path.join("snapshots"))?;
136    std::fs::create_dir_all(path.join("diffs"))?;
137
138    // Create .gitignore if needed
139    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    // Load the table
160    let mut table = Table::from_csv(file)?;
161
162    // Set primary key if specified
163    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    // Create snapshot
172    let snapshot = Snapshot::new(table, message);
173
174    // Save snapshot
175    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        // Placeholder for git commit
182        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            // Print git-style diff
202            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            // Default to text format
241            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    // Check if git repo exists
310    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    // List snapshots
336    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    // Sort by name (which should be timestamp-based)
343    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}