///|
/// Build a plan without changing the input. An invalid operation invalidates
/// the entire plan; partial previews are never returned as executable results.
pub fn plan(
snapshot : Snapshot,
operations : Array[Operation],
title~ : String = "文件变更计划",
) -> Plan {
let issues = validate_snapshot(snapshot)
let files = file_map(snapshot)
if operations.length() > 200 {
issues.push(issue("limit", "", "一次最多 200 个操作"))
}
if issues.is_empty() {
for op in operations {
if !valid_path(op.path) || !text_path(op.path) {
issues.push(
issue(
"invalid_path",
op.path,
"路径越界、保留名称或文件类型不受支持",
),
)
continue
}
match op.kind {
"create" =>
if op.to != None || op.content == None {
issues.push(
issue(
"invalid_operation",
op.path,
"创建操作需要 content,且不能带 to",
),
)
} else if path_collision(op.path, files, snapshot) {
issues.push(issue("collision", op.path, "目标路径已被占用"))
} else {
files[op.path] = op.content.unwrap()
}
"write" =>
if op.to != None || op.content == None {
issues.push(
issue(
"invalid_operation",
op.path,
"写入操作需要 content,且不能带 to",
),
)
} else if !files.contains(op.path) {
issues.push(
issue("missing", op.path, "要修改的文件不存在"),
)
} else {
files[op.path] = op.content.unwrap()
}
"move" =>
match op.to {
None =>
issues.push(
issue(
"invalid_operation",
op.path,
"移动操作缺少目标路径",
),
)
Some(to) =>
if op.content != None || !valid_path(to) || !text_path(to) {
issues.push(
issue(
"invalid_path", to, "移动目标不合法,或包含多余内容",
),
)
} else if !files.contains(op.path) {
issues.push(
issue("missing", op.path, "要移动的文件不存在"),
)
} else if path_collision(to, files, snapshot) {
issues.push(
issue(
"collision", to, "目标已存在,或存在大小写/目录冲突",
),
)
} else {
files[to] = files[op.path]
files.remove(op.path)
}
}
"trash" =>
if op.to != None || op.content != None {
issues.push(
issue(
"invalid_operation",
op.path,
"回收操作不能带 to 或 content",
),
)
} else if !files.contains(op.path) {
issues.push(
issue("missing", op.path, "要回收的文件不存在"),
)
} else {
files.remove(op.path)
}
_ =>
issues.push(
issue(
"unsupported",
op.path,
"未知操作;仅接受 create / write / move / trash",
),
)
}
}
}
let output : Array[File] = []
for path, content in files {
output.push({ path, content, })
}
output.sort_by((a, b) => a.path.compare(b.path))
let candidate : Snapshot = {
files: output,
folders: snapshot.folders.copy(),
blocked: snapshot.blocked.copy(),
}
if issues.is_empty() {
issues.append(validate_snapshot(candidate))
}
let baseline = copy_snapshot(snapshot)
let result = if issues.is_empty() {
candidate
} else {
copy_snapshot(snapshot)
}
let changes = differences(baseline, result)
{
schema: 1,
title,
baseline,
result,
operations: operations.copy(),
changes,
issues,
}
}
///|
/// Sorted net differences, suitable for verification and recovery journals.
pub fn differences(before : Snapshot, after : Snapshot) -> Array[Change] {
let a = file_map(before)
let b = file_map(after)
let keys : Map[String, Bool] = Map([])
for k, _ in a {
keys[k] = true
}
for k, _ in b {
keys[k] = true
}
let result = []
for k, _ in keys {
if a.get(k) != b.get(k) {
result.push({ path: k, before: a.get(k), after: b.get(k), })
}
}
result.sort_by((a, b) => a.path.compare(b.path))
result
}
///|
/// Reject a stale plan using its whole read snapshot. Hosts must also check
/// real filesystem types and serialize their own writers at the I/O boundary.
pub fn check_apply(planned : Plan, current : Snapshot) -> Array[Issue] {
let rebuilt = plan(planned.baseline, planned.operations, title=planned.title)
let issues = validate_snapshot(current)
if planned.schema != 1 || rebuilt != planned {
issues.push(
issue("invalid_plan", "", "计划内容与重新计算的结果不一致"),
)
return issues
}
if !planned.ready() {
issues.push(
issue("not_ready", "", "计划包含错误或没有实际变更"),
)
return issues
}
for change in differences(planned.baseline, current) {
issues.push(
issue(
"stale",
change.path,
"文件已在预演后发生变化,请重新预演",
),
)
}
let old_blocked = planned.baseline.blocked.copy()
let now_blocked = current.blocked.copy()
let old_folders = planned.baseline.folders.copy()
let now_folders = current.folders.copy()
old_blocked.sort()
now_blocked.sort()
old_folders.sort()
now_folders.sort()
if old_blocked != now_blocked || old_folders != now_folders {
issues.push(
issue(
"stale_structure", "", "目录或不可操作文件已变化,请重新预演",
),
)
}
issues
}
///|
/// Construct a restore plan. Unrelated new files survive; modified touched
/// files cause a conflict. recovery=true permits either before or after values
/// for an interrupted commit/restore, without overwriting a third value.
pub fn restore_plan(
planned : Plan,
current : Snapshot,
recovery~ : Bool = false,
) -> Plan {
let issues = []
let operations = []
let files = file_map(current)
let rebuilt = plan(planned.baseline, planned.operations, title=planned.title)
if planned.schema != 1 || rebuilt != planned || !planned.ready() {
issues.push(issue("invalid_plan", "", "原始计划不合法"))
} else {
for change in planned.changes {
let actual = files.get(change.path)
if actual != change.after && !(recovery && actual == change.before) {
issues.push(
issue(
"restore_conflict",
change.path,
"操作后文件又被修改;恢复已停止,保留你的改动",
),
)
}
}
}
// Remove created destinations first, then restore original paths.
for change in planned.changes {
if change.before == None && files.contains(change.path) {
operations.push({
kind: "trash",
path: change.path,
to: None,
content: None,
})
}
}
for change in planned.changes {
if change.before is Some(content) && files.get(change.path) != Some(content) {
operations.push({
kind: if files.contains(change.path) {
"write"
} else {
"create"
},
path: change.path,
to: None,
content: Some(content),
})
}
}
let restore = plan(current, operations, title="恢复:" + planned.title)
if issues.is_empty() {
restore
} else {
{ ..restore, result: copy_snapshot(current), changes: [], issues, }
}
}