Skip to main content

aft/hashline/integration/
preflight.rs

1//! Syntactic preflight and permission orchestration.
2//!
3//! Hosts never parse the hashline grammar. They call `hashline_preflight`
4//! (Phase-1 parse only, zero mutation) to obtain affected paths and a per-file
5//! operation summary, run permission / external-dir checks on that result, then
6//! preview, then apply — mirroring the shipped `apply_patch` affected_paths flow.
7
8use std::collections::BTreeSet;
9use std::path::{Path, PathBuf};
10
11use serde_json::{json, Value};
12
13use crate::hashline::syntax::{
14    parse_hashline_patch, validate_raw_arguments, HashlineRejection, Operation, Patch,
15};
16
17/// One operation row inside a preflight file summary.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct PreflightOperation {
20    pub kind: &'static str,
21    /// Destination path spelling for MV; otherwise unused.
22    pub destination: Option<String>,
23}
24
25/// Per-file summary returned by syntactic preflight.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct PreflightFileSummary {
28    pub requested_path: String,
29    pub tag: String,
30    pub operations: Vec<PreflightOperation>,
31}
32
33/// Mutation-free preflight product for host permission orchestration.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct PreflightResult {
36    pub files: Vec<PreflightFileSummary>,
37    /// Absolute (or host-canonicalized) paths in patch order, de-duplicated.
38    pub affected_paths: Vec<String>,
39    /// Project-relative spellings parallel to `affected_paths` when a root is known.
40    pub affected_rel_paths: Vec<String>,
41    /// MV destinations included so permission checks cover both ends of a move.
42    pub mv_destinations: Vec<String>,
43}
44
45impl PreflightResult {
46    /// Patterns suitable for host `askEditPermission` / external-dir checks.
47    pub fn permission_patterns(&self) -> Vec<String> {
48        let mut patterns = BTreeSet::new();
49        for path in self
50            .affected_paths
51            .iter()
52            .chain(self.mv_destinations.iter())
53        {
54            patterns.insert(path.clone());
55        }
56        for path in &self.affected_rel_paths {
57            patterns.insert(path.clone());
58        }
59        patterns.into_iter().collect()
60    }
61
62    /// JSON shape hosts consume (mirrors apply_patch preview path fields).
63    pub fn to_json(&self) -> Value {
64        json!({
65            "preview": false,
66            "preflight": true,
67            "affected_paths": self.affected_paths,
68            "affected_rel_paths": self.affected_rel_paths,
69            "mv_destinations": self.mv_destinations,
70            "files": self.files.iter().map(|file| {
71                json!({
72                    "requested_path": file.requested_path,
73                    "tag": file.tag,
74                    "operations": file.operations.iter().map(|op| {
75                        let mut row = json!({ "kind": op.kind });
76                        if let Some(dest) = &op.destination {
77                            row["destination"] = json!(dest);
78                        }
79                        row
80                    }).collect::<Vec<_>>(),
81                })
82            }).collect::<Vec<_>>(),
83        })
84    }
85}
86
87/// Run syntactic preflight from raw tool arguments (`{patch}` only).
88pub fn hashline_preflight_from_args(
89    arguments: &Value,
90    project_root: Option<&Path>,
91) -> Result<PreflightResult, HashlineRejection> {
92    let request = validate_raw_arguments(arguments)?;
93    hashline_preflight(&request.patch, project_root)
94}
95
96/// Phase-1 parse only: no snapshot lookup, no baseline load, no mutation.
97pub fn hashline_preflight(
98    patch_text: &str,
99    project_root: Option<&Path>,
100) -> Result<PreflightResult, HashlineRejection> {
101    let patch = parse_hashline_patch(patch_text)?;
102    summarize_patch(&patch, project_root)
103}
104
105fn summarize_patch(
106    patch: &Patch,
107    project_root: Option<&Path>,
108) -> Result<PreflightResult, HashlineRejection> {
109    if patch.is_empty() {
110        return Err(HashlineRejection::parse(
111            "preflight requires at least one patch section",
112        ));
113    }
114
115    let mut files = Vec::with_capacity(patch.sections.len());
116    let mut ordered_paths: Vec<String> = Vec::new();
117    let mut seen_paths = BTreeSet::new();
118    let mut mv_destinations = Vec::new();
119    let mut seen_dests = BTreeSet::new();
120
121    for section in &patch.sections {
122        let requested = section.header.requested_path.clone();
123        if seen_paths.insert(requested.clone()) {
124            ordered_paths.push(requested.clone());
125        }
126
127        let mut operations = Vec::with_capacity(section.operations.len());
128        for operation in &section.operations {
129            let (kind, destination) = match operation {
130                Operation::Put(_) => ("PUT", None),
131                Operation::Cut(_) => ("CUT", None),
132                Operation::Rem(_) => ("REM", None),
133                Operation::Mv(mv) => {
134                    if seen_dests.insert(mv.destination.clone()) {
135                        mv_destinations.push(mv.destination.clone());
136                    }
137                    ("MV", Some(mv.destination.clone()))
138                }
139            };
140            operations.push(PreflightOperation { kind, destination });
141        }
142
143        files.push(PreflightFileSummary {
144            requested_path: requested,
145            tag: section.header.tag.clone(),
146            operations,
147        });
148    }
149
150    let affected_paths = ordered_paths
151        .iter()
152        .map(|p| absolutize(project_root, p))
153        .collect::<Vec<_>>();
154    let affected_rel_paths = ordered_paths
155        .iter()
156        .map(|p| relativize(project_root, p))
157        .collect::<Vec<_>>();
158    let mv_destinations = mv_destinations
159        .into_iter()
160        .map(|p| absolutize(project_root, &p))
161        .collect();
162
163    Ok(PreflightResult {
164        files,
165        affected_paths,
166        affected_rel_paths,
167        mv_destinations,
168    })
169}
170
171fn absolutize(project_root: Option<&Path>, requested: &str) -> String {
172    let path = PathBuf::from(requested);
173    if path.is_absolute() {
174        return path.to_string_lossy().into_owned();
175    }
176    match project_root {
177        Some(root) => root.join(path).to_string_lossy().into_owned(),
178        None => requested.to_string(),
179    }
180}
181
182fn relativize(project_root: Option<&Path>, requested: &str) -> String {
183    let path = PathBuf::from(requested);
184    if let Some(root) = project_root {
185        if path.is_absolute() {
186            if let Ok(rel) = path.strip_prefix(root) {
187                return rel.to_string_lossy().replace('\\', "/");
188            }
189        }
190    }
191    requested.replace('\\', "/")
192}
193
194/// Host permission orchestration plan: preflight → permission → preview → apply.
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
196pub enum PermissionPhase {
197    Preflight,
198    PermissionCheck,
199    Preview,
200    Apply,
201}
202
203impl PermissionPhase {
204    pub const fn as_str(self) -> &'static str {
205        match self {
206            Self::Preflight => "preflight",
207            Self::PermissionCheck => "permission_check",
208            Self::Preview => "preview",
209            Self::Apply => "apply",
210        }
211    }
212}
213
214/// Ordered host flow for a hashline edit under permission-gated hosts.
215pub const PERMISSION_ORCHESTRATION_ORDER: &[PermissionPhase] = &[
216    PermissionPhase::Preflight,
217    PermissionPhase::PermissionCheck,
218    PermissionPhase::Preview,
219    PermissionPhase::Apply,
220];
221
222/// Build the permission-metadata object hosts attach when asking the user.
223pub fn permission_metadata(preflight: &PreflightResult) -> Value {
224    json!({
225        "tool": "edit",
226        "surface": "hashline",
227        "affected_paths": preflight.affected_paths,
228        "affected_rel_paths": preflight.affected_rel_paths,
229        "mv_destinations": preflight.mv_destinations,
230        "file_count": preflight.files.len(),
231    })
232}