Skip to main content

aft/hashline/integration/
schema.rs

1//! Edit schema selection and governed schema/manifest artifacts.
2//!
3//! One binary ships both arms. The installed session effective value selects
4//! exactly one agent-visible `edit` schema. This module is the exclusive owner
5//! of the hashline-side governed schema and manifest definitions; host wiring
6//! regenerates committed artifacts from these constants.
7
8use serde_json::{json, Value};
9
10use super::binding::effective_for_capture;
11use super::binding::BindingGuard;
12
13/// Which edit schema arm a session publishes.
14#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
15pub enum EditSchemaArm {
16    /// Legacy match/line/symbol edit surface (gate-off / unregistered).
17    Legacy,
18    /// Hashline patch language with required `patch` field (gate-on).
19    Hashline,
20}
21
22impl EditSchemaArm {
23    pub const fn as_str(self) -> &'static str {
24        match self {
25            Self::Legacy => "legacy",
26            Self::Hashline => "hashline",
27        }
28    }
29
30    pub const fn is_hashline(self) -> bool {
31        matches!(self, Self::Hashline)
32    }
33}
34
35/// Select the sole edit schema arm from effective mode.
36pub fn select_edit_schema(effective: bool) -> EditSchemaArm {
37    if effective {
38        EditSchemaArm::Hashline
39    } else {
40        EditSchemaArm::Legacy
41    }
42}
43
44/// Select from an optional captured binding (unregistered → legacy).
45pub fn select_edit_schema_for_capture(guard: Option<&BindingGuard>) -> EditSchemaArm {
46    select_edit_schema(effective_for_capture(guard))
47}
48
49/// Agent-visible description for the legacy edit tool.
50pub const LEGACY_EDIT_DESCRIPTION: &str = "Edit a file by finding and replacing text, or by targeting named symbols. To write or overwrite a whole file, use the `write` tool — `edit` requires an explicit edit mode and will not silently overwrite a file from `content` alone.";
51
52/// Agent-visible description for the hashline edit tool.
53pub const HASHLINE_EDIT_DESCRIPTION: &str = concat!(
54    "Apply a hashline patch. Arguments are exactly `{patch}` where `patch` is a non-empty string. ",
55    "Server-owned preview control is outside this schema.\n\n",
56    "Quick reference:\n",
57    "- Header: `[path#TAG]`; TAG is exactly four hexadecimal digits from a current tagged read. ",
58    "Read every addressed row and gap boundary; REM and MV require a whole-file tagged read. ",
59    "Re-read after an edit before chaining: an edit-response tag can retain only changed context.\n",
60    "- Same canonical path: multiple sections compose in patch order against pre-request coordinates.\n",
61    "- Addresses: `0` (BOF), `N` (one line), `N.=M` (range; `N..=M`/`N..M` also work), ",
62    "`<N`/`>N` (gap before/after), `N*`/`<N*`/`>N*` (block), and `$`/`$-K` (EOF-relative). ",
63    "A plain `N` PUT replaces; use `<N` or `>N` to insert.\n",
64    "- PUT text: `PUT <address>:` followed by one or more `+` body rows (`+` alone is blank). ",
65    "A final patch newline is allowed. PUT without `:` copies `@name` (or the anonymous register) and takes no body; names use `@` plus ASCII letters, digits, `_`, or `-`.\n",
66    "- CUT: `CUT <address> [@name]`. REM: bare `REM` only, removing the whole file. ",
67    "MV: `MV <destination>` (one whitespace-free path, optional matching quotes), once and after any line operations. ",
68    "`*** Begin Patch`/`*** End Patch` is an optional envelope.\n",
69    "- Only `read` (and accepted AFT `cat`/`head`/`tail` rewrites) mint hashline tags. ",
70    "`aft_zoom`, `aft_outline`, `grep`, `aft_search`, and conflict snippets do not. ",
71    "After navigation, call `read` on every file and range the patch addresses."
72);
73
74/// JSON Schema for the legacy edit arm (gate-off).
75pub fn legacy_edit_schema() -> Value {
76    json!({
77        "$schema": "https://json-schema.org/draft/2020-12/schema",
78        "type": "object",
79        "properties": {
80            "filePath": {
81                "description": "Path to the file to edit (absolute or relative to project root)",
82                "type": "string"
83            },
84            "symbol": {
85                "description": "Named symbol to replace (function, class, type)",
86                "type": "string"
87            },
88            "content": {
89                "description": "Replacement content for symbol mode. For whole-file writes, use the `write` tool.",
90                "type": "string"
91            },
92            "appendContent": {
93                "description": "Text to append to the end of path; creates the file if needed",
94                "type": "string"
95            },
96            "edits": {
97                "description": "Batch edits — non-empty array of { oldString, newString }, { oldString, newString, replaceAll: true }, or { startLine, endLine, content } objects",
98                "minItems": 1,
99                "type": "array",
100                "items": {
101                    "type": "object",
102                    "properties": {
103                        "oldString": {
104                            "description": "Text to find for a batch find/replace edit",
105                            "type": "string"
106                        },
107                        "newString": {
108                            "description": "Replacement text for a batch find/replace edit",
109                            "type": "string"
110                        },
111                        "replaceAll": {
112                            "description": "Replace every occurrence for this batch item",
113                            "type": "boolean"
114                        },
115                        "occurrence": {
116                            "description": "1-based occurrence for this batch item (1 = first match)",
117                            "type": "integer",
118                            "minimum": 1
119                        },
120                        "startLine": {
121                            "description": "1-based start line for a batch line-range edit",
122                            "type": "integer",
123                            "minimum": 1
124                        },
125                        "endLine": {
126                            "description": "1-based end line for a batch line-range edit",
127                            "type": "integer",
128                            "minimum": 1
129                        },
130                        "content": {
131                            "description": "Replacement text for a batch line-range edit",
132                            "type": "string"
133                        }
134                    }
135                }
136            }
137        },
138        "required": ["filePath"],
139        "description": LEGACY_EDIT_DESCRIPTION
140    })
141}
142
143/// JSON Schema for the hashline edit arm (gate-on).
144pub fn hashline_edit_schema() -> Value {
145    json!({
146        "$schema": "https://json-schema.org/draft/2020-12/schema",
147        "type": "object",
148        "additionalProperties": false,
149        "properties": {
150            "patch": {
151                "type": "string",
152                "minLength": 1,
153                "description": "Hashline patch text with one or more [path#TAG] sections and PUT/CUT/REM/MV operations"
154            }
155        },
156        "required": ["patch"],
157        "description": HASHLINE_EDIT_DESCRIPTION
158    })
159}
160
161/// Schema JSON for the selected arm.
162pub fn edit_schema_for(arm: EditSchemaArm) -> Value {
163    match arm {
164        EditSchemaArm::Legacy => legacy_edit_schema(),
165        EditSchemaArm::Hashline => hashline_edit_schema(),
166    }
167}
168
169/// Description string for the selected arm.
170pub fn edit_description_for(arm: EditSchemaArm) -> &'static str {
171    match arm {
172        EditSchemaArm::Legacy => LEGACY_EDIT_DESCRIPTION,
173        EditSchemaArm::Hashline => HASHLINE_EDIT_DESCRIPTION,
174    }
175}
176
177/// Native command name translation routes to when gate-on.
178pub const HASHLINE_EDIT_COMMAND: &str = "hashline_edit";
179
180/// Native command name for syntactic preflight (Phase-1 parse only).
181pub const HASHLINE_PREFLIGHT_COMMAND: &str = "hashline_preflight";
182
183/// Governed tool-manifest entry for one edit arm.
184#[derive(Clone, Debug, Eq, PartialEq)]
185pub struct GovernedEditManifestEntry {
186    pub name: &'static str,
187    pub arm: EditSchemaArm,
188    pub description: &'static str,
189    pub schema: Value,
190    pub supports_tool: bool,
191    pub hoisted: bool,
192    pub lane: &'static str,
193}
194
195/// Build the governed manifest entry hosts lock against for registration parity.
196pub fn governed_edit_manifest_entry(arm: EditSchemaArm) -> GovernedEditManifestEntry {
197    GovernedEditManifestEntry {
198        name: "edit",
199        arm,
200        description: edit_description_for(arm),
201        schema: edit_schema_for(arm),
202        supports_tool: true,
203        hoisted: true,
204        lane: "mutation",
205    }
206}
207
208/// Serialize both arms into the governed dual-mode artifact document.
209///
210/// Regeneration of committed `subc_tool_schemas.json` / plugin manifests must
211/// source the hashline arm exclusively from this document. The legacy arm is
212/// included so a single binary can publish either without rebuilding.
213pub fn regenerate_governed_edit_artifacts() -> Value {
214    let legacy = governed_edit_manifest_entry(EditSchemaArm::Legacy);
215    let hashline = governed_edit_manifest_entry(EditSchemaArm::Hashline);
216    json!({
217        "tool": "edit",
218        "dual_mode": true,
219        "selection": "session_effective_hashline",
220        "arms": {
221            "legacy": {
222                "arm": legacy.arm.as_str(),
223                "description": legacy.description,
224                "schema": legacy.schema,
225                "supports_tool": legacy.supports_tool,
226                "hoisted": legacy.hoisted,
227                "lane": legacy.lane,
228                "command": "edit",
229            },
230            "hashline": {
231                "arm": hashline.arm.as_str(),
232                "description": hashline.description,
233                "schema": hashline.schema,
234                "supports_tool": hashline.supports_tool,
235                "hoisted": hashline.hoisted,
236                "lane": hashline.lane,
237                "command": HASHLINE_EDIT_COMMAND,
238                "preflight_command": HASHLINE_PREFLIGHT_COMMAND,
239            }
240        },
241        "invariant": "a session never exposes both edit schemas",
242    })
243}
244
245/// Gate-on translation: accept only `{patch}` and route to `hashline_edit`.
246///
247/// Must run before shared path-argument normalization and every legacy edit-shape
248/// check. Legacy keys are never ignored or routed to a legacy handler.
249pub fn translate_gate_on_edit(
250    arguments: &Value,
251) -> Result<GateOnTranslation, crate::hashline::syntax::HashlineRejection> {
252    let request = crate::hashline::syntax::validate_raw_arguments(arguments)?;
253    Ok(GateOnTranslation {
254        command: HASHLINE_EDIT_COMMAND,
255        patch: request.patch,
256    })
257}
258
259/// Successful gate-on translation product.
260#[derive(Clone, Debug, Eq, PartialEq)]
261pub struct GateOnTranslation {
262    pub command: &'static str,
263    pub patch: String,
264}
265
266impl GateOnTranslation {
267    pub fn to_native_args(&self) -> Value {
268        json!({ "patch": self.patch })
269    }
270}
271
272/// Dispatch edit translation using the captured binding's effective mode.
273///
274/// - Effective on → hashline arm only (`hashline_edit`).
275/// - Effective off / unregistered → caller keeps the legacy translation path;
276///   this returns `None` so existing gate-off goldens stay byte-identical.
277pub fn translate_edit_for_session(
278    guard: Option<&BindingGuard>,
279    arguments: &Value,
280) -> Result<Option<GateOnTranslation>, crate::hashline::syntax::HashlineRejection> {
281    if !effective_for_capture(guard) {
282        return Ok(None);
283    }
284    Ok(Some(translate_gate_on_edit(arguments)?))
285}