//! 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, revision: u64, limits: ProjectLimits, target: Id, old_alias: String, new_alias: String, } #[derive(Debug)] struct PreparedData { request: AliasRenameRequest, after: Arc, 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); 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 { 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 { 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 { 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;