1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct InjectionMarkers {
9 pub start: String,
11 pub end: String,
13}
14
15impl Default for InjectionMarkers {
16 fn default() -> Self {
17 Self {
18 start: "<!-- feature-manifest:start -->".to_owned(),
19 end: "<!-- feature-manifest:end -->".to_owned(),
20 }
21 }
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct InjectionReport {
27 pub path: PathBuf,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct MarkerReport {
34 pub path: PathBuf,
36 pub start_count: usize,
38 pub end_count: usize,
40 pub ordered: bool,
42}
43
44impl MarkerReport {
45 pub fn ready(&self) -> bool {
47 self.start_count == 1 && self.end_count == 1 && self.ordered
48 }
49}
50
51pub fn write_output(path: impl AsRef<Path>, contents: &str) -> Result<()> {
53 let path = path.as_ref();
54 let normalized = ensure_trailing_newline(contents);
55 fs::write(path, normalized)
56 .with_context(|| format!("failed to write generated output to `{}`", path.display()))
57}
58
59pub fn output_matches(path: impl AsRef<Path>, contents: &str) -> Result<bool> {
61 let path = path.as_ref();
62 let existing = fs::read_to_string(path)
63 .with_context(|| format!("failed to read generated output `{}`", path.display()))?;
64 Ok(normalize_line_endings(&existing)
65 == normalize_line_endings(&ensure_trailing_newline(contents)))
66}
67
68pub fn inspect_markers(path: impl AsRef<Path>, markers: &InjectionMarkers) -> Result<MarkerReport> {
70 let path = path.as_ref();
71 let existing = fs::read_to_string(path)
72 .with_context(|| format!("failed to read document `{}`", path.display()))?;
73 Ok(inspect_marker_source(path, &existing, markers))
74}
75
76pub fn ensure_injection_markers(
78 path: impl AsRef<Path>,
79 markers: &InjectionMarkers,
80 heading: &str,
81) -> Result<MarkerReport> {
82 let path = path.as_ref();
83 let existing = match fs::read_to_string(path) {
84 Ok(existing) => existing,
85 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
86 Err(error) => {
87 return Err(error)
88 .with_context(|| format!("failed to read document `{}`", path.display()));
89 }
90 };
91
92 let report = inspect_marker_source(path, &existing, markers);
93 if report.ready() {
94 return Ok(report);
95 }
96
97 if report.start_count != 0 || report.end_count != 0 {
98 bail!(
99 "document `{}` has partial or duplicated feature-manifest markers",
100 path.display()
101 );
102 }
103
104 if let Some(parent) = path.parent() {
105 fs::create_dir_all(parent).with_context(|| {
106 format!(
107 "failed to create parent directory for document `{}`",
108 path.display()
109 )
110 })?;
111 }
112
113 let mut updated = existing.trim_end().to_owned();
114 if !updated.is_empty() {
115 updated.push_str("\n\n");
116 }
117 updated.push_str(&format!(
118 "{heading}\n\n{}\n{}\n",
119 markers.start, markers.end
120 ));
121
122 fs::write(path, updated)
123 .with_context(|| format!("failed to write document `{}`", path.display()))?;
124
125 inspect_markers(path, markers)
126}
127
128pub fn injected_region_matches(
130 path: impl AsRef<Path>,
131 contents: &str,
132 markers: &InjectionMarkers,
133) -> Result<bool> {
134 let path = path.as_ref();
135 let existing = fs::read_to_string(path).with_context(|| {
136 format!(
137 "failed to read document `{}` for injection check",
138 path.display()
139 )
140 })?;
141 let region = marker_region(path, &existing, markers)?;
142 Ok(normalize_line_endings(region.trim()) == normalize_line_endings(contents.trim()))
143}
144
145pub fn inject_between_markers(
147 path: impl AsRef<Path>,
148 contents: &str,
149 markers: &InjectionMarkers,
150) -> Result<InjectionReport> {
151 let path = path.as_ref();
152 let existing = fs::read_to_string(path)
153 .with_context(|| format!("failed to read document `{}` for injection", path.display()))?;
154
155 let (start_index, end_index) = marker_bounds(path, &existing, markers)?;
156
157 let before = &existing[..start_index + markers.start.len()];
158 let after = &existing[end_index..];
159 let injected = format!(
160 "{before}\n\n{}\n{after}",
161 ensure_trailing_newline(contents).trim_end()
162 );
163
164 fs::write(path, injected)
165 .with_context(|| format!("failed to write injected document `{}`", path.display()))?;
166
167 Ok(InjectionReport {
168 path: path.to_path_buf(),
169 })
170}
171
172fn inspect_marker_source(path: &Path, source: &str, markers: &InjectionMarkers) -> MarkerReport {
173 let start_positions = marker_positions(source, &markers.start);
174 let end_positions = marker_positions(source, &markers.end);
175 let ordered = match (start_positions.first(), end_positions.first()) {
176 (Some(start), Some(end)) => start < end,
177 _ => false,
178 };
179
180 MarkerReport {
181 path: path.to_path_buf(),
182 start_count: start_positions.len(),
183 end_count: end_positions.len(),
184 ordered,
185 }
186}
187
188fn marker_region<'a>(path: &Path, source: &'a str, markers: &InjectionMarkers) -> Result<&'a str> {
189 let (start_index, end_index) = marker_bounds(path, source, markers)?;
190 Ok(&source[start_index + markers.start.len()..end_index])
191}
192
193fn marker_bounds(path: &Path, source: &str, markers: &InjectionMarkers) -> Result<(usize, usize)> {
194 let report = inspect_marker_source(path, source, markers);
195 if report.start_count == 0 {
196 bail!(
197 "start marker `{}` was not found in `{}`",
198 markers.start,
199 path.display()
200 );
201 }
202 if report.end_count == 0 {
203 bail!(
204 "end marker `{}` was not found in `{}`",
205 markers.end,
206 path.display()
207 );
208 }
209 if report.start_count > 1 || report.end_count > 1 {
210 bail!(
211 "document `{}` has duplicate feature-manifest markers",
212 path.display()
213 );
214 }
215 if !report.ordered {
216 bail!(
217 "marker order is invalid in `{}`; the end marker appears before the start marker",
218 path.display()
219 );
220 }
221
222 Ok((
223 source
224 .find(&markers.start)
225 .expect("start marker was counted"),
226 source.find(&markers.end).expect("end marker was counted"),
227 ))
228}
229
230fn marker_positions(source: &str, marker: &str) -> Vec<usize> {
231 source
232 .match_indices(marker)
233 .map(|(index, _)| index)
234 .collect()
235}
236
237fn ensure_trailing_newline(contents: &str) -> String {
238 if contents.ends_with('\n') {
239 contents.to_owned()
240 } else {
241 format!("{contents}\n")
242 }
243}
244
245fn normalize_line_endings(contents: &str) -> String {
246 contents.replace("\r\n", "\n").replace('\r', "\n")
247}