Skip to main content

feature_manifest/
docs_io.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5
6/// Marker pair used for injecting generated content into an existing file.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct InjectionMarkers {
9    /// Marker that starts the generated region.
10    pub start: String,
11    /// Marker that ends the generated region.
12    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/// Result of a marker-based document injection.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct InjectionReport {
27    /// Path that was updated.
28    pub path: PathBuf,
29}
30
31/// Marker discovery details for a generated documentation region.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct MarkerReport {
34    /// Path that was inspected.
35    pub path: PathBuf,
36    /// Number of start markers found.
37    pub start_count: usize,
38    /// Number of end markers found.
39    pub end_count: usize,
40    /// Whether the first start marker appears before the first end marker.
41    pub ordered: bool,
42}
43
44impl MarkerReport {
45    /// Returns `true` when exactly one ordered marker pair exists.
46    pub fn ready(&self) -> bool {
47        self.start_count == 1 && self.end_count == 1 && self.ordered
48    }
49}
50
51/// Writes generated content to a file, ensuring a trailing newline.
52pub 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
59/// Returns `true` when a generated file already matches the expected contents.
60pub 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
68/// Returns marker status for a document without changing it.
69pub 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
76/// Ensures a document contains a marker pair, appending it when absent.
77pub 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
128/// Returns `true` when the region between markers already matches the expected contents.
129pub 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
145/// Replaces the region between two markers, preserving the markers themselves.
146pub 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}