use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::grammar::parse_delta_specification;
use crate::provenance;
use crate::rendering::RenderedDocument;
#[derive(Debug, Error)]
pub enum MergeError {
#[error("IO failure at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("merge refused; no files were written:\n{}", format_refusals(refusals))]
Refused { refusals: Vec<Refusal> },
}
impl MergeError {
fn io(path: &Path, source: std::io::Error) -> Self {
MergeError::Io {
path: path.to_path_buf(),
source,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
pub target: String,
pub reason: RefusalReason,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefusalReason {
UnsupportedDelta(Vec<String>),
Drifted,
Unmanaged,
ForeignDrifted(String),
NonFileTarget,
ReviewGate(String),
}
impl std::fmt::Display for RefusalReason {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RefusalReason::UnsupportedDelta(operations) => write!(
formatter,
"{} delta operations are not supported yet \
(merging into existing documents is deferred)",
operations.join(", ")
),
RefusalReason::Drifted => write!(
formatter,
"drifted since last merge (hand edits present); \
rerun with --force to overwrite"
),
RefusalReason::Unmanaged => write!(
formatter,
"an unmanaged file occupies the target (no nbspec \
provenance); rerun with --force to overwrite"
),
RefusalReason::ForeignDrifted(change_id) => write!(
formatter,
"owned by change {change_id} and drifted from its recorded \
provenance; rerun with --force to take over"
),
RefusalReason::NonFileTarget => write!(
formatter,
"a directory or other non-file occupies the target; \
remove it manually (--force does not override)"
),
RefusalReason::ReviewGate(state) => write!(
formatter,
"review gate unsatisfied: {state}; record an approving \
verdict with nbspec review, or rerun with --force to \
override the gate"
),
}
}
}
fn format_refusals(refusals: &[Refusal]) -> String {
refusals
.iter()
.map(|refusal| format!("- {}: {}", refusal.target, refusal.reason))
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TargetStatus {
NotMerged,
Current,
UpdatePending,
Drifted,
Unmanaged,
OwnedByOtherChange(String),
ForeignDrifted(String),
NonFile,
}
impl std::fmt::Display for TargetStatus {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TargetStatus::NotMerged => write!(formatter, "not merged"),
TargetStatus::Current => write!(formatter, "merged, current"),
TargetStatus::UpdatePending => {
write!(formatter, "merged, notebook update pending")
}
TargetStatus::Drifted => {
write!(formatter, "drifted (hand edits since last merge)")
}
TargetStatus::Unmanaged => {
write!(
formatter,
"unmanaged file at target (not written by nbspec)"
)
}
TargetStatus::OwnedByOtherChange(change_id) => {
write!(
formatter,
"owned by change {change_id}; clean succession available"
)
}
TargetStatus::ForeignDrifted(change_id) => {
write!(
formatter,
"owned by change {change_id}, drifted since its materialization"
)
}
TargetStatus::NonFile => {
write!(formatter, "blocked: a non-file occupies the target")
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Succession {
pub target: String,
pub previous_owner: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MergeReport {
pub written: Vec<String>,
pub unchanged: Vec<String>,
pub review_gate_overridden: Option<String>,
pub successions: Vec<Succession>,
pub drift_overrides: Vec<Succession>,
}
pub fn target_status(
document: &RenderedDocument,
project_root: &Path,
change_id: &str,
) -> Result<TargetStatus, MergeError> {
let Some(target_path) = &document.target_path else {
return Ok(TargetStatus::NotMerged);
};
let absolute = project_root.join(Path::new(target_path));
if !absolute.exists() {
return Ok(TargetStatus::NotMerged);
}
if !absolute.is_file() {
return Ok(TargetStatus::NonFile);
}
let content =
std::fs::read_to_string(&absolute).map_err(|error| MergeError::io(&absolute, error))?;
let (header, body) = provenance::split_document(&content);
let Some(header) = header else {
return Ok(TargetStatus::Unmanaged);
};
if header.change_id != change_id {
if provenance::body_matches(&header, body) {
return Ok(TargetStatus::OwnedByOtherChange(header.change_id));
}
return Ok(TargetStatus::ForeignDrifted(header.change_id));
}
if !provenance::body_matches(&header, body) {
return Ok(TargetStatus::Drifted);
}
if body == document.content {
Ok(TargetStatus::Current)
} else {
Ok(TargetStatus::UpdatePending)
}
}
pub fn merge_documents(
documents: &[RenderedDocument],
project_root: &Path,
change_id: &str,
notebook: &str,
review_gate_state: Option<&str>,
force: bool,
) -> Result<MergeReport, MergeError> {
let mut refusals = Vec::new();
let mut writes: Vec<(String, String)> = Vec::new();
let mut report = MergeReport::default();
if let Some(state) = review_gate_state {
if force {
report.review_gate_overridden = Some(state.to_string());
} else {
refusals.push(Refusal {
target: change_id.to_string(),
reason: RefusalReason::ReviewGate(state.to_string()),
});
}
}
for document in documents {
let Some(target_path) = &document.target_path else {
continue;
};
if let Some(operations) = unsupported_operations(&document.content) {
refusals.push(Refusal {
target: target_path.clone(),
reason: RefusalReason::UnsupportedDelta(operations),
});
continue;
}
let status = target_status(document, project_root, change_id)?;
if status == TargetStatus::NonFile {
refusals.push(Refusal {
target: target_path.clone(),
reason: RefusalReason::NonFileTarget,
});
continue;
}
let refusal = match &status {
TargetStatus::Drifted => Some(RefusalReason::Drifted),
TargetStatus::Unmanaged => Some(RefusalReason::Unmanaged),
TargetStatus::ForeignDrifted(other) => {
Some(RefusalReason::ForeignDrifted(other.clone()))
}
TargetStatus::NotMerged
| TargetStatus::Current
| TargetStatus::UpdatePending
| TargetStatus::OwnedByOtherChange(_)
| TargetStatus::NonFile => None,
};
if let Some(reason) = refusal {
if !force {
refusals.push(Refusal {
target: target_path.clone(),
reason,
});
continue;
}
if let RefusalReason::ForeignDrifted(previous_owner) = &reason {
report.drift_overrides.push(Succession {
target: target_path.clone(),
previous_owner: previous_owner.clone(),
});
}
}
if let TargetStatus::OwnedByOtherChange(previous_owner) = &status {
report.successions.push(Succession {
target: target_path.clone(),
previous_owner: previous_owner.clone(),
});
}
if status == TargetStatus::Current {
report.unchanged.push(target_path.clone());
continue;
}
let stamped = provenance::stamp(
&document.content,
change_id,
notebook,
&document.source_note,
);
writes.push((target_path.clone(), stamped));
}
if !refusals.is_empty() {
return Err(MergeError::Refused { refusals });
}
for (target_path, content) in writes {
let absolute = project_root.join(Path::new(&target_path));
if let Some(parent) = absolute.parent() {
std::fs::create_dir_all(parent).map_err(|error| MergeError::io(parent, error))?;
}
std::fs::write(&absolute, content).map_err(|error| MergeError::io(&absolute, error))?;
report.written.push(target_path);
}
Ok(report)
}
fn unsupported_operations(content: &str) -> Option<Vec<String>> {
let presence = parse_delta_specification(content).presence;
let mut operations = Vec::new();
if presence.modified {
operations.push("MODIFIED".to_string());
}
if presence.removed {
operations.push("REMOVED".to_string());
}
if presence.renamed {
operations.push("RENAMED".to_string());
}
if operations.is_empty() {
None
} else {
Some(operations)
}
}