use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::Path;
mod worktree_files;
use prikk_error::{PrikkError, Result};
use prikk_object::{
BlobKind, BlobPayload, CanonicalEncode, ChangePerm, CreateFile, DeleteNode, DeleteNodePreimage,
EditText, NodeId, NodeKind, ObjectEnvelope, ObjectId, ObjectType, Operation, OperationKind,
PATCH_PARENT_IDS_RETIRED_SCHEMA, PatchPayload, PatchPurpose, ReplaceBinary,
};
use crate::active::{prepare_empty_active_ref_for_append, require_active_ref_for_non_empty_wal};
use crate::author_signing::AuthorSigner;
use crate::commit_index::{self, CommitIndex, CommitIndexEntry};
use crate::fsutil::{RootFileStat, read_file_if_exists};
use crate::layout::RepositoryLayout;
use crate::lifecycle_cache::incremental::resolve_baseline_state;
use crate::lock::ActiveLock;
use crate::node_id_gen::{NodeIdEntropySource, NodeIdGenerator};
use crate::node_lifecycle::{LiveNode, NodeContent, NodeLifecycleState};
use crate::object_store::{ObjectReader, ObjectWriteSession, ObjectWriter};
use crate::patch_replay::{WorktreeBaseline, resolve_worktree_baseline};
use crate::path::RepoPath;
use crate::text_span;
use crate::wal::Wal;
use crate::worktree_marker::worktree_is_dirty;
use crate::worktree_patch::{
WorktreePatchCommitOptions, WorktreePatchCommitReport, WorktreePatchOperationKind,
WorktreePatchOperationSummary, next_op_seq,
};
use crate::{
ActiveRefMetadata, read_active_ref_metadata, remove_active_ref_metadata,
validate_local_branch_ref,
};
use worktree_files::{WorktreeFileMeta, enumerate_worktree_files};
const REGULAR_FILE_MODE: u32 = 0o100_644;
const EXECUTABLE_FILE_MODE: u32 = 0o100_755;
#[derive(Debug)]
pub(crate) enum AuthorError {
NodeIdentityUnavailable(String),
UnsupportedKindTransition(String),
UnsupportedSymlinkAuthoring(String),
Mint(crate::node_id_gen::NodeIdMintError),
Store(PrikkError),
}
impl fmt::Display for AuthorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NodeIdentityUnavailable(detail) => {
write!(f, "worktree authoring: node identity unavailable: {detail}")
}
Self::UnsupportedKindTransition(detail) => {
write!(
f,
"worktree authoring: unsupported kind transition: {detail}"
)
}
Self::UnsupportedSymlinkAuthoring(detail) => {
write!(
f,
"worktree authoring: unsupported symlink authoring: {detail}"
)
}
Self::Mint(e) => write!(f, "worktree authoring: {e}"),
Self::Store(e) => write!(f, "worktree authoring: {e}"),
}
}
}
impl From<AuthorError> for PrikkError {
fn from(e: AuthorError) -> Self {
match e {
AuthorError::Store(inner) => inner,
AuthorError::Mint(inner) => inner.into(),
other => PrikkError::Integrity(other.to_string()),
}
}
}
impl From<PrikkError> for AuthorError {
fn from(e: PrikkError) -> Self {
AuthorError::Store(e)
}
}
struct BaselineFile {
node_id: NodeId,
kind: NodeKind,
blob_id: ObjectId,
mode: u32,
}
struct PlannedOp {
kind: OperationKind,
path: String,
node_id: NodeId,
summary_kind: WorktreePatchOperationKind,
blob_refs: usize,
}
fn kind_rank(kind: &OperationKind) -> u8 {
match kind {
OperationKind::DeleteNode(_) => 0,
OperationKind::CreateFile(_) => 1,
OperationKind::ChangePerm(_) => 2,
OperationKind::ReplaceBinary(_) => 3,
OperationKind::EditText(_) => 4,
OperationKind::CreateSymlink(_) | OperationKind::RenamePath(_) => 5,
}
}
pub(crate) fn author_worktree_patch<S: NodeIdEntropySource, A: AuthorSigner>(
layout: &RepositoryLayout,
ref_name: &str,
message: &str,
options: WorktreePatchCommitOptions,
generator: &mut NodeIdGenerator<S>,
signer: &A,
) -> Result<WorktreePatchCommitReport> {
if message.trim().is_empty() {
return Err(PrikkError::InvalidName(
"commit message must not be empty".to_string(),
));
}
author_inner(
layout,
ref_name,
message,
options.active_patch_limit,
generator,
signer,
)
.map_err(PrikkError::from)
}
fn author_inner<S: NodeIdEntropySource, A: AuthorSigner>(
layout: &RepositoryLayout,
ref_name: &str,
_message: &str,
active_patch_limit: usize,
generator: &mut NodeIdGenerator<S>,
signer: &A,
) -> std::result::Result<WorktreePatchCommitReport, AuthorError> {
let canonical_ref = validate_local_branch_ref(ref_name).map_err(AuthorError::Store)?;
let active_lock = ActiveLock::acquire(layout).map_err(AuthorError::Store)?;
crate::refs::ensure_no_incomplete_publication(layout).map_err(AuthorError::Store)?;
if worktree_is_dirty(layout).map_err(AuthorError::Store)? {
return Err(AuthorError::Store(PrikkError::Integrity(
"worktree materialization was interrupted; the worktree must be re-verified against \
its baseline before committing (re-run checkout materialization to complete it)"
.to_string(),
)));
}
let wal = Wal::for_layout(layout);
let active_replay = wal.replay().map_err(AuthorError::Store)?;
if active_replay.trailing_partial_bytes != 0 {
return Err(AuthorError::Store(PrikkError::InvalidName(format!(
"active WAL has {} trailing partial bytes; run `prikk doctor --repair-wal-tail` \
before committing",
active_replay.trailing_partial_bytes
))));
}
if active_replay.has_item_failure() {
return Err(AuthorError::Store(PrikkError::Integrity(
"active WAL has a damaged record; run doctor before committing".to_string(),
)));
}
if crate::worktree_patch::active_patch_limit_exceeded(
active_replay.records.len(),
active_patch_limit,
) {
return Err(AuthorError::Store(PrikkError::LockConflict(format!(
"active WAL has {} queued patches, at or above the configured limit ({active_patch_limit}); \
run `prikk seal` before committing again",
active_replay.records.len()
))));
}
if active_replay.records.is_empty() {
match read_active_ref_metadata(layout).map_err(AuthorError::Store)? {
ActiveRefMetadata::Missing => {}
ActiveRefMetadata::Valid(_) | ActiveRefMetadata::Invalid(_) => {
remove_active_ref_metadata(layout).map_err(AuthorError::Store)?;
}
}
} else {
require_active_ref_for_non_empty_wal(layout, &canonical_ref).map_err(AuthorError::Store)?;
}
let mut object_store = ObjectWriteSession::open(layout).map_err(AuthorError::Store)?;
let baseline = resolve_worktree_baseline(layout, ref_name)?;
let (lineage_baseline_block_id, lineage_horizon_id) = match &baseline {
WorktreeBaseline::Published {
baseline_block,
horizon,
} => (Some(*baseline_block), Some(*horizon)),
WorktreeBaseline::Genesis => (None, None),
};
let mut baseline_state: NodeLifecycleState = match &baseline {
WorktreeBaseline::Published {
baseline_block,
horizon,
} => resolve_baseline_state(layout, &object_store, *baseline_block, *horizon)?
.state()
.clone(),
WorktreeBaseline::Genesis => NodeLifecycleState::new(),
};
let mut queue_text_cache = crate::lifecycle_cache::replay::TextCache::new();
if !active_replay.records.is_empty() {
crate::lifecycle_cache::replay::apply_queued_patch_envelopes(
&object_store,
&active_replay.records,
&mut baseline_state,
&mut queue_text_cache,
match &baseline {
WorktreeBaseline::Published {
baseline_block,
horizon,
} => Some((*baseline_block, *horizon)),
WorktreeBaseline::Genesis => None,
},
)?;
}
let mut baseline_files: BTreeMap<String, BaselineFile> = BTreeMap::new();
let mut baseline_symlinks: BTreeMap<String, NodeId> = BTreeMap::new();
for (node_id, node) in baseline_state.live_nodes() {
match &node.content {
NodeContent::File { blob_id, mode } => {
baseline_files.insert(
node.path.as_str().to_string(),
BaselineFile {
node_id: *node_id,
kind: node.kind,
blob_id: *blob_id,
mode: *mode,
},
);
}
NodeContent::Symlink { .. } => {
baseline_symlinks.insert(node.path.as_str().to_string(), *node_id);
}
}
}
if let WorktreeBaseline::Published { baseline_block, .. } = &baseline {
if baseline_files.is_empty()
&& baseline_symlinks.is_empty()
&& baseline_block_has_snapshot_ref(&object_store, *baseline_block)?
{
return Err(AuthorError::NodeIdentityUnavailable(
"baseline is snapshot-derived and carries no node identity; \
a node-addressed baseline is required for worktree authoring"
.to_string(),
));
}
}
let worktree = enumerate_worktree_files(layout)?;
let mut commit_index = CommitIndex::load(layout).map_err(AuthorError::Store)?;
let mut working_state = baseline_state.clone();
let mut planned: Vec<PlannedOp> = Vec::new();
let mut create_candidates: Vec<(String, Vec<u8>, u32)> = Vec::new();
for (path, meta) in &worktree {
if let Some(base) = baseline_files.get(path) {
match base.kind {
NodeKind::TextFile => {
let resolved = resolve_existing_file(
layout,
&mut commit_index,
path,
meta,
base.mode,
BlobKind::Text,
)?;
if resolved.content_hash != base.blob_id {
let bytes = match resolved.bytes {
Some(bytes) => bytes,
None => read_existing_file_bytes(layout, path, BlobKind::Text)?,
};
planned.push(plan_edit_text(
&object_store,
base,
&bytes,
path,
lineage_baseline_block_id,
lineage_horizon_id,
&queue_text_cache,
)?);
}
}
NodeKind::BinaryFile => {
let resolved = resolve_existing_file(
layout,
&mut commit_index,
path,
meta,
base.mode,
BlobKind::Binary,
)?;
if resolved.content_hash != base.blob_id {
let bytes = match resolved.bytes {
Some(bytes) => bytes,
None => read_existing_file_bytes(layout, path, BlobKind::Binary)?,
};
planned.push(plan_replace_binary(&mut object_store, base, &bytes, path)?);
}
}
NodeKind::Symlink => {
return Err(AuthorError::UnsupportedSymlinkAuthoring(format!(
"{path}: symlink node modification is out of scope"
)));
}
}
if let Some(op) = plan_mode_change_if_observed(base, meta.mode, path) {
planned.push(op);
}
} else if baseline_symlinks.contains_key(path) {
return Err(AuthorError::UnsupportedSymlinkAuthoring(format!(
"{path}: symlink node modification is out of scope"
)));
} else {
let bytes = read_worktree_file_bytes(layout, path)?;
let (blob_kind, _node_kind) = classify_new(&bytes);
let content_hash =
commit_index::content_hash(blob_kind, &bytes).map_err(AuthorError::Store)?;
let resolved_mode = meta.mode.unwrap_or(REGULAR_FILE_MODE);
commit_index.record(
path.clone(),
CommitIndexEntry {
size: meta.size,
mtime_secs: meta.mtime_secs,
mtime_nanos: meta.mtime_nanos,
mode: resolved_mode,
kind: blob_kind,
content_hash,
},
);
create_candidates.push((path.clone(), bytes, resolved_mode));
}
}
for (path, base) in &baseline_files {
if !worktree.contains_key(path) {
planned.push(plan_delete(base, path));
}
}
for path in baseline_symlinks.keys() {
if !worktree.contains_key(path) {
return Err(AuthorError::UnsupportedSymlinkAuthoring(format!(
"{path}: symlink node deletion is out of scope"
)));
}
}
let live_paths: BTreeSet<String> = worktree.keys().cloned().collect();
commit_index.retain_paths(&live_paths);
commit_index.save(layout).map_err(AuthorError::Store)?;
create_candidates.sort_by(|a, b| a.0.cmp(&b.0));
for (path, bytes, mode) in &create_candidates {
let repo_path = RepoPath::parse(path).map_err(AuthorError::Store)?;
let (blob_kind, node_kind) = classify_new(bytes);
let blob_id = write_content_blob(&mut object_store, blob_kind, bytes)?;
let node_id = generator
.mint_fresh(&working_state)
.map_err(AuthorError::Mint)?;
working_state
.create_node(
node_id,
LiveNode {
path: repo_path,
kind: node_kind,
content: NodeContent::File {
blob_id,
mode: *mode,
},
},
)
.map_err(AuthorError::Store)?;
planned.push(PlannedOp {
kind: OperationKind::CreateFile(CreateFile {
path: path.clone(),
node_id,
blob_id,
mode: *mode,
}),
path: path.clone(),
node_id,
summary_kind: WorktreePatchOperationKind::CreateFile,
blob_refs: 1,
});
}
if planned.is_empty() {
return Err(AuthorError::Store(PrikkError::InvalidName(
"worktree has no node-addressed changes to commit".to_string(),
)));
}
planned.sort_by(|a, b| {
kind_rank(&a.kind)
.cmp(&kind_rank(&b.kind))
.then_with(|| a.path.as_bytes().cmp(b.path.as_bytes()))
.then_with(|| a.node_id.as_bytes().cmp(b.node_id.as_bytes()))
});
let mut operations = Vec::with_capacity(planned.len());
let mut referenced_blob_count = 0_usize;
let mut text_edit_count = 0_usize;
let mut summaries = Vec::with_capacity(planned.len());
for (index, op) in planned.into_iter().enumerate() {
let op_seq = next_op_seq(index).map_err(AuthorError::Store)?;
referenced_blob_count += op.blob_refs;
if matches!(op.kind, OperationKind::EditText(_)) {
text_edit_count += 1;
}
summaries.push(WorktreePatchOperationSummary {
path: op.path,
operation: op.summary_kind,
});
operations.push(Operation {
op_seq,
op_id: None,
preconditions: Vec::new(),
kind: op.kind,
});
}
let operation_count = operations.len();
let patch_payload = PatchPayload {
operations,
intent: None,
preconditions: Vec::new(),
purpose: PatchPurpose::Normal,
};
patch_payload.validate().map_err(AuthorError::Store)?;
let mut patch = ObjectEnvelope::unsigned(
ObjectType::Patch,
PATCH_PARENT_IDS_RETIRED_SCHEMA,
patch_payload
.to_canonical_bytes()
.map_err(AuthorError::Store)?,
);
let patch_id = patch.object_id();
let signature =
crate::author_signing::author_signature(signer, patch_id).map_err(AuthorError::Store)?;
patch.add_signature(signature).map_err(AuthorError::Store)?;
crate::author_key_index::record_author_key_material(
layout,
signer.key_id(),
signer.public_key_bytes(),
&active_lock,
)
.map_err(AuthorError::Store)?;
prepare_empty_active_ref_for_append(layout, &canonical_ref).map_err(AuthorError::Store)?;
let wal_sequence = wal.append_patch(&patch).map_err(AuthorError::Store)?;
Ok(WorktreePatchCommitReport {
ref_name: canonical_ref,
patch_id,
wal_sequence,
operation_count,
referenced_blob_count,
text_edit_count,
changes: summaries,
})
}
fn baseline_block_has_snapshot_ref(
object_store: &impl ObjectReader,
baseline_block: ObjectId,
) -> std::result::Result<bool, AuthorError> {
let envelope = object_store
.read_typed(baseline_block, ObjectType::Block)
.map_err(AuthorError::Store)?
.ok_or_else(|| {
AuthorError::Store(PrikkError::Integrity(format!(
"baseline Block {baseline_block} is missing"
)))
})?;
let block = prikk_object::BlockPayload::decode_canonical(&envelope.canonical_payload)
.map_err(AuthorError::Store)?;
Ok(block.snapshot_blob_ref.is_some())
}
fn classify_new(bytes: &[u8]) -> (BlobKind, NodeKind) {
if std::str::from_utf8(bytes).is_ok() {
(BlobKind::Text, NodeKind::TextFile)
} else {
(BlobKind::Binary, NodeKind::BinaryFile)
}
}
fn plan_edit_text(
object_store: &impl ObjectReader,
base: &BaselineFile,
new_bytes: &[u8],
path: &str,
lineage_baseline_block_id: Option<ObjectId>,
lineage_horizon_id: Option<ObjectId>,
queue_text_cache: &crate::lifecycle_cache::replay::TextCache,
) -> std::result::Result<PlannedOp, AuthorError> {
let old_text = current_text_for_node(
object_store,
base,
path,
lineage_baseline_block_id,
lineage_horizon_id,
queue_text_cache,
)?;
let span = text_span::plan_authored_text_span(&old_text, new_bytes, base.node_id)
.map_err(|err| AuthorError::Store(PrikkError::Integrity(format!("EditText: {err}"))))?
.ok_or_else(|| {
AuthorError::Store(PrikkError::Integrity(
"EditText requested for unchanged text".to_string(),
))
})?;
Ok(PlannedOp {
kind: OperationKind::EditText(EditText {
node_id: base.node_id,
span_id: span.span_id,
old_span_hash: span.old_span_hash,
left_anchor_hash: span.left_anchor_hash,
right_anchor_hash: span.right_anchor_hash,
replacement_text: span.replacement_text,
presentation_hint_line: None,
presentation_hint_column: None,
old_span_text: span.old_span_text,
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::EditText,
blob_refs: 0,
})
}
fn plan_replace_binary(
object_store: &mut impl ObjectWriter,
base: &BaselineFile,
new_bytes: &[u8],
path: &str,
) -> std::result::Result<PlannedOp, AuthorError> {
let new_blob_id = write_content_blob(object_store, BlobKind::Binary, new_bytes)?;
Ok(PlannedOp {
kind: OperationKind::ReplaceBinary(ReplaceBinary {
node_id: base.node_id,
old_blob_id: base.blob_id,
new_blob_id,
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::ReplaceBinary,
blob_refs: 2,
})
}
fn plan_mode_change_if_observed(
base: &BaselineFile,
observed_mode: Option<u32>,
path: &str,
) -> Option<PlannedOp> {
let observed_mode = observed_mode?;
if observed_mode == base.mode {
return None;
}
Some(plan_change_perm(base, observed_mode, path))
}
fn plan_change_perm(base: &BaselineFile, new_mode: u32, path: &str) -> PlannedOp {
PlannedOp {
kind: OperationKind::ChangePerm(ChangePerm {
node_id: base.node_id,
old_mode: base.mode,
new_mode,
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::ChangePerm,
blob_refs: 0,
}
}
fn plan_delete(base: &BaselineFile, path: &str) -> PlannedOp {
PlannedOp {
kind: OperationKind::DeleteNode(DeleteNode {
path: path.to_string(),
node_id: base.node_id,
old_node_kind: base.kind,
preimage: DeleteNodePreimage::File {
old_blob_id: base.blob_id,
old_mode: base.mode,
},
}),
path: path.to_string(),
node_id: base.node_id,
summary_kind: WorktreePatchOperationKind::DeleteFile,
blob_refs: 0,
}
}
struct ExistingFileResolution {
content_hash: ObjectId,
bytes: Option<Vec<u8>>,
}
fn resolve_existing_file(
layout: &RepositoryLayout,
commit_index: &mut CommitIndex,
path: &str,
meta: &WorktreeFileMeta,
base_mode: u32,
blob_kind: BlobKind,
) -> std::result::Result<ExistingFileResolution, AuthorError> {
let stat = RootFileStat {
size: meta.size,
mtime_secs: meta.mtime_secs,
mtime_nanos: meta.mtime_nanos,
mode: meta.mode,
};
if let Some(cached) = commit_index.get(path) {
if cached.kind == blob_kind && cached.matches_stat(&stat) {
return Ok(ExistingFileResolution {
content_hash: cached.content_hash,
bytes: None,
});
}
}
let bytes = read_existing_file_bytes(layout, path, blob_kind)?;
let content_hash = commit_index::content_hash(blob_kind, &bytes).map_err(AuthorError::Store)?;
commit_index.record(
path.to_string(),
CommitIndexEntry {
size: meta.size,
mtime_secs: meta.mtime_secs,
mtime_nanos: meta.mtime_nanos,
mode: meta.mode.unwrap_or(base_mode),
kind: blob_kind,
content_hash,
},
);
Ok(ExistingFileResolution {
content_hash,
bytes: Some(bytes),
})
}
fn read_existing_file_bytes(
layout: &RepositoryLayout,
path: &str,
blob_kind: BlobKind,
) -> std::result::Result<Vec<u8>, AuthorError> {
let bytes = read_worktree_file_bytes(layout, path)?;
if matches!(blob_kind, BlobKind::Text) && std::str::from_utf8(&bytes).is_err() {
return Err(AuthorError::UnsupportedKindTransition(format!(
"{path}: existing TextFile cannot accept non-UTF-8 content"
)));
}
Ok(bytes)
}
fn read_worktree_file_bytes(
layout: &RepositoryLayout,
path: &str,
) -> std::result::Result<Vec<u8>, AuthorError> {
read_file_if_exists(layout.worktree_mutation_root(), Path::new(path))
.map_err(AuthorError::Store)?
.ok_or_else(|| {
AuthorError::Store(PrikkError::Io(format!(
"worktree entry disappeared: {path}"
)))
})
}
fn write_content_blob(
object_store: &mut impl ObjectWriter,
kind: BlobKind,
bytes: &[u8],
) -> std::result::Result<ObjectId, AuthorError> {
let payload = BlobPayload::new(kind, bytes.to_vec());
let canonical = payload.to_canonical_bytes().map_err(AuthorError::Store)?;
let envelope = ObjectEnvelope::unsigned(ObjectType::Blob, 1, canonical);
object_store
.write_object(&envelope)
.map_err(AuthorError::Store)
}
fn read_file_blob_bytes_if_present(
object_store: &impl ObjectReader,
blob_id: ObjectId,
) -> std::result::Result<Option<Vec<u8>>, AuthorError> {
let Some(envelope) = object_store
.read_object(blob_id)
.map_err(AuthorError::Store)?
else {
return Ok(None);
};
crate::blob_access::decode_file_content_blob(&envelope.canonical_payload)
.map_err(AuthorError::Store)
.map(Some)
}
fn current_text_for_node(
object_store: &impl ObjectReader,
base: &BaselineFile,
path: &str,
lineage_baseline_block_id: Option<ObjectId>,
lineage_horizon_id: Option<ObjectId>,
queue_text_cache: &crate::lifecycle_cache::replay::TextCache,
) -> std::result::Result<Vec<u8>, AuthorError> {
if let Some(text) = queue_text_cache.get(&base.node_id) {
return Ok(text.clone());
}
if let Some(bytes) = read_file_blob_bytes_if_present(object_store, base.blob_id)? {
return Ok(bytes);
}
let (baseline_block_id, horizon_id) = match (lineage_baseline_block_id, lineage_horizon_id) {
(Some(baseline_block_id), Some(horizon_id)) => (baseline_block_id, horizon_id),
_ => {
return Err(AuthorError::Store(PrikkError::Integrity(format!(
"{path}: text node baseline blob {} is missing and no lineage is available to \
materialize it (an existing node implies a published baseline)",
base.blob_id
))));
}
};
crate::lifecycle_cache::materialize_edited_text(
object_store,
baseline_block_id,
horizon_id,
base.node_id,
)
.map_err(AuthorError::Store)?
.ok_or_else(|| {
AuthorError::Store(PrikkError::Integrity(format!(
"{path}: text node baseline blob {} is missing and could not be materialized from \
its edit history",
base.blob_id
)))
})
}
#[cfg(test)]
mod mode_change_tests {
#![allow(clippy::expect_used)]
use prikk_object::{NodeId, NodeKind, ObjectId, ObjectType};
use super::{
BaselineFile, EXECUTABLE_FILE_MODE, REGULAR_FILE_MODE, plan_mode_change_if_observed,
};
fn sample_baseline(mode: u32) -> BaselineFile {
BaselineFile {
node_id: NodeId::from_bytes([0x42; 32]),
kind: NodeKind::TextFile,
blob_id: ObjectId::from_canonical_payload(ObjectType::Blob, 1, b"mode-change-tests"),
mode,
}
}
#[test]
fn unobserved_mode_never_plans_a_change_perm_even_when_baseline_differs() {
let base = sample_baseline(EXECUTABLE_FILE_MODE);
assert!(plan_mode_change_if_observed(&base, None, "bin/tool").is_none());
}
#[test]
fn unobserved_mode_never_plans_a_change_perm_when_baseline_is_regular() {
let base = sample_baseline(REGULAR_FILE_MODE);
assert!(plan_mode_change_if_observed(&base, None, "src/lib.rs").is_none());
}
#[test]
fn observed_mode_matching_baseline_plans_no_change() {
let base = sample_baseline(REGULAR_FILE_MODE);
assert!(
plan_mode_change_if_observed(&base, Some(REGULAR_FILE_MODE), "src/lib.rs").is_none()
);
}
#[test]
fn observed_mode_differing_from_baseline_plans_a_change_perm() {
let base = sample_baseline(REGULAR_FILE_MODE);
let op = plan_mode_change_if_observed(&base, Some(EXECUTABLE_FILE_MODE), "bin/tool")
.expect("a real observed change must be planned");
assert_eq!(op.path, "bin/tool");
assert_eq!(op.node_id, base.node_id);
}
}