///|
/// Atomic changes to the indexed scan inventory; sources are never removed.
pub struct ScanChanges {
priv additions : Array[String]
priv removals : Array[String]
}
///|
pub fn ScanChanges::new(
additions : Array[String],
removals : Array[String],
) -> ScanChanges raise TraceError {
if additions.length() + removals.length() > 256 {
reject(
"scan_changes_limit", "", "At most 256 combined additions/removals; split the review explicitly.",
)
}
let seen : Map[String, String] = Map([])
for path in additions + removals {
ignore(selector(path, sidecar=false))
let folded = path.to_lower()
if seen.get(folded) is Some(existing) {
reject(
"scan_change_conflict",
path,
"Each scan path may occur only once, including case variants and both operations.",
related=[existing],
)
}
seen[folded] = path
}
let changes : ScanChanges = {
additions: DatasetIndex::from_manifest(additions, []).data_paths(),
removals: removals.copy(),
}
changes.removals.sort_by(lexical_compare)
if changes.to_json().length() > 2097152 {
reject(
"input_limit", "", "Canonical scan changes exceed 2,097,152 UTF-16 units.",
)
}
changes
}
///|
fn ScanChanges::changes_value(self : ScanChanges) -> Json {
Json::object({
"add": Json::array(self.additions.map(Json::string)),
"remove": Json::array(self.removals.map(Json::string)),
})
}
///|
pub fn ScanChanges::to_json(self : ScanChanges) -> String {
canonical_json(self.changes_value())
}
///|
pub fn ScanChanges::from_json(text : String) -> ScanChanges raise TraceError {
guard parse_source("", text, container_limit=2) is Object(input) else {
reject(
"invalid_scan_changes", "", "Expected arrays add and remove.",
)
abort("reject raises")
}
for key in sorted_keys(input) {
if !["add", "remove"].contains(key) {
reject(
"unknown_scan_change_field", key, "Use only add and remove controls; sources are preserved.",
)
}
}
guard input.get("add") is Some(Array(additions)) &&
input.get("remove") is Some(Array(removals)) else {
reject(
"invalid_scan_changes", "", "Both add and remove arrays are mandatory.",
)
abort("reject raises")
}
fn paths(values : Array[Json]) -> Array[String] raise TraceError {
values.map(value => {
guard value is String(path) else {
reject(
"invalid_scan_changes", "", "Every operation names a supported relative scan path.",
)
abort("reject raises")
}
path
})
}
ScanChanges::new(paths(additions), paths(removals))
}
///|
pub struct ScanChangePlan {
priv baseline : DatasetIndex
priv candidate : DatasetIndex
priv changes : ScanChanges
priv impact : ImpactReport
priv before_coverage : SourceCoverageReport
priv after_coverage : SourceCoverageReport
}
///|
/// Validate the final snapshot only, then expose impacts and source coverage.
/// Removed scans keep before evidence; unmatched sources remain in the candidate.
pub fn DatasetIndex::plan_scans(
self : DatasetIndex,
changes : ScanChanges,
) -> ScanChangePlan raise TraceError {
let existing : Map[String, Bool] = Map([])
for file in self.files {
existing[file.path] = true
}
for path in changes.removals {
if !existing.contains(path) {
reject(
"unknown_data_path", path, "Cannot remove an absent scan; inspect the baseline inventory.",
)
}
}
for path in changes.additions {
if existing.contains(path) {
reject(
"existing_data_path", path, "Cannot add an indexed scan; each inventory path is unique.",
)
}
}
let paths = self.data_paths().filter(path => !changes.removals.contains(path))
let sources = self.sidecars.map(source => {
SidecarInput::new(
source.selector.path,
canonical_json(Json::object(source.metadata)),
)
})
let candidate = DatasetIndex::from_manifest(
paths + changes.additions,
sources,
)
{
baseline: self,
candidate,
changes,
impact: self.compare(candidate),
before_coverage: self.source_coverage(),
after_coverage: candidate.source_coverage(),
}
}
///|
pub fn ScanChangePlan::before(self : ScanChangePlan) -> DatasetIndex {
self.baseline
}
///|
pub fn ScanChangePlan::after(self : ScanChangePlan) -> DatasetIndex {
self.candidate
}
///|
pub fn ScanChangePlan::impact(self : ScanChangePlan) -> ImpactReport {
self.impact
}
///|
pub fn ScanChangePlan::before_coverage(
self : ScanChangePlan,
) -> SourceCoverageReport {
self.before_coverage
}
///|
pub fn ScanChangePlan::after_coverage(
self : ScanChangePlan,
) -> SourceCoverageReport {
self.after_coverage
}
///|
/// Complete evidence with already canonical components; never rounds numbers.
pub fn ScanChangePlan::to_json(self : ScanChangePlan) -> String {
"{\"after_coverage\":" +
self.after_coverage.to_json() +
",\"after_manifest\":" +
self.candidate.to_manifest_json() +
",\"before_coverage\":" +
self.before_coverage.to_json() +
",\"before_manifest\":" +
self.baseline.to_manifest_json() +
",\"changes\":" +
self.changes.to_json() +
",\"impact\":" +
self.impact.to_json() +
"}"
}