///|
/// AND across entity names, OR across a name's choices and selected suffixes.
pub struct ScanQuery {
priv suffixes : Array[String]
priv entities : Map[String, Array[String]]
}
///|
fn ScanQuery::query_value(self : ScanQuery) -> Json {
let entities : Map[String, Json] = Map([])
for key in sorted_keys(self.entities) {
entities[key] = Json::array(self.entities[key].map(Json::string))
}
Json::object({
"suffixes": Json::array(self.suffixes.map(Json::string)),
"entities": Json::object(entities),
})
}
///|
pub fn ScanQuery::to_json(self : ScanQuery) -> String {
canonical_json(self.query_value())
}
///|
/// Copies inputs, normalizes decimal indices without conversion to machine Int.
/// Empty choice lists, unsupported keys/values and normalized duplicates fail.
pub fn ScanQuery::new(
suffixes : Array[String],
entities : Map[String, Array[String]],
) -> ScanQuery raise TraceError {
if suffixes.is_empty() || suffixes.length() > 3 {
reject(
"invalid_query_suffixes", "/suffixes", "Choose one or more distinct supported suffixes: bold, T1w, dwi.",
)
}
let seen_suffixes : Map[String, Bool] = Map([])
for suffix in suffixes {
if !["bold", "T1w", "dwi"].contains(suffix) {
reject(
"unsupported_suffix", suffix, "Query supports only bold, T1w and dwi.",
)
}
if seen_suffixes.contains(suffix) {
reject("duplicate_query_choice", suffix, "Each suffix must occur once.")
}
seen_suffixes[suffix] = true
}
let normalized : Map[String, Array[String]] = Map([])
let mut choices = suffixes.length()
for key in sorted_keys(entities) {
if ![
"sub", "ses", "task", "acq", "ce", "rec", "dir", "run", "echo", "part", "chunk",
].contains(key) {
reject(
"unsupported_entity", key, "Use supported filename entity names; metadata predicates and wildcards are unsupported.",
)
}
let values = entities[key]
if values.is_empty() {
reject(
"empty_query_choices", key, "Choose at least one value or omit this entity constraint.",
)
}
if values.length() > 256 - choices {
reject(
"scan_query_limit", "", "At most 256 total suffix/entity choices; reduce the selection scope.",
)
}
choices += values.length()
let seen : Map[String, String] = Map([])
let items = values.map(value => {
let canonical = entity_value(key, value, "/entities/" + key)
let folded = canonical.to_lower()
if seen.get(folded) is Some(existing) {
reject(
"duplicate_query_choice",
key,
"Duplicate, normalized-equal or case-colliding choices; select each value once.",
related=[existing, value],
)
}
seen[folded] = value
canonical
})
items.sort_by(lexical_compare)
normalized[key] = items
}
let selected_suffixes = suffixes.copy()
selected_suffixes.sort_by(lexical_compare)
let query : ScanQuery = { suffixes: selected_suffixes, entities: normalized, }
if query.to_json().length() > 2097152 {
reject(
"input_limit", "", "Canonical scan query exceeds 2,097,152 UTF-16 units; reduce choices.",
)
}
query
}
///|
/// Strict {suffixes:[strings],entities:{entity:[strings]}}; both mandatory.
pub fn ScanQuery::from_json(text : String) -> ScanQuery raise TraceError {
guard parse_source("", text, container_limit=3) is Object(fields) else {
reject(
"invalid_scan_query", "", "Expected suffixes array and entities object.",
)
abort("reject raises")
}
for key in sorted_keys(fields) {
if !["suffixes", "entities"].contains(key) {
reject(
"unknown_scan_query_field", key, "Use only suffixes and entities controls.",
)
}
}
guard fields.get("suffixes") is Some(Array(suffix_values)) &&
fields.get("entities") is Some(Object(entity_values)) else {
reject(
"invalid_scan_query", "", "Both suffixes array and entities object are mandatory.",
)
abort("reject raises")
}
fn strings(
values : Array[Json],
path : String,
) -> Array[String] raise TraceError {
values.map(value => {
guard value is String(item) else {
reject(
"invalid_scan_query", path, "Every choice must be a string; decimal indices are strings too.",
)
abort("reject raises")
}
item
})
}
let entities : Map[String, Array[String]] = Map([])
for key in sorted_keys(entity_values) {
guard entity_values[key] is Array(values) else {
reject(
"invalid_scan_query", key, "Each entity requires an array of string choices.",
)
abort("reject raises")
}
entities[key] = strings(values, key)
}
ScanQuery::new(strings(suffix_values, "/suffixes"), entities)
}
///|
pub struct ScanSelection {
priv index : DatasetIndex
priv query : ScanQuery
priv selected : Array[String]
priv excluded : Array[String]
}
///|
/// Selection depends only on validated filename entities, never metadata success.
/// The original index is retained so ancestor and unselected-source inheritance
/// remain available when auditing the selected scans.
pub fn DatasetIndex::select_scans(
self : DatasetIndex,
query : ScanQuery,
) -> ScanSelection {
let selected : Array[String] = []
let excluded : Array[String] = []
for file in self.files {
let matches = query.suffixes.contains(file.suffix) &&
query.entities
.iter()
.all(pair => {
match file.entities.get(pair.0) {
Some(value) => pair.1.contains(value)
None => false
}
})
if matches {
selected.push(file.path)
} else {
excluded.push(file.path)
}
}
{ index: self, query, selected, excluded, }
}
///|
pub fn ScanSelection::paths(self : ScanSelection) -> Array[String] {
self.selected.copy()
}
///|
/// Empty selection returns an empty audit, not audit_all(). Errors are retained.
pub fn ScanSelection::audit(self : ScanSelection) -> AuditReport {
self.index.audit(self.selected)
}
///|
/// Inventory accounting, not a new manifest; no sources/data are deleted.
pub fn ScanSelection::to_json(self : ScanSelection) -> String {
canonical_json(
Json::object({
"scope": Json::string("indexed_scans"),
"query": self.query.query_value(),
"selected_count": Json::number(self.selected.length().to_double()),
"total_count": Json::number(self.index.files.length().to_double()),
"selected_paths": Json::array(self.selected.map(Json::string)),
"excluded_paths": Json::array(self.excluded.map(Json::string)),
}),
)
}