use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionCorrelation {
pub worktree: Option<PathBuf>,
pub branch: Option<String>,
pub pr_id: Option<u64>,
pub issue_id: Option<u64>,
}
impl SessionCorrelation {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_worktree(mut self, path: impl Into<PathBuf>) -> Self {
self.worktree = Some(path.into());
self
}
#[must_use]
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
self.branch = Some(branch.into());
self
}
#[must_use]
pub fn with_pr_id(mut self, pr_id: u64) -> Self {
self.pr_id = Some(pr_id);
self
}
#[must_use]
pub fn with_issue_id(mut self, issue_id: u64) -> Self {
self.issue_id = Some(issue_id);
self
}
pub fn is_uncorrelated(&self) -> bool {
self.worktree.is_none()
&& self.branch.is_none()
&& self.pr_id.is_none()
&& self.issue_id.is_none()
}
pub fn path_in_scope(&self, path: impl AsRef<Path>) -> bool {
match &self.worktree {
Some(root) => path.as_ref().starts_with(root),
None => false,
}
}
pub fn validate_in_scope(
&self,
touched_paths: &[PathBuf],
referenced_issue_ids: &[u64],
) -> ScopeCheck {
if self.is_uncorrelated() {
return ScopeCheck::Uncorrelated;
}
let stray_paths: Vec<PathBuf> = if self.worktree.is_some() {
touched_paths
.iter()
.filter(|p| !self.path_in_scope(p))
.cloned()
.collect()
} else {
Vec::new()
};
let foreign_issue_ids: Vec<u64> = match self.issue_id {
Some(expected) => referenced_issue_ids
.iter()
.copied()
.filter(|id| *id != expected)
.collect(),
None => Vec::new(),
};
if stray_paths.is_empty() && foreign_issue_ids.is_empty() {
ScopeCheck::InScope
} else {
ScopeCheck::OutOfScope {
stray_paths,
foreign_issue_ids,
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeCheck {
InScope,
OutOfScope {
stray_paths: Vec<PathBuf>,
foreign_issue_ids: Vec<u64>,
},
Uncorrelated,
}
impl ScopeCheck {
pub fn is_in_scope(&self) -> bool {
matches!(self, ScopeCheck::InScope)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_fully_unset() {
let c = SessionCorrelation::new();
assert!(c.worktree.is_none());
assert!(c.branch.is_none());
assert!(c.pr_id.is_none());
assert!(c.issue_id.is_none());
assert!(c.is_uncorrelated());
}
#[test]
fn with_builders_set_fields() {
let c = SessionCorrelation::new()
.with_worktree("/tmp/wt")
.with_branch("feat/x")
.with_pr_id(42)
.with_issue_id(1204);
assert_eq!(c.worktree, Some(PathBuf::from("/tmp/wt")));
assert_eq!(c.branch.as_deref(), Some("feat/x"));
assert_eq!(c.pr_id, Some(42));
assert_eq!(c.issue_id, Some(1204));
}
#[test]
fn is_uncorrelated_tracks_fields() {
assert!(SessionCorrelation::new().is_uncorrelated());
assert!(!SessionCorrelation::new().with_branch("b").is_uncorrelated());
assert!(!SessionCorrelation::new().with_issue_id(1).is_uncorrelated());
}
#[test]
fn serde_round_trip() {
let c = SessionCorrelation::new()
.with_worktree("/tmp/wt")
.with_branch("feat/x")
.with_pr_id(7)
.with_issue_id(1204);
let json = serde_json::to_string(&c).expect("serialize");
let back: SessionCorrelation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(c, back);
}
#[test]
fn path_in_worktree_matches_descendants() {
let c = SessionCorrelation::new().with_worktree("/repo/wt");
assert!(c.path_in_scope("/repo/wt"));
assert!(c.path_in_scope("/repo/wt/src/lib.rs"));
}
#[test]
fn path_out_of_worktree() {
let c = SessionCorrelation::new().with_worktree("/repo/wt");
assert!(!c.path_in_scope("/repo/other/file.rs"));
assert!(!c.path_in_scope("/etc/passwd"));
}
#[test]
fn path_in_scope_without_worktree_is_false() {
let c = SessionCorrelation::new().with_branch("b");
assert!(!c.path_in_scope("/anything"));
}
#[test]
fn validate_in_scope_uncorrelated() {
let c = SessionCorrelation::new();
let check = c.validate_in_scope(&[PathBuf::from("/x")], &[1]);
assert_eq!(check, ScopeCheck::Uncorrelated);
assert!(!check.is_in_scope());
}
#[test]
fn validate_in_scope_all_paths_inside() {
let c = SessionCorrelation::new()
.with_worktree("/repo/wt")
.with_issue_id(1204);
let check = c.validate_in_scope(
&[
PathBuf::from("/repo/wt/a.rs"),
PathBuf::from("/repo/wt/sub/b.rs"),
],
&[1204],
);
assert_eq!(check, ScopeCheck::InScope);
assert!(check.is_in_scope());
}
#[test]
fn validate_in_scope_stray_path() {
let c = SessionCorrelation::new().with_worktree("/repo/wt");
let stray = PathBuf::from("/repo/other.rs");
let check = c.validate_in_scope(&[PathBuf::from("/repo/wt/a.rs"), stray.clone()], &[]);
match check {
ScopeCheck::OutOfScope {
stray_paths,
foreign_issue_ids,
} => {
assert_eq!(stray_paths, vec![stray]);
assert!(foreign_issue_ids.is_empty());
}
other => panic!("expected OutOfScope, got {other:?}"),
}
}
#[test]
fn validate_in_scope_foreign_issue() {
let c = SessionCorrelation::new()
.with_worktree("/repo/wt")
.with_issue_id(1204);
let check = c.validate_in_scope(&[PathBuf::from("/repo/wt/a.rs")], &[1204, 9999]);
match check {
ScopeCheck::OutOfScope {
stray_paths,
foreign_issue_ids,
} => {
assert!(stray_paths.is_empty());
assert_eq!(foreign_issue_ids, vec![9999]);
}
other => panic!("expected OutOfScope, got {other:?}"),
}
}
#[test]
fn validate_in_scope_ignores_issue_when_uncorrelated_issue() {
let c = SessionCorrelation::new().with_worktree("/repo/wt");
let check = c.validate_in_scope(&[PathBuf::from("/repo/wt/a.rs")], &[1, 2, 3]);
assert_eq!(check, ScopeCheck::InScope);
}
}