///|
/// Atomic source inventory changes; additions and removals name distinct paths.
pub struct SourceChanges {
priv additions : Array[SidecarInput]
priv removals : Array[String]
}
///|
/// Validate source paths/JSON, normalize order, retain exact unknown metadata.
pub fn SourceChanges::new(
additions : Array[SidecarInput],
removals : Array[String],
) -> SourceChanges raise TraceError {
if additions.length() + removals.length() > 256 {
reject(
"source_changes_limit", "", "At most 256 added/removed sources; split the review explicitly.",
)
}
let seen : Map[String, String] = Map([])
for path in removals {
ignore(selector(path, sidecar=true))
let folded = path.to_lower()
if seen.get(folded) is Some(existing) {
reject(
"source_change_conflict",
path,
"Each source path may occur only once, including case variants; use an edit for replacement.",
related=[existing],
)
}
seen[folded] = path
}
for input in additions {
let folded = input.path.to_lower()
if seen.get(folded) is Some(existing) {
reject(
"source_change_conflict",
input.path,
"Each source path may occur only once; use an edit for replacement.",
related=[existing],
)
}
seen[folded] = input.path
}
let sources = DatasetIndex::from_manifest([], additions)
let removed = removals.copy()
removed.sort_by(lexical_compare)
let changes : SourceChanges = {
additions: sources.sidecars.map(s => {
SidecarInput::new(
s.selector.path,
canonical_json(Json::object(s.metadata)),
)
}),
removals: removed,
}
if changes.to_json().length() > 2097152 {
reject(
"input_limit", "", "Canonical changes exceed 2,097,152 UTF-16 units; reduce the review scope.",
)
}
changes
}
///|
fn SourceChanges::changes_value(self : SourceChanges) -> Json {
Json::object({
"add": Json::array(
self.additions.map(input => {
Json::object({
"path": Json::string(input.path),
"metadata": try! parse_source(input.path, input.text),
})
}),
),
"remove": Json::array(self.removals.map(Json::string)),
})
}
///|
pub fn SourceChanges::to_json(self : SourceChanges) -> String {
canonical_json(self.changes_value())
}
///|
/// Both add and remove are mandatory; every unknown control is rejected.
pub fn SourceChanges::from_json(
text : String,
) -> SourceChanges raise TraceError {
guard parse_source("", text, container_limit=67)
is Object(fields) else {
reject(
"invalid_source_changes", "", "Expected add and remove controls.",
)
abort("reject raises")
}
for key in sorted_keys(fields) {
if !["add", "remove"].contains(key) {
reject(
"unknown_source_change_field", key, "Use only add and remove controls.",
)
}
}
guard fields.get("add") is Some(Array(add)) &&
fields.get("remove") is Some(Array(remove)) else {
reject(
"invalid_source_changes", "", "Both add and remove must be arrays.",
)
abort("reject raises")
}
if add.length() + remove.length() > 256 {
reject(
"source_changes_limit", "", "At most 256 added/removed sources.",
)
}
let additions = add.map(value => {
guard value is Object(entry) else {
reject(
"invalid_source_changes", "/add", "Each addition requires path and object metadata.",
)
abort("reject raises")
}
for key in sorted_keys(entry) {
if !["path", "metadata"].contains(key) {
reject(
"unknown_source_change_field", key, "Use only path and metadata in additions.",
)
}
}
guard entry.get("path") is Some(String(path)) &&
entry.get("metadata") is Some(Object(metadata)) else {
reject(
"invalid_source_changes", "/add", "Each addition requires string path and object metadata.",
)
abort("reject raises")
}
SidecarInput::new(path, canonical_json(Json::object(metadata)))
})
let removals = remove.map(value => {
guard value is String(path) else {
reject(
"invalid_source_changes", "/remove", "Each removal must be a source path.",
)
abort("reject raises")
}
path
})
SourceChanges::new(additions, removals)
}
///|
pub struct SourceChangePlan {
priv baseline : DatasetIndex
priv candidate : DatasetIndex
priv changes : SourceChanges
priv impact : ImpactReport
}
///|
/// Build/revalidate only the final inventory. Never writes/deletes disk files.
pub fn DatasetIndex::plan_sources(
self : DatasetIndex,
changes : SourceChanges,
) -> SourceChangePlan raise TraceError {
let existing : Map[String, Bool] = Map([])
for source in self.sidecars {
existing[source.selector.path] = true
}
for path in changes.removals {
if !existing.contains(path) {
reject(
"unknown_sidecar_path", path, "Cannot remove an absent source; inspect the baseline inventory.",
)
}
}
for input in changes.additions {
if existing.contains(input.path) {
reject(
"existing_sidecar_path",
input.path,
"Source already exists; use metadata edit planning for replacement.",
)
}
}
let inputs = self.sidecars
.filter(source => !changes.removals.contains(source.selector.path))
.map(source => {
SidecarInput::new(
source.selector.path,
canonical_json(Json::object(source.metadata)),
)
})
for input in changes.additions {
inputs.push(input)
}
let candidate = DatasetIndex::from_manifest(self.data_paths(), inputs)
{ baseline: self, candidate, changes, impact: self.compare(candidate), }
}
///|
pub fn SourceChangePlan::before(self : SourceChangePlan) -> DatasetIndex {
self.baseline
}
///|
pub fn SourceChangePlan::after(self : SourceChangePlan) -> DatasetIndex {
self.candidate
}
///|
pub fn SourceChangePlan::impact(self : SourceChangePlan) -> ImpactReport {
self.impact
}
///|
/// Complete snapshots retain removed data, including sources without scan targets.
pub fn SourceChangePlan::to_json(self : SourceChangePlan) -> String {
canonical_json(
Json::object({
"changes": self.changes.changes_value(),
"before_manifest": self.baseline.manifest_value(),
"after_manifest": self.candidate.manifest_value(),
"impact": self.impact.report_value(),
}),
)
}