#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StageDefect {
Unstaged,
Untracked,
}
impl StageDefect {
pub fn as_str(self) -> &'static str {
match self {
Self::Unstaged => "unstaged",
Self::Untracked => "untracked",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StageDefectEntry {
pub defect: StageDefect,
pub path: String,
}
pub const MAX_REPORTED_STAGE_PATHS: usize = 20;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WorkspaceStageStatus {
pub defects: Vec<StageDefectEntry>,
}
impl WorkspaceStageStatus {
pub fn is_clean(&self) -> bool {
self.defects.is_empty()
}
pub fn unstaged_paths(&self) -> Vec<&str> {
self.paths_with(StageDefect::Unstaged)
}
pub fn untracked_paths(&self) -> Vec<&str> {
self.paths_with(StageDefect::Untracked)
}
fn paths_with(&self, defect: StageDefect) -> Vec<&str> {
self.defects
.iter()
.filter(|entry| entry.defect == defect)
.map(|entry| entry.path.as_str())
.collect()
}
pub fn bounded_paths_report(&self) -> String {
let mut lines = Vec::new();
for entry in self.defects.iter().take(MAX_REPORTED_STAGE_PATHS) {
lines.push(format!("{}: {}", entry.defect.as_str(), entry.path));
}
let elided = self.defects.len().saturating_sub(MAX_REPORTED_STAGE_PATHS);
if elided > 0 {
lines.push(format!(
"... and {} more affected path(s); the complete status is in the persistent log",
elided
));
}
lines.join("\n")
}
pub fn summary(&self) -> String {
format!(
"{} unstaged and {} untracked workspace entr(ies) remain",
self.unstaged_paths().len(),
self.untracked_paths().len()
)
}
}
pub fn classify_porcelain_status(porcelain: &str) -> WorkspaceStageStatus {
let mut defects = Vec::new();
for line in porcelain.lines() {
if line.len() < 4 {
continue;
}
let mut columns = line.chars();
let index_column = columns.next().unwrap_or(' ');
let worktree_column = columns.next().unwrap_or(' ');
let path = line[3..].to_string();
if index_column == '?' && worktree_column == '?' {
defects.push(StageDefectEntry {
defect: StageDefect::Untracked,
path,
});
continue;
}
if index_column == '!' && worktree_column == '!' {
continue;
}
if worktree_column != ' ' {
defects.push(StageDefectEntry {
defect: StageDefect::Unstaged,
path,
});
}
}
WorkspaceStageStatus { defects }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fully_staged_workspace_passes_the_gate() {
let status = classify_porcelain_status("M src/lib.rs\nA src/new.rs\nD src/old.rs\n");
assert!(
status.is_clean(),
"staged-only entries must pass: {status:?}"
);
}
#[test]
fn an_empty_status_passes_the_gate() {
assert!(classify_porcelain_status("").is_clean());
}
#[test]
fn an_unstaged_worktree_column_fails_the_gate() {
let status = classify_porcelain_status(" M src/lib.rs\n");
assert!(!status.is_clean());
assert_eq!(status.unstaged_paths(), vec!["src/lib.rs"]);
assert!(status.untracked_paths().is_empty());
}
#[test]
fn a_staged_file_edited_again_fails_the_gate() {
let status = classify_porcelain_status("MM src/lib.rs\n");
assert_eq!(status.unstaged_paths(), vec!["src/lib.rs"]);
}
#[test]
fn an_untracked_file_fails_the_gate() {
let status = classify_porcelain_status("?? notes.txt\n");
assert_eq!(status.untracked_paths(), vec!["notes.txt"]);
assert!(status.unstaged_paths().is_empty());
}
#[test]
fn unmerged_entries_fail_the_gate() {
let status = classify_porcelain_status("UU src/conflict.rs\n");
assert_eq!(status.unstaged_paths(), vec!["src/conflict.rs"]);
}
#[test]
fn ignored_entries_do_not_fail_the_gate() {
assert!(classify_porcelain_status("!! target/debug\n").is_clean());
}
#[test]
fn a_staged_rename_passes_and_keeps_its_reported_path() {
let status = classify_porcelain_status("R old.rs -> new.rs\n");
assert!(status.is_clean());
let dirty = classify_porcelain_status("RM old.rs -> new.rs\n");
assert_eq!(dirty.unstaged_paths(), vec!["old.rs -> new.rs"]);
}
#[test]
fn mixed_defects_are_reported_in_porcelain_order() {
let status = classify_porcelain_status(" M a.rs\n?? b.rs\nM c.rs\n M d.rs\n");
assert_eq!(
status.defects,
vec![
StageDefectEntry {
defect: StageDefect::Unstaged,
path: "a.rs".to_string(),
},
StageDefectEntry {
defect: StageDefect::Untracked,
path: "b.rs".to_string(),
},
StageDefectEntry {
defect: StageDefect::Unstaged,
path: "d.rs".to_string(),
},
]
);
}
#[test]
fn the_reported_path_list_is_bounded_and_states_what_it_dropped() {
let porcelain = (0..MAX_REPORTED_STAGE_PATHS + 5)
.map(|index| format!("?? file{index}.txt"))
.collect::<Vec<_>>()
.join("\n");
let status = classify_porcelain_status(&porcelain);
let report = status.bounded_paths_report();
assert_eq!(
report.lines().count(),
MAX_REPORTED_STAGE_PATHS + 1,
"the report keeps the cap plus one truncation notice: {report}"
);
assert!(
report.contains("... and 5 more affected path(s)"),
"truncation must be stated: {report}"
);
assert!(!report.contains("file20.txt"), "report: {report}");
}
#[test]
fn short_or_blank_lines_never_invent_a_defect() {
assert!(classify_porcelain_status("\n \nM\nMM\n").is_clean());
}
#[test]
fn the_summary_counts_both_defect_kinds() {
let status = classify_porcelain_status(" M a.rs\n?? b.rs\n?? c.rs\n");
assert_eq!(
status.summary(),
"1 unstaged and 2 untracked workspace entr(ies) remain"
);
}
}