use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PathChange {
Modified,
Added,
Deleted,
Untracked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingTreeEntry {
pub path: String,
pub change: PathChange,
}
impl WorkingTreeEntry {
pub fn new(path: impl Into<String>, change: PathChange) -> Self {
Self {
path: path.into(),
change,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReservationLease {
pub pattern: String,
pub exclusive: bool,
}
impl ReservationLease {
pub fn new(pattern: impl Into<String>, exclusive: bool) -> Self {
Self {
pattern: pattern.into(),
exclusive,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CleanOverlayRequest {
pub head_commit: String,
pub working_tree: Vec<WorkingTreeEntry>,
pub selected_paths: Vec<String>,
pub reservations: Vec<ReservationLease>,
pub command_intent: String,
pub report_only: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExclusionReason {
PeerDirtyUnselected,
UnreservedSelection,
DeletedSelectionRefused,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExcludedPath {
pub path: String,
pub reason: ExclusionReason,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CleanOverlayManifest {
pub head_commit: String,
pub selected_paths: Vec<String>,
pub included_paths: Vec<String>,
pub excluded_paths: Vec<ExcludedPath>,
pub reservation_evidence: Vec<String>,
pub command_intent: String,
pub report_only: bool,
pub blocked: bool,
pub no_claim_boundaries: Vec<String>,
}
fn normalize_path(raw: &str) -> String {
let trimmed = raw.trim();
let mut out = String::with_capacity(trimmed.len());
let mut prev_slash = false;
for ch in trimmed.chars() {
let ch = if ch == '\\' { '/' } else { ch };
if ch == '/' {
if prev_slash {
continue;
}
prev_slash = true;
} else {
prev_slash = false;
}
out.push(ch);
}
while out.starts_with("./") {
out.drain(..2);
}
while out.ends_with('/') {
out.pop();
}
out
}
fn glob_match(pattern: &str, name: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let txt: Vec<char> = name.chars().collect();
let mut p = 0usize;
let mut t = 0usize;
let mut star: Option<usize> = None;
let mut star_t = 0usize;
while t < txt.len() {
if p < pat.len() && (pat[p] == '?' || pat[p] == txt[t]) {
p += 1;
t += 1;
} else if p < pat.len() && pat[p] == '*' {
star = Some(p);
star_t = t;
p += 1;
} else if let Some(sp) = star {
p = sp + 1;
star_t += 1;
t = star_t;
} else {
return false;
}
}
while p < pat.len() && pat[p] == '*' {
p += 1;
}
p == pat.len()
}
fn covering_reservation(path: &str, patterns: &[String]) -> Option<String> {
let mut best: Option<&String> = None;
for pattern in patterns {
if glob_match(pattern, path) {
match best {
Some(current) if current <= pattern => {}
_ => best = Some(pattern),
}
}
}
best.cloned()
}
fn reason_rank(reason: ExclusionReason) -> u8 {
match reason {
ExclusionReason::DeletedSelectionRefused => 0,
ExclusionReason::UnreservedSelection => 1,
ExclusionReason::PeerDirtyUnselected => 2,
}
}
fn build_boundaries(
request: &CleanOverlayRequest,
included: &[String],
excluded: &[ExcludedPath],
effective_blocked: bool,
fail_closed_path_count: usize,
) -> Vec<String> {
let peer = excluded
.iter()
.filter(|e| e.reason == ExclusionReason::PeerDirtyUnselected)
.count();
let unreserved = excluded
.iter()
.filter(|e| e.reason == ExclusionReason::UnreservedSelection)
.count();
let deleted = excluded
.iter()
.filter(|e| e.reason == ExclusionReason::DeletedSelectionRefused)
.count();
let mut out = Vec::with_capacity(5);
out.push(format!("head_commit={}", request.head_commit.trim()));
out.push(format!(
"included={} path(s) overlaid on HEAD",
included.len()
));
out.push(format!(
"excluded={} path(s): peer_dirty={peer}, unreserved={unreserved}, deleted={deleted}",
excluded.len()
));
if effective_blocked {
out.push(format!(
"BLOCKED: fail-closed on {fail_closed_path_count} dirty/deleted path(s); no proof claim emitted"
));
} else if fail_closed_path_count > 0 && request.report_only {
out.push(format!(
"report-only: {fail_closed_path_count} path(s) would fail closed in an enforced run; no proof claim"
));
} else {
out.push("proof claim limited to included paths; HEAD baseline otherwise".to_string());
}
out.push(
"constraints: no git branch/worktree/clone; RCH-only; no file deletion; no workspace-wide health claim"
.to_string(),
);
out
}
pub fn plan_clean_overlay(request: &CleanOverlayRequest) -> CleanOverlayManifest {
let mut change_by_path: BTreeMap<String, PathChange> = BTreeMap::new();
for entry in &request.working_tree {
let path = normalize_path(&entry.path);
if !path.is_empty() {
change_by_path.insert(path, entry.change);
}
}
let mut patterns: Vec<String> = request
.reservations
.iter()
.filter(|lease| lease.exclusive)
.map(|lease| normalize_path(&lease.pattern))
.filter(|pattern| !pattern.is_empty())
.collect();
patterns.sort();
patterns.dedup();
let mut selected: BTreeSet<String> = BTreeSet::new();
let mut included: BTreeSet<String> = BTreeSet::new();
let mut evidence: BTreeSet<String> = BTreeSet::new();
let mut excluded: Vec<ExcludedPath> = Vec::new();
let mut fail_closed_path_count = 0usize;
let mut blocked = false;
for raw in &request.selected_paths {
let path = normalize_path(raw);
if path.is_empty() || !selected.insert(path.clone()) {
continue;
}
match change_by_path.get(&path).copied() {
Some(PathChange::Deleted) => {
excluded.push(ExcludedPath {
path,
reason: ExclusionReason::DeletedSelectionRefused,
});
fail_closed_path_count += 1;
blocked = true;
}
Some(PathChange::Modified | PathChange::Added | PathChange::Untracked) => {
if let Some(pattern) = covering_reservation(&path, &patterns) {
included.insert(path);
evidence.insert(pattern);
} else {
excluded.push(ExcludedPath {
path,
reason: ExclusionReason::UnreservedSelection,
});
fail_closed_path_count += 1;
blocked = true;
}
}
None => {
included.insert(path);
}
}
}
for path in change_by_path.keys() {
if !selected.contains(path) {
excluded.push(ExcludedPath {
path: path.clone(),
reason: ExclusionReason::PeerDirtyUnselected,
});
fail_closed_path_count += 1;
blocked = true;
}
}
let effective_blocked = blocked && !request.report_only;
excluded.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| reason_rank(a.reason).cmp(&reason_rank(b.reason)))
});
excluded.dedup();
let included_paths: Vec<String> = included.into_iter().collect();
let reservation_evidence: Vec<String> = evidence.into_iter().collect();
let no_claim_boundaries = build_boundaries(
request,
&included_paths,
&excluded,
effective_blocked,
fail_closed_path_count,
);
CleanOverlayManifest {
head_commit: request.head_commit.trim().to_string(),
selected_paths: selected.into_iter().collect(),
included_paths,
excluded_paths: excluded,
reservation_evidence,
command_intent: request.command_intent.clone(),
report_only: request.report_only,
blocked: effective_blocked,
no_claim_boundaries,
}
}