aft/hashline/integration/
preflight.rs1use 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#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct PreflightOperation {
20 pub kind: &'static str,
21 pub destination: Option<String>,
23}
24
25#[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#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct PreflightResult {
36 pub files: Vec<PreflightFileSummary>,
37 pub affected_paths: Vec<String>,
39 pub affected_rel_paths: Vec<String>,
41 pub mv_destinations: Vec<String>,
43}
44
45impl PreflightResult {
46 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 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
87pub 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
96pub 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 §ion.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#[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
214pub const PERMISSION_ORCHESTRATION_ORDER: &[PermissionPhase] = &[
216 PermissionPhase::Preflight,
217 PermissionPhase::PermissionCheck,
218 PermissionPhase::Preview,
219 PermissionPhase::Apply,
220];
221
222pub 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}