use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
use crate::edit::build_unified_diff;
use crate::hashline::apply::{FileClassification, MutationState};
use crate::hashline::syntax::{HashlineRejection, HashlineRejectionCode, RejectionStage};
use crate::hashline::transaction::{FileOutcome, FileRole, TransactionEnvelope};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TransportKind {
Ndjson,
Subc,
Mcp,
OpenCode,
Pi,
}
impl TransportKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Ndjson => "ndjson",
Self::Subc => "subc",
Self::Mcp => "mcp",
Self::OpenCode => "opencode",
Self::Pi => "pi",
}
}
pub const ALL: &[TransportKind] = &[
Self::Ndjson,
Self::Subc,
Self::Mcp,
Self::OpenCode,
Self::Pi,
];
}
#[derive(Clone, Debug, Default)]
pub struct DisplayFileBytes {
pub requested_path: String,
pub before: Vec<u8>,
pub after: Option<Vec<u8>>,
pub remove_file: bool,
pub move_from: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MutationRenderInput<'a> {
pub envelope: &'a TransactionEnvelope,
pub display_files: &'a [DisplayFileBytes],
pub project_root: Option<&'a Path>,
pub transport: TransportKind,
}
const HASHLINE_STRUCTURED_KEYS: &[&str] = &[
"hashline",
"classifications",
"mutation_states",
"final_tags",
"registers_committed",
"stop_reason",
"remap_recovery",
"stage",
"steering",
];
pub fn render_mutation_response(input: MutationRenderInput<'_>) -> Value {
let envelope = input.envelope;
let (diff, files_meta) = build_display_metadata(input.display_files, input.project_root);
let file_path = first_display_path(input.display_files, envelope);
let output = render_agent_output(envelope);
let title = render_title(envelope);
let complete = envelope.complete;
let partial = envelope.success && !envelope.complete;
let all_failed = !envelope.success;
let mut payload = json!({
"output": output,
"title": title,
"success": envelope.success,
"complete": complete,
"partial": partial,
"all_failed": all_failed,
"preview": envelope.preview,
"metadata": {
"diff": diff,
"files": files_meta,
},
});
if let Some(path) = file_path {
payload["filePath"] = json!(path);
}
if let Some(op_id) = &envelope.op_id {
payload["op_id"] = json!(op_id);
}
payload["hashline"] = json!(true);
payload["registers_committed"] = json!(envelope.registers_committed);
if let Some(stop) = envelope.stop_reason {
payload["stop_reason"] = json!(stop);
}
payload["classifications"] = Value::Array(
envelope
.files
.iter()
.map(|f| {
json!({
"requested_path": f.requested_path,
"role": f.role.as_str(),
"classification": f.classification.as_str(),
"mutation_state": f.mutation_state.as_str(),
"final_tag": f.final_tag,
"backup_id": f.backup_id,
"format_skipped_reason": f.format_skipped_reason,
"tag_notice": f.tag_notice,
"remove_file": f.remove_file,
})
})
.collect(),
);
payload["final_tags"] = Value::Array(
envelope
.files
.iter()
.filter_map(|f| {
f.final_tag.as_ref().map(|tag| {
json!({
"requested_path": f.requested_path,
"tag": tag,
})
})
})
.collect(),
);
adapt_for_transport(payload, input.transport)
}
pub fn render_rejection_response(rejection: &HashlineRejection, transport: TransportKind) -> Value {
let payload = json!({
"success": false,
"complete": false,
"partial": false,
"all_failed": false,
"error": rejection.code.as_str(),
"code": rejection.code.as_str(),
"stage": rejection.stage.as_str(),
"message": rejection.message,
"steering": rejection.steering,
"output": format!(
"{} at {}: {}\n{}",
rejection.code.as_str(),
rejection.stage.as_str(),
rejection.message,
rejection.steering
),
"metadata": {
"diff": "",
"files": [],
},
});
adapt_for_transport(payload, transport)
}
pub fn strip_hashline_fields(payload: &Value) -> Value {
let mut stripped = payload.clone();
if let Some(obj) = stripped.as_object_mut() {
for key in HASHLINE_STRUCTURED_KEYS {
obj.remove(*key);
}
if let Some(Value::Object(meta)) = obj.get_mut("metadata") {
meta.remove("hashline");
meta.remove("classifications");
}
}
stripped
}
pub fn shipped_envelope_is_renderable(stripped: &Value) -> bool {
let obj = match stripped.as_object() {
Some(obj) => obj,
None => return false,
};
obj.contains_key("output")
&& obj.contains_key("complete")
&& obj.contains_key("partial")
&& obj.contains_key("all_failed")
&& obj
.get("metadata")
.and_then(|m| m.get("files"))
.map(|f| f.is_array())
.unwrap_or(false)
&& obj
.get("metadata")
.and_then(|m| m.get("diff"))
.map(|d| d.is_string())
.unwrap_or(false)
}
pub fn render_agent_output(envelope: &TransactionEnvelope) -> String {
let mut lines = Vec::new();
lines.push(envelope.summary_text.clone());
if envelope.preview {
lines.push("preview: no files were modified".to_string());
}
for file in &envelope.files {
lines.push(render_file_text(file));
}
if let Some(stop) = envelope.stop_reason {
lines.push(format!("stop_reason: {stop}"));
}
if let Some(op_id) = &envelope.op_id {
lines.push(format!("op_id: {op_id}"));
}
lines.join("\n")
}
fn render_file_text(file: &FileOutcome) -> String {
let role = file.role.as_str();
let class = file.classification.as_str();
let mut line = format!(
"{role} {path}: {class} ({state})",
path = file.requested_path,
state = file.mutation_state.as_str()
);
if let Some(tag) = &file.final_tag {
line.push_str(&format!("\n[{}#{}]", file.requested_path, tag));
if !file.affected.ranges.is_empty() {
}
} else if let Some(notice) = &file.tag_notice {
line.push_str(&format!("\n{notice}"));
} else if file.remove_file
&& file.classification.is_applied_star()
&& file.role == FileRole::MvSource
{
line.push_str(&format!(
"\nsource removed: {} (no final tag)",
file.requested_path
));
} else if file.classification == FileClassification::AppliedTagUnavailable {
line.push_str("\ntag unavailable: re-read before chaining");
}
if let Some(reason) = &file.format_skipped_reason {
line.push_str(&format!("\nformat_skipped: {reason}"));
}
for warning in &file.warnings {
line.push_str(&format!("\nwarning: {warning}"));
}
line
}
fn render_title(envelope: &TransactionEnvelope) -> String {
if envelope.preview {
return "Hashline preview".to_string();
}
if envelope.complete {
"Hashline edit applied".to_string()
} else if envelope.success {
"Hashline edit partially applied".to_string()
} else {
"Hashline edit failed".to_string()
}
}
fn first_display_path(
display_files: &[DisplayFileBytes],
envelope: &TransactionEnvelope,
) -> Option<String> {
display_files
.iter()
.map(|f| f.requested_path.clone())
.next()
.or_else(|| {
envelope
.files
.iter()
.find(|f| f.role != FileRole::MvSource)
.map(|f| f.requested_path.clone())
})
}
fn build_display_metadata(
display_files: &[DisplayFileBytes],
project_root: Option<&Path>,
) -> (String, Vec<Value>) {
let mut files = Vec::with_capacity(display_files.len());
for file in display_files {
let before = String::from_utf8_lossy(&file.before);
let after = if file.remove_file {
String::new()
} else {
file.after
.as_ref()
.map(|bytes| String::from_utf8_lossy(bytes).into_owned())
.unwrap_or_default()
};
let entry_type = if file.move_from.is_some() {
"move"
} else if file.remove_file
|| (file.after.as_ref().is_some_and(|a| a.is_empty()) && !file.before.is_empty())
{
"delete"
} else if file.before.is_empty() && file.after.as_ref().is_some_and(|a| !a.is_empty()) {
"add"
} else {
"update"
};
let patch = build_unified_diff(&file.requested_path, &before, &after);
let (additions, deletions) = line_diff_counts(&before, &after);
let mut entry = json!({
"filePath": absolutize(project_root, &file.requested_path),
"relativePath": relativize(project_root, &file.requested_path),
"type": entry_type,
"patch": patch,
"additions": additions,
"deletions": deletions,
});
if let Some(src) = &file.move_from {
entry["movePath"] = json!(src);
entry["sourcePath"] = json!(src);
}
files.push(entry);
}
let diff = files
.iter()
.filter_map(|file| file.get("patch").and_then(Value::as_str))
.filter(|patch| !patch.is_empty())
.collect::<Vec<_>>()
.join("\n");
(diff, files)
}
fn line_diff_counts(before: &str, after: &str) -> (usize, usize) {
use similar::ChangeTag;
let diff = similar::TextDiff::from_lines(before, after);
let mut additions = 0usize;
let mut deletions = 0usize;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Insert => additions += 1,
ChangeTag::Delete => deletions += 1,
ChangeTag::Equal => {}
}
}
(additions, deletions)
}
fn absolutize(project_root: Option<&Path>, requested: &str) -> String {
let path = PathBuf::from(requested);
if path.is_absolute() {
return path.to_string_lossy().into_owned();
}
match project_root {
Some(root) => root.join(path).to_string_lossy().into_owned(),
None => requested.to_string(),
}
}
fn relativize(project_root: Option<&Path>, requested: &str) -> String {
let path = PathBuf::from(requested);
if let Some(root) = project_root {
if path.is_absolute() {
if let Ok(rel) = path.strip_prefix(root) {
return rel.to_string_lossy().replace('\\', "/");
}
}
}
requested.replace('\\', "/")
}
fn adapt_for_transport(mut payload: Value, transport: TransportKind) -> Value {
if let Some(obj) = payload.as_object_mut() {
obj.insert("transport".to_string(), json!(transport.as_str()));
match transport {
TransportKind::Mcp => {
let output = obj
.get("output")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
obj.insert(
"content".to_string(),
json!([{ "type": "text", "text": output }]),
);
}
TransportKind::OpenCode | TransportKind::Pi => {
obj.entry("metadata".to_string())
.or_insert_with(|| json!({ "diff": "", "files": [] }));
}
TransportKind::Ndjson | TransportKind::Subc => {
}
}
}
payload
}
pub fn display_files_from_envelope(
envelope: &TransactionEnvelope,
baselines: &[(String, Vec<u8>)],
) -> Vec<DisplayFileBytes> {
let baseline_map: std::collections::BTreeMap<&str, &[u8]> = baselines
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect();
let mut out = Vec::new();
let mut i = 0usize;
while i < envelope.files.len() {
let file = &envelope.files[i];
match file.role {
FileRole::MvDestination => {
let source = envelope
.files
.get(i + 1)
.filter(|f| f.role == FileRole::MvSource);
let before = baseline_map
.get(file.requested_path.as_str())
.map(|b| b.to_vec())
.unwrap_or_default();
out.push(DisplayFileBytes {
requested_path: file.requested_path.clone(),
before,
after: file.final_bytes.clone(),
remove_file: false,
move_from: source.map(|s| s.requested_path.clone()),
});
if source.is_some() {
i += 2;
continue;
}
}
FileRole::MvSource => {
let before = baseline_map
.get(file.requested_path.as_str())
.map(|b| b.to_vec())
.unwrap_or_default();
out.push(DisplayFileBytes {
requested_path: file.requested_path.clone(),
before,
after: None,
remove_file: file.remove_file,
move_from: None,
});
}
FileRole::Primary => {
let before = baseline_map
.get(file.requested_path.as_str())
.map(|b| b.to_vec())
.unwrap_or_default();
out.push(DisplayFileBytes {
requested_path: file.requested_path.clone(),
before,
after: file.final_bytes.clone(),
remove_file: file.remove_file,
move_from: None,
});
}
}
i += 1;
}
out
}
pub fn all_failed_payload_preserved(payload: &Value) -> bool {
let obj = match payload.as_object() {
Some(obj) => obj,
None => return false,
};
if obj.get("success") != Some(&Value::Bool(false)) {
return false;
}
if obj.get("all_failed") != Some(&Value::Bool(true)) {
return false;
}
let output = obj.get("output").and_then(Value::as_str).unwrap_or("");
if !output.contains("files applied") {
return false;
}
let stripped = strip_hashline_fields(payload);
shipped_envelope_is_renderable(&stripped)
&& stripped
.get("output")
.and_then(Value::as_str)
.is_some_and(|text| text.contains("files applied"))
}
pub fn carrier_keys(payload: &Value) -> BTreeSet<String> {
const REQUIRED: &[&str] = &[
"output",
"success",
"complete",
"partial",
"all_failed",
"metadata",
];
let mut keys = BTreeSet::new();
if let Some(obj) = payload.as_object() {
for key in REQUIRED {
if obj.contains_key(*key) {
keys.insert((*key).to_string());
}
}
}
keys
}
pub fn transports_preserve_carriers(payloads: &[(TransportKind, Value)]) -> bool {
if payloads.is_empty() {
return false;
}
let expected = carrier_keys(&payloads[0].1);
if expected.len() < 6 {
return false;
}
payloads
.iter()
.all(|(_, payload)| carrier_keys(payload) == expected)
}
pub fn synthetic_envelope(
success: bool,
complete: bool,
files: Vec<FileOutcome>,
op_id: Option<String>,
preview: bool,
) -> TransactionEnvelope {
let applied = files
.iter()
.filter(|f| f.role != FileRole::MvSource && f.classification.is_applied_star())
.count();
let total = files
.iter()
.filter(|f| !(f.role == FileRole::MvSource && f.classification.is_applied_star()))
.count();
TransactionEnvelope {
success,
complete,
files,
op_id,
stop_reason: if success {
None
} else {
Some("hashline_baseline_drift")
},
registers_committed: false,
preview,
summary_text: format!("{applied} of {total} files applied"),
}
}
pub fn synthetic_failed_file(path: &str, classification: FileClassification) -> FileOutcome {
FileOutcome {
canonical_path: PathBuf::from(path),
requested_path: path.to_string(),
role: FileRole::Primary,
classification,
mutation_state: classification.mutation_state(),
final_bytes: None,
final_tag: None,
affected: crate::hashline::snapshot::AffectedRegion::default(),
warnings: Vec::new(),
format_skipped_reason: None,
backup_id: None,
remove_file: false,
tag_notice: None,
}
}
pub fn synthetic_applied_file(path: &str, _before: &[u8], after: &[u8], tag: &str) -> FileOutcome {
FileOutcome {
canonical_path: PathBuf::from(path),
requested_path: path.to_string(),
role: FileRole::Primary,
classification: FileClassification::Applied,
mutation_state: MutationState::Applied,
final_bytes: Some(after.to_vec()),
final_tag: Some(tag.to_string()),
affected: crate::hashline::snapshot::AffectedRegion::default(),
warnings: Vec::new(),
format_skipped_reason: None,
backup_id: Some("bak-test".to_string()),
remove_file: false,
tag_notice: None,
}
}
pub fn required_rejection_fields(payload: &Value) -> Map<String, Value> {
let mut out = Map::new();
if let Some(obj) = payload.as_object() {
for key in ["code", "stage", "message", "steering", "output", "success"] {
if let Some(value) = obj.get(key) {
out.insert(key.to_string(), value.clone());
}
}
}
out
}
pub fn rejection_transport_status(_code: HashlineRejectionCode) -> &'static str {
"error"
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RejectionTransportContract {
pub code: HashlineRejectionCode,
pub stage: RejectionStage,
pub transport_status: &'static str,
pub steering: &'static str,
pub mutates_files: bool,
pub mutates_stores: bool,
}
pub fn rejection_transport_registry() -> Vec<RejectionTransportContract> {
use HashlineRejectionCode::*;
use RejectionStage::*;
let row = |code: HashlineRejectionCode, stage: RejectionStage, steering: &'static str| {
RejectionTransportContract {
code,
stage,
transport_status: rejection_transport_status(code),
steering,
mutates_files: false,
mutates_stores: false,
}
};
vec![
row(
ParseError,
Parse,
"submit only a hashline patch with tagged section headers and valid operations",
),
row(
MissingTag,
Header,
"read the current file with the tagged read surface, then include its four-hex tag",
),
row(
MalformedTag,
Header,
"read the current file with the tagged read surface, then include its four-hex tag",
),
row(
UnknownTag,
Resolution,
"re-read the file to mint a fresh tag, then retry the edit",
),
row(
EvictedTag,
Resolution,
"re-read the file to mint a fresh tag, then retry the edit",
),
row(
AmbiguousTag,
Resolution,
"use apply_patch or another available non-hashline edit surface; re-reading preserves this colliding four-hex tag",
),
row(
AmbiguousTag,
Recovery,
"re-address the current tagged content; the stale span has multiple verbatim landings",
),
row(
StaleTag,
Verification,
"perform a ranged tagged re-read because required boundary context changed",
),
row(
StaleTag,
Recovery,
"re-address the current tagged content; the stale span no longer occurs verbatim",
),
row(
UnseenLine,
Eligibility,
"re-read the file to mint a fresh tag that includes every addressed row and boundary, then retry the edit",
),
row(
BoundaryIneligible,
Eligibility,
"re-read the file to mint a fresh tag that includes every addressed row and boundary, then retry the edit",
),
row(
UntaggablePath,
Path,
"choose a writable regular text file or use an available non-hashline surface",
),
row(
RegisterOverflow,
Register,
"reduce register contents before retrying the patch",
),
row(
BackupUnavailable,
Baseline,
"enable backups or use apply_patch for this destructive change",
),
]
}
pub fn rejection_for_contract(contract: &RejectionTransportContract) -> HashlineRejection {
HashlineRejection::new(contract.code, contract.stage, contract.code.as_str())
}