1use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11
12use serde_json::{json, Map, Value};
13
14use crate::edit::build_unified_diff;
15use crate::hashline::apply::{FileClassification, MutationState};
16use crate::hashline::syntax::{HashlineRejection, HashlineRejectionCode, RejectionStage};
17use crate::hashline::transaction::{FileOutcome, FileRole, TransactionEnvelope};
18
19#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
21pub enum TransportKind {
22 Ndjson,
23 Subc,
24 Mcp,
25 OpenCode,
26 Pi,
27}
28
29impl TransportKind {
30 pub const fn as_str(self) -> &'static str {
31 match self {
32 Self::Ndjson => "ndjson",
33 Self::Subc => "subc",
34 Self::Mcp => "mcp",
35 Self::OpenCode => "opencode",
36 Self::Pi => "pi",
37 }
38 }
39
40 pub const ALL: &[TransportKind] = &[
41 Self::Ndjson,
42 Self::Subc,
43 Self::Mcp,
44 Self::OpenCode,
45 Self::Pi,
46 ];
47}
48
49#[derive(Clone, Debug, Default)]
51pub struct DisplayFileBytes {
52 pub requested_path: String,
53 pub before: Vec<u8>,
54 pub after: Option<Vec<u8>>,
55 pub remove_file: bool,
56 pub move_from: Option<String>,
57}
58
59#[derive(Clone, Debug)]
61pub struct MutationRenderInput<'a> {
62 pub envelope: &'a TransactionEnvelope,
63 pub display_files: &'a [DisplayFileBytes],
65 pub project_root: Option<&'a Path>,
66 pub transport: TransportKind,
67}
68
69const HASHLINE_STRUCTURED_KEYS: &[&str] = &[
71 "hashline",
72 "classifications",
73 "mutation_states",
74 "final_tags",
75 "registers_committed",
76 "stop_reason",
77 "remap_recovery",
78 "stage",
79 "steering",
80];
81
82pub fn render_mutation_response(input: MutationRenderInput<'_>) -> Value {
84 let envelope = input.envelope;
85 let (diff, files_meta) = build_display_metadata(input.display_files, input.project_root);
86 let file_path = first_display_path(input.display_files, envelope);
87 let output = render_agent_output(envelope);
88 let title = render_title(envelope);
89 let complete = envelope.complete;
90 let partial = envelope.success && !envelope.complete;
91 let all_failed = !envelope.success;
92
93 let mut payload = json!({
94 "output": output,
95 "title": title,
96 "success": envelope.success,
97 "complete": complete,
98 "partial": partial,
99 "all_failed": all_failed,
100 "preview": envelope.preview,
101 "metadata": {
102 "diff": diff,
103 "files": files_meta,
104 },
105 });
106
107 if let Some(path) = file_path {
108 payload["filePath"] = json!(path);
109 }
110
111 if let Some(op_id) = &envelope.op_id {
112 payload["op_id"] = json!(op_id);
113 }
114
115 payload["hashline"] = json!(true);
117 payload["registers_committed"] = json!(envelope.registers_committed);
118 if let Some(stop) = envelope.stop_reason {
119 payload["stop_reason"] = json!(stop);
120 }
121 payload["classifications"] = Value::Array(
122 envelope
123 .files
124 .iter()
125 .map(|f| {
126 json!({
127 "requested_path": f.requested_path,
128 "role": f.role.as_str(),
129 "classification": f.classification.as_str(),
130 "mutation_state": f.mutation_state.as_str(),
131 "final_tag": f.final_tag,
132 "backup_id": f.backup_id,
133 "format_skipped_reason": f.format_skipped_reason,
134 "tag_notice": f.tag_notice,
135 "remove_file": f.remove_file,
136 })
137 })
138 .collect(),
139 );
140 payload["final_tags"] = Value::Array(
141 envelope
142 .files
143 .iter()
144 .filter_map(|f| {
145 f.final_tag.as_ref().map(|tag| {
146 json!({
147 "requested_path": f.requested_path,
148 "tag": tag,
149 })
150 })
151 })
152 .collect(),
153 );
154
155 adapt_for_transport(payload, input.transport)
156}
157
158pub fn render_rejection_response(rejection: &HashlineRejection, transport: TransportKind) -> Value {
160 let payload = json!({
161 "success": false,
162 "complete": false,
163 "partial": false,
164 "all_failed": false,
165 "error": rejection.code.as_str(),
166 "code": rejection.code.as_str(),
167 "stage": rejection.stage.as_str(),
168 "message": rejection.message,
169 "steering": rejection.steering,
170 "output": format!(
171 "{} at {}: {}\n{}",
172 rejection.code.as_str(),
173 rejection.stage.as_str(),
174 rejection.message,
175 rejection.steering
176 ),
177 "metadata": {
178 "diff": "",
179 "files": [],
180 },
181 });
182 adapt_for_transport(payload, transport)
183}
184
185pub fn strip_hashline_fields(payload: &Value) -> Value {
190 let mut stripped = payload.clone();
191 if let Some(obj) = stripped.as_object_mut() {
192 for key in HASHLINE_STRUCTURED_KEYS {
193 obj.remove(*key);
194 }
195 if let Some(Value::Object(meta)) = obj.get_mut("metadata") {
198 meta.remove("hashline");
199 meta.remove("classifications");
200 }
201 }
202 stripped
203}
204
205pub fn shipped_envelope_is_renderable(stripped: &Value) -> bool {
207 let obj = match stripped.as_object() {
208 Some(obj) => obj,
209 None => return false,
210 };
211 obj.contains_key("output")
212 && obj.contains_key("complete")
213 && obj.contains_key("partial")
214 && obj.contains_key("all_failed")
215 && obj
216 .get("metadata")
217 .and_then(|m| m.get("files"))
218 .map(|f| f.is_array())
219 .unwrap_or(false)
220 && obj
221 .get("metadata")
222 .and_then(|m| m.get("diff"))
223 .map(|d| d.is_string())
224 .unwrap_or(false)
225}
226
227pub fn render_agent_output(envelope: &TransactionEnvelope) -> String {
229 let mut lines = Vec::new();
230 lines.push(envelope.summary_text.clone());
231 if envelope.preview {
232 lines.push("preview: no files were modified".to_string());
233 }
234 for file in &envelope.files {
235 lines.push(render_file_text(file));
236 }
237 if let Some(stop) = envelope.stop_reason {
238 lines.push(format!("stop_reason: {stop}"));
239 }
240 if let Some(op_id) = &envelope.op_id {
241 lines.push(format!("op_id: {op_id}"));
242 }
243 lines.join("\n")
244}
245
246fn render_file_text(file: &FileOutcome) -> String {
247 let role = file.role.as_str();
248 let class = file.classification.as_str();
249 let mut line = format!(
250 "{role} {path}: {class} ({state})",
251 path = file.requested_path,
252 state = file.mutation_state.as_str()
253 );
254 if let Some(tag) = &file.final_tag {
255 line.push_str(&format!("\n[{}#{}]", file.requested_path, tag));
256 if !file.affected.ranges.is_empty() {
257 }
261 } else if let Some(notice) = &file.tag_notice {
262 line.push_str(&format!("\n{notice}"));
263 } else if file.remove_file
264 && file.classification.is_applied_star()
265 && file.role == FileRole::MvSource
266 {
267 line.push_str(&format!(
268 "\nsource removed: {} (no final tag)",
269 file.requested_path
270 ));
271 } else if file.classification == FileClassification::AppliedTagUnavailable {
272 line.push_str("\ntag unavailable: re-read before chaining");
273 }
274 if let Some(reason) = &file.format_skipped_reason {
275 line.push_str(&format!("\nformat_skipped: {reason}"));
276 }
277 for warning in &file.warnings {
278 line.push_str(&format!("\nwarning: {warning}"));
279 }
280 line
281}
282
283fn render_title(envelope: &TransactionEnvelope) -> String {
284 if envelope.preview {
285 return "Hashline preview".to_string();
286 }
287 if envelope.complete {
288 "Hashline edit applied".to_string()
289 } else if envelope.success {
290 "Hashline edit partially applied".to_string()
291 } else {
292 "Hashline edit failed".to_string()
293 }
294}
295
296fn first_display_path(
297 display_files: &[DisplayFileBytes],
298 envelope: &TransactionEnvelope,
299) -> Option<String> {
300 display_files
301 .iter()
302 .map(|f| f.requested_path.clone())
303 .next()
304 .or_else(|| {
305 envelope
306 .files
307 .iter()
308 .find(|f| f.role != FileRole::MvSource)
309 .map(|f| f.requested_path.clone())
310 })
311}
312
313fn build_display_metadata(
314 display_files: &[DisplayFileBytes],
315 project_root: Option<&Path>,
316) -> (String, Vec<Value>) {
317 let mut files = Vec::with_capacity(display_files.len());
318 for file in display_files {
319 let before = String::from_utf8_lossy(&file.before);
320 let after = if file.remove_file {
321 String::new()
322 } else {
323 file.after
324 .as_ref()
325 .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
326 .unwrap_or_default()
327 };
328 let entry_type = if file.move_from.is_some() {
329 "move"
330 } else if file.remove_file
331 || (file.after.as_ref().is_some_and(|a| a.is_empty()) && !file.before.is_empty())
332 {
333 "delete"
334 } else if file.before.is_empty() && file.after.as_ref().is_some_and(|a| !a.is_empty()) {
335 "add"
336 } else {
337 "update"
338 };
339 let patch = build_unified_diff(&file.requested_path, &before, &after);
340 let (additions, deletions) = line_diff_counts(&before, &after);
341 let mut entry = json!({
342 "filePath": absolutize(project_root, &file.requested_path),
343 "relativePath": relativize(project_root, &file.requested_path),
344 "type": entry_type,
345 "patch": patch,
346 "additions": additions,
347 "deletions": deletions,
348 });
349 if let Some(src) = &file.move_from {
350 entry["movePath"] = json!(src);
351 entry["sourcePath"] = json!(src);
353 }
354 files.push(entry);
355 }
356 let diff = files
357 .iter()
358 .filter_map(|file| file.get("patch").and_then(Value::as_str))
359 .filter(|patch| !patch.is_empty())
360 .collect::<Vec<_>>()
361 .join("\n");
362 (diff, files)
363}
364
365fn line_diff_counts(before: &str, after: &str) -> (usize, usize) {
366 use similar::ChangeTag;
367 let diff = similar::TextDiff::from_lines(before, after);
368 let mut additions = 0usize;
369 let mut deletions = 0usize;
370 for change in diff.iter_all_changes() {
371 match change.tag() {
372 ChangeTag::Insert => additions += 1,
373 ChangeTag::Delete => deletions += 1,
374 ChangeTag::Equal => {}
375 }
376 }
377 (additions, deletions)
378}
379
380fn absolutize(project_root: Option<&Path>, requested: &str) -> String {
381 let path = PathBuf::from(requested);
382 if path.is_absolute() {
383 return path.to_string_lossy().into_owned();
384 }
385 match project_root {
386 Some(root) => root.join(path).to_string_lossy().into_owned(),
387 None => requested.to_string(),
388 }
389}
390
391fn relativize(project_root: Option<&Path>, requested: &str) -> String {
392 let path = PathBuf::from(requested);
393 if let Some(root) = project_root {
394 if path.is_absolute() {
395 if let Ok(rel) = path.strip_prefix(root) {
396 return rel.to_string_lossy().replace('\\', "/");
397 }
398 }
399 }
400 requested.replace('\\', "/")
401}
402
403fn adapt_for_transport(mut payload: Value, transport: TransportKind) -> Value {
405 if let Some(obj) = payload.as_object_mut() {
406 obj.insert("transport".to_string(), json!(transport.as_str()));
407 match transport {
408 TransportKind::Mcp => {
409 let output = obj
412 .get("output")
413 .and_then(Value::as_str)
414 .unwrap_or("")
415 .to_string();
416 obj.insert(
417 "content".to_string(),
418 json!([{ "type": "text", "text": output }]),
419 );
420 }
421 TransportKind::OpenCode | TransportKind::Pi => {
422 obj.entry("metadata".to_string())
425 .or_insert_with(|| json!({ "diff": "", "files": [] }));
426 }
427 TransportKind::Ndjson | TransportKind::Subc => {
428 }
430 }
431 }
432 payload
433}
434
435pub fn display_files_from_envelope(
438 envelope: &TransactionEnvelope,
439 baselines: &[(String, Vec<u8>)],
440) -> Vec<DisplayFileBytes> {
441 let baseline_map: std::collections::BTreeMap<&str, &[u8]> = baselines
442 .iter()
443 .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
444 .collect();
445
446 let mut out = Vec::new();
447 let mut i = 0usize;
448 while i < envelope.files.len() {
449 let file = &envelope.files[i];
450 match file.role {
451 FileRole::MvDestination => {
452 let source = envelope
453 .files
454 .get(i + 1)
455 .filter(|f| f.role == FileRole::MvSource);
456 let before = baseline_map
457 .get(file.requested_path.as_str())
458 .map(|b| b.to_vec())
459 .unwrap_or_default();
460 out.push(DisplayFileBytes {
461 requested_path: file.requested_path.clone(),
462 before,
463 after: file.final_bytes.clone(),
464 remove_file: false,
465 move_from: source.map(|s| s.requested_path.clone()),
466 });
467 if source.is_some() {
468 i += 2;
469 continue;
470 }
471 }
472 FileRole::MvSource => {
473 let before = baseline_map
476 .get(file.requested_path.as_str())
477 .map(|b| b.to_vec())
478 .unwrap_or_default();
479 out.push(DisplayFileBytes {
480 requested_path: file.requested_path.clone(),
481 before,
482 after: None,
483 remove_file: file.remove_file,
484 move_from: None,
485 });
486 }
487 FileRole::Primary => {
488 let before = baseline_map
489 .get(file.requested_path.as_str())
490 .map(|b| b.to_vec())
491 .unwrap_or_default();
492 out.push(DisplayFileBytes {
493 requested_path: file.requested_path.clone(),
494 before,
495 after: file.final_bytes.clone(),
496 remove_file: file.remove_file,
497 move_from: None,
498 });
499 }
500 }
501 i += 1;
502 }
503 out
504}
505
506pub fn all_failed_payload_preserved(payload: &Value) -> bool {
508 let obj = match payload.as_object() {
509 Some(obj) => obj,
510 None => return false,
511 };
512 if obj.get("success") != Some(&Value::Bool(false)) {
513 return false;
514 }
515 if obj.get("all_failed") != Some(&Value::Bool(true)) {
516 return false;
517 }
518 let output = obj.get("output").and_then(Value::as_str).unwrap_or("");
519 if !output.contains("files applied") {
522 return false;
523 }
524 let stripped = strip_hashline_fields(payload);
525 shipped_envelope_is_renderable(&stripped)
526 && stripped
527 .get("output")
528 .and_then(Value::as_str)
529 .is_some_and(|text| text.contains("files applied"))
530}
531
532pub fn carrier_keys(payload: &Value) -> BTreeSet<String> {
534 const REQUIRED: &[&str] = &[
535 "output",
536 "success",
537 "complete",
538 "partial",
539 "all_failed",
540 "metadata",
541 ];
542 let mut keys = BTreeSet::new();
543 if let Some(obj) = payload.as_object() {
544 for key in REQUIRED {
545 if obj.contains_key(*key) {
546 keys.insert((*key).to_string());
547 }
548 }
549 }
550 keys
551}
552
553pub fn transports_preserve_carriers(payloads: &[(TransportKind, Value)]) -> bool {
555 if payloads.is_empty() {
556 return false;
557 }
558 let expected = carrier_keys(&payloads[0].1);
559 if expected.len() < 6 {
560 return false;
561 }
562 payloads
563 .iter()
564 .all(|(_, payload)| carrier_keys(payload) == expected)
565}
566
567pub fn synthetic_envelope(
569 success: bool,
570 complete: bool,
571 files: Vec<FileOutcome>,
572 op_id: Option<String>,
573 preview: bool,
574) -> TransactionEnvelope {
575 let applied = files
576 .iter()
577 .filter(|f| f.role != FileRole::MvSource && f.classification.is_applied_star())
578 .count();
579 let total = files
580 .iter()
581 .filter(|f| !(f.role == FileRole::MvSource && f.classification.is_applied_star()))
582 .count();
583 TransactionEnvelope {
584 success,
585 complete,
586 files,
587 op_id,
588 stop_reason: if success {
589 None
590 } else {
591 Some("hashline_baseline_drift")
592 },
593 registers_committed: false,
594 preview,
595 summary_text: format!("{applied} of {total} files applied"),
596 }
597}
598
599pub fn synthetic_failed_file(path: &str, classification: FileClassification) -> FileOutcome {
601 FileOutcome {
602 canonical_path: PathBuf::from(path),
603 requested_path: path.to_string(),
604 role: FileRole::Primary,
605 classification,
606 mutation_state: classification.mutation_state(),
607 final_bytes: None,
608 final_tag: None,
609 affected: crate::hashline::snapshot::AffectedRegion::default(),
610 warnings: Vec::new(),
611 format_skipped_reason: None,
612 backup_id: None,
613 remove_file: false,
614 tag_notice: None,
615 }
616}
617
618pub fn synthetic_applied_file(path: &str, _before: &[u8], after: &[u8], tag: &str) -> FileOutcome {
620 FileOutcome {
621 canonical_path: PathBuf::from(path),
622 requested_path: path.to_string(),
623 role: FileRole::Primary,
624 classification: FileClassification::Applied,
625 mutation_state: MutationState::Applied,
626 final_bytes: Some(after.to_vec()),
627 final_tag: Some(tag.to_string()),
628 affected: crate::hashline::snapshot::AffectedRegion::default(),
629 warnings: Vec::new(),
630 format_skipped_reason: None,
631 backup_id: Some("bak-test".to_string()),
632 remove_file: false,
633 tag_notice: None,
634 }
635}
636
637pub fn required_rejection_fields(payload: &Value) -> Map<String, Value> {
639 let mut out = Map::new();
640 if let Some(obj) = payload.as_object() {
641 for key in ["code", "stage", "message", "steering", "output", "success"] {
642 if let Some(value) = obj.get(key) {
643 out.insert(key.to_string(), value.clone());
644 }
645 }
646 }
647 out
648}
649
650pub fn rejection_transport_status(_code: HashlineRejectionCode) -> &'static str {
652 "error"
653}
654
655#[derive(Clone, Debug, Eq, PartialEq)]
657pub struct RejectionTransportContract {
658 pub code: HashlineRejectionCode,
659 pub stage: RejectionStage,
660 pub transport_status: &'static str,
661 pub steering: &'static str,
662 pub mutates_files: bool,
663 pub mutates_stores: bool,
664}
665
666pub fn rejection_transport_registry() -> Vec<RejectionTransportContract> {
668 use HashlineRejectionCode::*;
669 use RejectionStage::*;
670
671 let row = |code: HashlineRejectionCode, stage: RejectionStage, steering: &'static str| {
672 RejectionTransportContract {
673 code,
674 stage,
675 transport_status: rejection_transport_status(code),
676 steering,
677 mutates_files: false,
678 mutates_stores: false,
679 }
680 };
681
682 vec![
683 row(
684 ParseError,
685 Parse,
686 "submit only a hashline patch with tagged section headers and valid operations",
687 ),
688 row(
689 MissingTag,
690 Header,
691 "read the current file with the tagged read surface, then include its four-hex tag",
692 ),
693 row(
694 MalformedTag,
695 Header,
696 "read the current file with the tagged read surface, then include its four-hex tag",
697 ),
698 row(
699 UnknownTag,
700 Resolution,
701 "re-read the current tagged content before editing",
702 ),
703 row(
704 EvictedTag,
705 Resolution,
706 "re-read the current tagged content before editing",
707 ),
708 row(
709 AmbiguousTag,
710 Resolution,
711 "use apply_patch or another available non-hashline edit surface; re-reading preserves this colliding four-hex tag",
712 ),
713 row(
714 AmbiguousTag,
715 Recovery,
716 "re-address the current tagged content; the stale span has multiple verbatim landings",
717 ),
718 row(
719 StaleTag,
720 Verification,
721 "perform a ranged tagged re-read because required boundary context changed",
722 ),
723 row(
724 StaleTag,
725 Recovery,
726 "re-address the current tagged content; the stale span no longer occurs verbatim",
727 ),
728 row(
729 UnseenLine,
730 Eligibility,
731 "read the addressed rows and their boundary context with the tagged read surface",
732 ),
733 row(
734 BoundaryIneligible,
735 Eligibility,
736 "read the addressed rows and their boundary context with the tagged read surface",
737 ),
738 row(
739 UntaggablePath,
740 Path,
741 "choose a writable regular text file or use an available non-hashline surface",
742 ),
743 row(
744 RegisterOverflow,
745 Register,
746 "reduce register contents before retrying the patch",
747 ),
748 row(
749 BackupUnavailable,
750 Baseline,
751 "enable backups or use apply_patch for this destructive change",
752 ),
753 ]
754}
755
756pub fn rejection_for_contract(contract: &RejectionTransportContract) -> HashlineRejection {
758 HashlineRejection::new(contract.code, contract.stage, contract.code.as_str())
759}