use super::events::FileEvent;
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::time::Instant;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum GitStateError {
#[error("invalid path '{path}': {reason}")]
InvalidPath { path: PathBuf, reason: String },
#[error("parse error on line '{line}': {reason}")]
ParseError { line: String, reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileStatus {
Clean,
Modified,
New,
Deleted,
Renamed { from: PathBuf },
}
#[derive(Debug, Clone, Default)]
pub struct GitState {
files: HashMap<PathBuf, FileStatus>,
captured_at: Option<Instant>,
}
impl GitState {
pub fn new() -> Self {
Self::default()
}
pub fn from_git_status(output: &str) -> Result<Self, GitStateError> {
let mut files = HashMap::new();
for line in output.lines() {
let line = line.trim_end();
if line.is_empty() || line.len() < 3 {
continue;
}
if line.chars().all(|c| c.is_whitespace()) {
continue;
}
let status_chars = &line[0..2];
let path_part = &line[3..];
let (path, status) = parse_status_line(status_chars, path_part)?;
let validated_path = validate_path(&path)?;
files.insert(validated_path, status);
}
Ok(Self {
files,
captured_at: Some(Instant::now()),
})
}
pub fn diff(&self, new: &GitState) -> Vec<FileEvent> {
let mut events = Vec::new();
for (path, new_status) in &new.files {
match new_status {
FileStatus::Renamed { from } => {
events.push(FileEvent::Renamed(from.clone(), path.clone()));
}
FileStatus::Deleted => {
events.push(FileEvent::Deleted(path.clone()));
}
_ => {
match self.files.get(path) {
None => {
events.push(FileEvent::Modified(path.clone()));
}
Some(old_status) if old_status != new_status => {
events.push(FileEvent::Modified(path.clone()));
}
_ => {
}
}
}
}
}
for (path, old_status) in &self.files {
let is_rename_source = new
.files
.values()
.any(|s| matches!(s, FileStatus::Renamed { from } if from == path));
if is_rename_source {
continue;
}
if !new.files.contains_key(path) {
if !matches!(old_status, FileStatus::Deleted) {
events.push(FileEvent::Deleted(path.clone()));
}
}
}
events
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn get(&self, path: &Path) -> Option<&FileStatus> {
self.files.get(path)
}
pub fn captured_at(&self) -> Option<Instant> {
self.captured_at
}
#[cfg(test)]
pub fn insert(&mut self, path: PathBuf, status: FileStatus) {
self.files.insert(path, status);
}
}
fn validate_path(path: &Path) -> Result<PathBuf, GitStateError> {
if path.is_absolute() {
return Err(GitStateError::InvalidPath {
path: path.to_path_buf(),
reason: "absolute path not allowed".into(),
});
}
for component in path.components() {
if matches!(component, Component::ParentDir) {
return Err(GitStateError::InvalidPath {
path: path.to_path_buf(),
reason: "path traversal not allowed".into(),
});
}
}
Ok(path.to_path_buf())
}
fn parse_status_line(
status_chars: &str,
path_part: &str,
) -> Result<(PathBuf, FileStatus), GitStateError> {
let chars: Vec<char> = status_chars.chars().collect();
if chars.len() != 2 {
return Err(GitStateError::ParseError {
line: format!("{}{}", status_chars, path_part),
reason: "invalid status code length".into(),
});
}
let index_status = chars[0];
let worktree_status = chars[1];
if index_status == 'R' {
return parse_rename(path_part);
}
if index_status == 'C' {
return parse_copy(path_part);
}
let path = unquote_path(path_part)?;
let status = match (index_status, worktree_status) {
('D', _) | (_, 'D') => FileStatus::Deleted,
('A', _) | ('?', '?') => FileStatus::New,
('M', _) | (_, 'M') | ('T', _) | (_, 'T') => FileStatus::Modified,
('U', _) | (_, 'U') => FileStatus::Modified,
('!', '!') => FileStatus::Clean,
(' ', ' ') => FileStatus::Clean,
_ => FileStatus::Modified,
};
Ok((path, status))
}
fn parse_rename(path_part: &str) -> Result<(PathBuf, FileStatus), GitStateError> {
if let Some(arrow_pos) = path_part.find(" -> ") {
let old_path_str = &path_part[..arrow_pos];
let new_path_str = &path_part[arrow_pos + 4..];
let old_path = unquote_path(old_path_str)?;
let new_path = unquote_path(new_path_str)?;
validate_path(&old_path)?;
validate_path(&new_path)?;
Ok((new_path, FileStatus::Renamed { from: old_path }))
} else {
Err(GitStateError::ParseError {
line: path_part.to_string(),
reason: "rename missing ' -> ' separator".into(),
})
}
}
fn parse_copy(path_part: &str) -> Result<(PathBuf, FileStatus), GitStateError> {
if let Some(arrow_pos) = path_part.find(" -> ") {
let new_path_str = &path_part[arrow_pos + 4..];
let new_path = unquote_path(new_path_str)?;
Ok((new_path, FileStatus::New))
} else {
Err(GitStateError::ParseError {
line: path_part.to_string(),
reason: "copy missing ' -> ' separator".into(),
})
}
}
fn unquote_path(path_str: &str) -> Result<PathBuf, GitStateError> {
let path_str = path_str.trim();
if path_str.starts_with('"') && path_str.ends_with('"') && path_str.len() >= 2 {
let inner = &path_str[1..path_str.len() - 1];
let unescaped = unescape_git_path(inner)?;
Ok(PathBuf::from(unescaped))
} else {
Ok(PathBuf::from(path_str))
}
}
fn unescape_git_path(s: &str) -> Result<String, GitStateError> {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('\\') => result.push('\\'),
Some('"') => result.push('"'),
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some(c1) if c1.is_ascii_digit() => {
let mut octal = String::from(c1);
for _ in 0..2 {
if let Some(&next) = chars.peek() {
if next.is_ascii_digit() {
octal.push(chars.next().unwrap());
} else {
break;
}
}
}
if let Ok(byte) = u8::from_str_radix(&octal, 8) {
result.push(byte as char);
} else {
result.push('\\');
result.push_str(&octal);
}
}
Some(other) => {
result.push('\\');
result.push(other);
}
None => {
result.push('\\');
}
}
} else {
result.push(c);
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_modified_staged() {
let output = "M src/main.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(
state.get(Path::new("src/main.rs")),
Some(&FileStatus::Modified)
);
}
#[test]
fn test_parse_modified_unstaged() {
let output = " M src/main.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(
state.get(Path::new("src/main.rs")),
Some(&FileStatus::Modified)
);
}
#[test]
fn test_parse_modified_both() {
let output = "MM src/main.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(
state.get(Path::new("src/main.rs")),
Some(&FileStatus::Modified)
);
}
#[test]
fn test_parse_added_staged() {
let output = "A new-file.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(state.get(Path::new("new-file.rs")), Some(&FileStatus::New));
}
#[test]
fn test_parse_untracked() {
let output = "?? untracked.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(state.get(Path::new("untracked.rs")), Some(&FileStatus::New));
}
#[test]
fn test_parse_deleted_staged() {
let output = "D deleted.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(
state.get(Path::new("deleted.rs")),
Some(&FileStatus::Deleted)
);
}
#[test]
fn test_parse_deleted_unstaged() {
let output = " D deleted.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(
state.get(Path::new("deleted.rs")),
Some(&FileStatus::Deleted)
);
}
#[test]
fn test_parse_renamed() {
let output = "R old.rs -> new.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(
state.get(Path::new("new.rs")),
Some(&FileStatus::Renamed {
from: PathBuf::from("old.rs")
})
);
}
#[test]
fn test_parse_copied() {
let output = "C original.rs -> copy.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(state.get(Path::new("copy.rs")), Some(&FileStatus::New));
}
#[test]
fn test_parse_multiple_files() {
let output = "M modified.rs\n?? untracked.rs\n D deleted.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert_eq!(state.len(), 3);
assert_eq!(
state.get(Path::new("modified.rs")),
Some(&FileStatus::Modified)
);
assert_eq!(state.get(Path::new("untracked.rs")), Some(&FileStatus::New));
assert_eq!(
state.get(Path::new("deleted.rs")),
Some(&FileStatus::Deleted)
);
}
#[test]
fn test_parse_empty_output() {
let output = "";
let state = GitState::from_git_status(output).unwrap();
assert!(state.is_empty());
}
#[test]
fn test_parse_whitespace_only() {
let output = " \n\n";
let state = GitState::from_git_status(output).unwrap();
assert!(state.is_empty());
}
#[test]
fn test_parse_path_with_spaces() {
let output = " M \"path with spaces/file.rs\"\n";
let state = GitState::from_git_status(output).unwrap();
assert!(state.get(Path::new("path with spaces/file.rs")).is_some());
}
#[test]
fn test_parse_path_with_quotes_in_name() {
let output = " M \"file\\\"quoted\\\".rs\"\n";
let state = GitState::from_git_status(output).unwrap();
assert!(state.get(Path::new("file\"quoted\".rs")).is_some());
}
#[test]
fn test_reject_absolute_path() {
let output = " M /etc/passwd\n";
let result = GitState::from_git_status(output);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, GitStateError::InvalidPath { .. }));
}
#[test]
fn test_reject_path_traversal() {
let output = " M ../outside/file.rs\n";
let result = GitState::from_git_status(output);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, GitStateError::InvalidPath { .. }));
}
#[test]
fn test_reject_hidden_traversal() {
let output = " M foo/../bar/../../etc/passwd\n";
let result = GitState::from_git_status(output);
assert!(result.is_err());
}
#[test]
fn test_diff_new_file() {
let old = GitState::default();
let mut new = GitState::default();
new.insert(PathBuf::from("new.rs"), FileStatus::New);
let events = old.diff(&new);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], FileEvent::Modified(p) if p == Path::new("new.rs")));
}
#[test]
fn test_diff_deleted_file() {
let mut old = GitState::default();
old.insert(PathBuf::from("deleted.rs"), FileStatus::Clean);
let new = GitState::default();
let events = old.diff(&new);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], FileEvent::Deleted(p) if p == Path::new("deleted.rs")));
}
#[test]
fn test_diff_file_with_deleted_status() {
let old = GitState::default();
let mut new = GitState::default();
new.insert(PathBuf::from("deleted.rs"), FileStatus::Deleted);
let events = old.diff(&new);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], FileEvent::Deleted(p) if p == Path::new("deleted.rs")));
}
#[test]
fn test_diff_modified_file() {
let mut old = GitState::default();
old.insert(PathBuf::from("file.rs"), FileStatus::Clean);
let mut new = GitState::default();
new.insert(PathBuf::from("file.rs"), FileStatus::Modified);
let events = old.diff(&new);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], FileEvent::Modified(p) if p == Path::new("file.rs")));
}
#[test]
fn test_diff_renamed_file() {
let mut old = GitState::default();
old.insert(PathBuf::from("old.rs"), FileStatus::Clean);
let mut new = GitState::default();
new.insert(
PathBuf::from("new.rs"),
FileStatus::Renamed {
from: PathBuf::from("old.rs"),
},
);
let events = old.diff(&new);
assert!(events.iter().any(|e| matches!(e, FileEvent::Renamed(o, n) if o == Path::new("old.rs") && n == Path::new("new.rs"))));
}
#[test]
fn test_diff_no_changes() {
let mut old = GitState::default();
old.insert(PathBuf::from("file.rs"), FileStatus::Clean);
let mut new = GitState::default();
new.insert(PathBuf::from("file.rs"), FileStatus::Clean);
let events = old.diff(&new);
assert!(events.is_empty());
}
#[test]
fn test_diff_multiple_changes() {
let mut old = GitState::default();
old.insert(PathBuf::from("existing.rs"), FileStatus::Clean);
old.insert(PathBuf::from("to-delete.rs"), FileStatus::Clean);
let mut new = GitState::default();
new.insert(PathBuf::from("existing.rs"), FileStatus::Modified);
new.insert(PathBuf::from("new.rs"), FileStatus::New);
let events = old.diff(&new);
assert_eq!(events.len(), 3); }
#[test]
fn test_gitstate_default() {
let state = GitState::default();
assert!(state.is_empty());
assert_eq!(state.len(), 0);
assert!(state.captured_at().is_none());
}
#[test]
fn test_gitstate_from_git_status_sets_timestamp() {
let output = "M file.rs\n";
let state = GitState::from_git_status(output).unwrap();
assert!(state.captured_at().is_some());
}
#[test]
fn test_unquote_simple_path() {
let path = unquote_path("src/main.rs").unwrap();
assert_eq!(path, PathBuf::from("src/main.rs"));
}
#[test]
fn test_unquote_quoted_path() {
let path = unquote_path("\"path with spaces.rs\"").unwrap();
assert_eq!(path, PathBuf::from("path with spaces.rs"));
}
#[test]
fn test_unquote_escaped_quotes() {
let path = unquote_path("\"file\\\"name\\\".rs\"").unwrap();
assert_eq!(path, PathBuf::from("file\"name\".rs"));
}
}