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 = "Apply a hashline patch. Arguments are exactly `{patch}` where `patch` is a non-empty string of one or more `[path#TAG]` sections with PUT/CUT/REM/MV operations. Paths and tags come from section headers; obtain tags from tagged reads. Server-owned preview control is outside this schema.";
54
55/// JSON Schema for the legacy edit arm (gate-off).
56pub fn legacy_edit_schema() -> Value {
57    json!({
58        "$schema": "https://json-schema.org/draft/2020-12/schema",
59        "type": "object",
60        "properties": {
61            "filePath": {
62                "description": "Path to the file to edit (absolute or relative to project root)",
63                "type": "string"
64            },
65            "symbol": {
66                "description": "Named symbol to replace (function, class, type)",
67                "type": "string"
68            },
69            "content": {
70                "description": "Replacement content for symbol mode. For whole-file writes, use the `write` tool.",
71                "type": "string"
72            },
73            "appendContent": {
74                "description": "Text to append to the end of path; creates the file if needed",
75                "type": "string"
76            },
77            "edits": {
78                "description": "Batch edits — non-empty array of { oldString, newString }, { oldString, newString, replaceAll: true }, or { startLine, endLine, content } objects",
79                "minItems": 1,
80                "type": "array",
81                "items": {
82                    "type": "object",
83                    "properties": {
84                        "oldString": {
85                            "description": "Text to find for a batch find/replace edit",
86                            "type": "string"
87                        },
88                        "newString": {
89                            "description": "Replacement text for a batch find/replace edit",
90                            "type": "string"
91                        },
92                        "replaceAll": {
93                            "description": "Replace every occurrence for this batch item",
94                            "type": "boolean"
95                        },
96                        "occurrence": {
97                            "description": "1-based occurrence for this batch item (1 = first match)",
98                            "type": "integer",
99                            "minimum": 1
100                        },
101                        "startLine": {
102                            "description": "1-based start line for a batch line-range edit",
103                            "type": "integer",
104                            "minimum": 1
105                        },
106                        "endLine": {
107                            "description": "1-based end line for a batch line-range edit",
108                            "type": "integer",
109                            "minimum": 1
110                        },
111                        "content": {
112                            "description": "Replacement text for a batch line-range edit",
113                            "type": "string"
114                        }
115                    }
116                }
117            }
118        },
119        "required": ["filePath"],
120        "description": LEGACY_EDIT_DESCRIPTION
121    })
122}
123
124/// JSON Schema for the hashline edit arm (gate-on).
125pub fn hashline_edit_schema() -> Value {
126    json!({
127        "$schema": "https://json-schema.org/draft/2020-12/schema",
128        "type": "object",
129        "additionalProperties": false,
130        "properties": {
131            "patch": {
132                "type": "string",
133                "minLength": 1,
134                "description": "Hashline patch text with one or more [path#TAG] sections and PUT/CUT/REM/MV operations"
135            }
136        },
137        "required": ["patch"],
138        "description": HASHLINE_EDIT_DESCRIPTION
139    })
140}
141
142/// Schema JSON for the selected arm.
143pub fn edit_schema_for(arm: EditSchemaArm) -> Value {
144    match arm {
145        EditSchemaArm::Legacy => legacy_edit_schema(),
146        EditSchemaArm::Hashline => hashline_edit_schema(),
147    }
148}
149
150/// Description string for the selected arm.
151pub fn edit_description_for(arm: EditSchemaArm) -> &'static str {
152    match arm {
153        EditSchemaArm::Legacy => LEGACY_EDIT_DESCRIPTION,
154        EditSchemaArm::Hashline => HASHLINE_EDIT_DESCRIPTION,
155    }
156}
157
158/// Native command name translation routes to when gate-on.
159pub const HASHLINE_EDIT_COMMAND: &str = "hashline_edit";
160
161/// Native command name for syntactic preflight (Phase-1 parse only).
162pub const HASHLINE_PREFLIGHT_COMMAND: &str = "hashline_preflight";
163
164/// Governed tool-manifest entry for one edit arm.
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct GovernedEditManifestEntry {
167    pub name: &'static str,
168    pub arm: EditSchemaArm,
169    pub description: &'static str,
170    pub schema: Value,
171    pub supports_tool: bool,
172    pub hoisted: bool,
173    pub lane: &'static str,
174}
175
176/// Build the governed manifest entry hosts lock against for registration parity.
177pub fn governed_edit_manifest_entry(arm: EditSchemaArm) -> GovernedEditManifestEntry {
178    GovernedEditManifestEntry {
179        name: "edit",
180        arm,
181        description: edit_description_for(arm),
182        schema: edit_schema_for(arm),
183        supports_tool: true,
184        hoisted: true,
185        lane: "mutation",
186    }
187}
188
189/// Serialize both arms into the governed dual-mode artifact document.
190///
191/// Regeneration of committed `subc_tool_schemas.json` / plugin manifests must
192/// source the hashline arm exclusively from this document. The legacy arm is
193/// included so a single binary can publish either without rebuilding.
194pub fn regenerate_governed_edit_artifacts() -> Value {
195    let legacy = governed_edit_manifest_entry(EditSchemaArm::Legacy);
196    let hashline = governed_edit_manifest_entry(EditSchemaArm::Hashline);
197    json!({
198        "tool": "edit",
199        "dual_mode": true,
200        "selection": "session_effective_hashline",
201        "arms": {
202            "legacy": {
203                "arm": legacy.arm.as_str(),
204                "description": legacy.description,
205                "schema": legacy.schema,
206                "supports_tool": legacy.supports_tool,
207                "hoisted": legacy.hoisted,
208                "lane": legacy.lane,
209                "command": "edit",
210            },
211            "hashline": {
212                "arm": hashline.arm.as_str(),
213                "description": hashline.description,
214                "schema": hashline.schema,
215                "supports_tool": hashline.supports_tool,
216                "hoisted": hashline.hoisted,
217                "lane": hashline.lane,
218                "command": HASHLINE_EDIT_COMMAND,
219                "preflight_command": HASHLINE_PREFLIGHT_COMMAND,
220            }
221        },
222        "invariant": "a session never exposes both edit schemas",
223    })
224}
225
226/// Gate-on translation: accept only `{patch}` and route to `hashline_edit`.
227///
228/// Must run before shared path-argument normalization and every legacy edit-shape
229/// check. Legacy keys are never ignored or routed to a legacy handler.
230pub fn translate_gate_on_edit(
231    arguments: &Value,
232) -> Result<GateOnTranslation, crate::hashline::syntax::HashlineRejection> {
233    let request = crate::hashline::syntax::validate_raw_arguments(arguments)?;
234    Ok(GateOnTranslation {
235        command: HASHLINE_EDIT_COMMAND,
236        patch: request.patch,
237    })
238}
239
240/// Successful gate-on translation product.
241#[derive(Clone, Debug, Eq, PartialEq)]
242pub struct GateOnTranslation {
243    pub command: &'static str,
244    pub patch: String,
245}
246
247impl GateOnTranslation {
248    pub fn to_native_args(&self) -> Value {
249        json!({ "patch": self.patch })
250    }
251}
252
253/// Dispatch edit translation using the captured binding's effective mode.
254///
255/// - Effective on → hashline arm only (`hashline_edit`).
256/// - Effective off / unregistered → caller keeps the legacy translation path;
257///   this returns `None` so existing gate-off goldens stay byte-identical.
258pub fn translate_edit_for_session(
259    guard: Option<&BindingGuard>,
260    arguments: &Value,
261) -> Result<Option<GateOnTranslation>, crate::hashline::syntax::HashlineRejection> {
262    if !effective_for_capture(guard) {
263        return Ok(None);
264    }
265    Ok(Some(translate_gate_on_edit(arguments)?))
266}