Files
ARCADIA-Studio/crates/oa-desktop/src/document_alias_rename.rs
T

234 lines
7.8 KiB
Rust

//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do
//! not authorize adoption; only this module can mint a prepared rename.
use super::*;
use std::collections::BTreeMap;
/// A pinned intent minted by its owning Document. Move it to a worker and call
/// `prepare`; preparation has no access to the live Document or its histories.
#[derive(Debug)]
pub struct AliasRenameRequest {
owner: Arc<()>,
before: Arc<CompiledSourceProject>,
revision: u64,
limits: ProjectLimits,
target: Id,
old_alias: String,
new_alias: String,
}
#[derive(Debug)]
struct PreparedData {
request: AliasRenameRequest,
after: Arc<CompiledSourceProject>,
before_bytes: usize,
after_bytes: usize,
changed: bool,
}
/// Immutable, Clone-compatible transport for preview and approval. Neither
/// mutable candidate access nor deserialization can manufacture this authority.
#[derive(Debug, Clone)]
pub struct PreparedAliasRename(Arc<PreparedData>);
impl PreparedAliasRename {
pub fn before(&self) -> &CompiledSourceProject {
&self.0.request.before
}
pub fn after(&self) -> &CompiledSourceProject {
&self.0.after
}
pub fn target(&self) -> Id {
self.0.request.target
}
pub fn old_alias(&self) -> &str {
&self.0.request.old_alias
}
pub fn new_alias(&self) -> &str {
&self.0.request.new_alias
}
pub fn revision(&self) -> u64 {
self.0.request.revision
}
pub fn changed(&self) -> bool {
self.0.changed
}
}
fn refusal(message: &str) -> DocumentError {
ModelError(message.into()).into()
}
fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> {
if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES {
return Err(refusal(
"This rename exceeds the model Undo budget. No rename was applied.",
));
}
Ok(())
}
impl Document {
pub fn alias_rename_request(
&self,
target: Id,
replacement: String,
) -> Result<AliasRenameRequest, DocumentError> {
if self.revision == u64::MAX {
return Err(refusal(
"The document revision is exhausted. Reopen before renaming.",
));
}
let old_alias = self
.project()
.identities()
.into_iter()
.find_map(|(id, alias)| (id == target).then(|| alias.to_owned()))
.ok_or_else(|| refusal("The alias target is no longer present."))?;
// A candidate has a nonzero estimate. Avoid expensive preparation when
// even the current snapshot leaves no room for its immediate Undo.
if old_alias != replacement && self.current_bytes >= HISTORY_BYTES {
return Err(refusal(
"This rename exceeds the model Undo budget. No rename was applied.",
));
}
Ok(AliasRenameRequest {
owner: self.alias_owner.clone(),
before: self.current.clone(),
revision: self.revision,
limits: self.limits,
target,
old_alias,
new_alias: replacement,
})
}
/// App must separately consume its current approval and check workspace,
/// invocation and draft freshness. This checks accepted Document authority.
pub fn commit_alias_rename(
&mut self,
prepared: &PreparedAliasRename,
) -> Result<bool, DocumentError> {
let data = &prepared.0;
let request = &data.request;
if self.revision == u64::MAX
|| self.revision != request.revision
|| !Arc::ptr_eq(&self.alias_owner, &request.owner)
|| !Arc::ptr_eq(&self.current, &request.before)
{
return Err(refusal(
"This rename preview is stale. Preview the current alias again.",
));
}
if !data.changed {
return Ok(false);
}
if self.current_bytes != data.before_bytes {
return Err(refusal(
"The rename history estimate is stale. Preview again.",
));
}
check_history_capacity(self.current_bytes, data.after_bytes)?;
Ok(self.commit_snapshot(data.after.clone(), data.after_bytes))
}
}
impl AliasRenameRequest {
/// Run on the preparation worker. The typed command and source updater are
/// the only candidate construction path, followed by alias-only invariants.
pub fn prepare(self) -> Result<PreparedAliasRename, DocumentError> {
let before_project = &self.before.decoded.project;
let candidate = prepare_command(
before_project,
&Command::SetAlias(self.target, self.new_alias.clone()),
)?;
let mut expected = before_project.clone();
assign_expected_alias(&mut expected, self.target, &self.new_alias)?;
let identities: BTreeMap<_, _> = before_project
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
if candidate.project != expected || candidate.identity_map != identities {
return Err(refusal(
"Alias preparation changed unrelated model data or identities.",
));
}
let after = source_project::update_bundle_with_identity_map(
&self.before,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
let before = &self.before;
if after.decoded.project != expected
|| before.bundle.entry != after.bundle.entry
|| !before.bundle.files.keys().eq(after.bundle.files.keys())
|| before.index.lexical_owners != after.index.lexical_owners
|| !before
.index
.declarations
.iter()
.map(|(id, loc)| (id, &loc.path))
.eq(after
.index
.declarations
.iter()
.map(|(id, loc)| (id, &loc.path)))
{
return Err(refusal(
"Alias preparation changed source membership or ownership.",
));
}
let changed = before.bundle != after.bundle;
let before_bytes = estimated_bytes(before);
let after_bytes = estimated_bytes(&after);
if changed {
check_history_capacity(before_bytes, after_bytes)?;
}
Ok(PreparedAliasRename(Arc::new(PreparedData {
request: self,
after: Arc::new(after),
before_bytes,
after_bytes,
changed,
})))
}
}
// Independent full-model expectation: SetAlias may change exactly one header
// field, across every supported identity family. Do not reuse command execution
// to derive the expected result, which would hide collateral command changes.
fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> {
if project.id == id {
project.alias = alias.to_owned();
} else if let Some(record) = project.elements.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.relations.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.diagrams.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.artifact_links.get_mut(&id) {
record.alias = alias.to_owned();
} else if let Some(record) = project.traces.get_mut(&id) {
record.alias = alias.to_owned();
} else {
project
.diagrams
.values_mut()
.find_map(|diagram| diagram.nodes.get_mut(&id))
.ok_or_else(|| refusal("The alias target is no longer present."))?
.alias = alias.to_owned();
}
Ok(())
}
#[cfg(test)]
#[path = "document_alias_rename_tests.rs"]
mod tests;