Skip to main content

aft/hashline/integration/
transport.rs

1//! Transport preservation for hashline responses across host paths.
2//!
3//! NDJSON, subc, MCP, OpenCode, and Pi all share one renderer. Hashline-specific
4//! structured fields supplement the shipped mutation display envelope; hosts that
5//! strip those fields still render truth from `output` / `metadata` / completion
6//! flags. All-failed Phase-2 envelopes keep `success:false` with the complete
7//! per-file payload through every shipped host path.
8
9use 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/// Host transport that must preserve the shared carrier contract.
20#[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/// Optional before/after bytes used to build the shipped display envelope.
50#[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/// Inputs required to render a mutation (or preview) response for hosts.
60#[derive(Clone, Debug)]
61pub struct MutationRenderInput<'a> {
62    pub envelope: &'a TransactionEnvelope,
63    /// Patch-order file bytes for diff metadata (primary / destination rows).
64    pub display_files: &'a [DisplayFileBytes],
65    pub project_root: Option<&'a Path>,
66    pub transport: TransportKind,
67}
68
69/// Hashline fields that supplement the shipped envelope.
70const 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
82/// Render a Phase-2 (or preview) envelope for one transport.
83pub 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    // Hashline supplements — hosts may strip these (A14) and still render.
116    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
158/// Render a Phase-1 rejection for one transport.
159pub 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
185/// Strip hashline-specific structured fields, leaving the shipped display envelope.
186///
187/// A14 locks that hosts consuming only this envelope still render single-file,
188/// multi-file, MV, and all-failed results correctly.
189pub 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        // classifications / final_tags already covered; also drop nested hashline-only
196        // keys that may appear under metadata in future extensions.
197        if let Some(Value::Object(meta)) = obj.get_mut("metadata") {
198            meta.remove("hashline");
199            meta.remove("classifications");
200        }
201    }
202    stripped
203}
204
205/// True when the stripped envelope still carries the fields hosts need to render.
206pub 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
227/// Agent-visible text: summary lead-in plus per-file tag carriers / notices.
228pub 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            // Affected gutters are carried when final bytes are available; the
258            // structured classifications already expose the region. Text keeps
259            // the tag header so chaining works on field-stripping hosts.
260        }
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            // Spec: MV displays the destination diff and names the source.
352            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
403/// Transport-specific adaptation while preserving the shared carrier contract.
404fn 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                // MCP tool results expose text content; keep output as the primary
410                // agent-visible carrier and mirror it under content for adapters.
411                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                // Hoisted adapters read filePath + metadata.diff / metadata.files[].
423                // Ensure metadata always exists even on pure rejections.
424                obj.entry("metadata".to_string())
425                    .or_insert_with(|| json!({ "diff": "", "files": [] }));
426            }
427            TransportKind::Ndjson | TransportKind::Subc => {
428                // Wire JSON as-is; summary_text already leads output.
429            }
430        }
431    }
432    payload
433}
434
435/// Build display-file rows from a transaction envelope when callers already hold
436/// baseline/final bytes on each [`FileOutcome`].
437pub 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                // Source-only row without a preceding destination is unusual;
474                // still emit a delete-style entry so hosts see the path.
475                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
506/// Assert all-failed payloads retain the complete mutation envelope after strip.
507pub 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    // Text always leads with applied/failed counts so field-stripping hosts
520    // still render the truth ("0 of N files applied").
521    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
532/// Collect the set of transports that must agree on carrier keys for one payload.
533pub 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
553/// Merge transport payloads and verify they share the same carrier contract.
554pub 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
567/// Build a minimal synthetic envelope for transport tests without disk I/O.
568pub 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
599/// Convenience constructor for a failed primary file row.
600pub 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
618/// Convenience constructor for an applied primary file row with tag + bytes.
619pub 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
637/// Flatten a JSON object for stable golden comparison of required keys.
638pub 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
650/// Transport status for a Phase-1 rejection (always non-mutating error).
651pub fn rejection_transport_status(_code: HashlineRejectionCode) -> &'static str {
652    "error"
653}
654
655/// Registry row locking transport status, stage, and steering for one code.
656#[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
666/// Full rejection transport registry (A18 transport + steering portions).
667pub 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
756/// Build a rejection whose steering matches the registry for (code, stage).
757pub fn rejection_for_contract(contract: &RejectionTransportContract) -> HashlineRejection {
758    HashlineRejection::new(contract.code, contract.stage, contract.code.as_str())
759}