hakari/
cargo_toml.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use atomicwrites::{AtomicFile, OverwriteBehavior};
5use camino::{Utf8Path, Utf8PathBuf};
6use diffy::Patch;
7use std::{error, fmt, io};
8
9/// Support for maintaining `Cargo.toml` files that unify features in a workspace.
10///
11/// This struct maintains a context around a `Cargo.toml` file. It provides facilities for diffing
12/// the contents of the file, and for writing out new contents.
13///
14/// # Structure of the Cargo.toml file
15///
16/// The `Cargo.toml` file is treated as partially generated. It is expected to have a section marked
17/// off as, for example:
18///
19/// ```toml
20/// [package]
21/// ...
22///
23/// ### BEGIN HAKARI SECTION
24/// [dependencies]
25/// ...
26///
27/// [build-dependencies]
28/// ...
29///
30/// [dev-dependencies]
31/// ...
32/// ### END HAKARI SECTION
33/// ```
34///
35/// The part of the `Cargo.toml` file between the `BEGIN HAKARI SECTION` and `END HAKARI SECTION`
36/// lines is managed by this struct, and changes to it may not be preserved. The part of the file
37/// outside this section can be edited and its contents will be preserved.
38///
39/// # Setting up a new package
40///
41/// For Hakari to manage a package, a bit of initial prep work must be done:
42///
43/// 1. Add a new library package in a desired location within your workspace, for example: `cargo
44///    new --lib hakari-package`.
45/// 2. Copy and paste the following lines of code to the end of the package's `Cargo.toml` file. Be
46///    sure to put in a trailing newline.
47///
48///     ```toml
49///     ### BEGIN HAKARI SECTION
50///
51///     ### END HAKARI SECTION
52///
53///     ```
54///
55/// 3. Add an empty `build.rs` file (the exact contents don't matter, but the presence of this file
56///    makes build dependencies work properly).
57///
58///     ```
59///     fn main() {}
60///     ```
61#[derive(Clone, Debug)]
62pub struct HakariCargoToml {
63    toml_path: Utf8PathBuf,
64    contents: String,
65    // Start and end offsets for the section to replace.
66    start_offset: usize,
67    end_offset: usize,
68}
69
70impl HakariCargoToml {
71    /// The string `"\n### BEGIN HAKARI SECTION\n"`. This string marks the beginning of the
72    /// generated section.
73    pub const BEGIN_SECTION: &'static str = "\n### BEGIN HAKARI SECTION\n";
74
75    /// The string `"\n### END HAKARI SECTION\n"`. This string marks the end of the generated
76    /// section.
77    pub const END_SECTION: &'static str = "\n### END HAKARI SECTION\n";
78
79    /// Creates a new instance of `HakariCargoToml` with the `Cargo.toml` located at the given path.
80    /// Reads the contents of the file off of disk.
81    ///
82    /// If the path is relative, it is evaluated with respect to the current directory.
83    ///
84    /// Returns an error if the file couldn't be read (other than if the file wasn't found, which
85    /// is a case handled by this struct).
86    pub fn new(toml_path: impl Into<Utf8PathBuf>) -> Result<Self, CargoTomlError> {
87        let toml_path = toml_path.into();
88
89        let contents = match std::fs::read_to_string(&toml_path) {
90            Ok(contents) => contents,
91            Err(error) => return Err(CargoTomlError::Io { toml_path, error }),
92        };
93
94        Self::new_in_memory(toml_path, contents)
95    }
96
97    /// Creates a new instance of `HakariCargoToml` at the given workspace root and crate
98    /// directory. Reads the contents of the file off of disk.
99    ///
100    /// This is a convenience method around appending `crate_dir` and `Cargo.toml` to
101    /// `workspace_root`.
102    ///
103    /// If the path is relative, it is evaluated with respect to the current directory.
104    pub fn new_relative(
105        workspace_root: impl Into<Utf8PathBuf>,
106        crate_dir: impl AsRef<Utf8Path>,
107    ) -> Result<Self, CargoTomlError> {
108        let mut toml_path = workspace_root.into();
109        toml_path.push(crate_dir);
110        toml_path.push("Cargo.toml");
111
112        Self::new(toml_path)
113    }
114
115    /// Creates a new instance of `HakariCargoToml` with the given path with the given contents as
116    /// read from disk.
117    ///
118    /// This may be useful for test scenarios.
119    pub fn new_in_memory(
120        toml_path: impl Into<Utf8PathBuf>,
121        contents: String,
122    ) -> Result<Self, CargoTomlError> {
123        let toml_path = toml_path.into();
124
125        // Look for the start and end offsets.
126        let start_offset = match contents.find(Self::BEGIN_SECTION) {
127            Some(offset) => {
128                // Add the length of BEGIN_SECTION so that anything after that is replaced.
129                offset + Self::BEGIN_SECTION.len()
130            }
131            None => return Err(CargoTomlError::GeneratedSectionNotFound { toml_path }),
132        };
133
134        // Start searching from 1 before the end of the BEGIN text so that we find the END text
135        // even if there's nothing in between.
136        let end_offset = match contents[(start_offset - 1)..].find(Self::END_SECTION) {
137            Some(offset) => start_offset + offset,
138            None => return Err(CargoTomlError::GeneratedSectionNotFound { toml_path }),
139        };
140
141        Ok(Self {
142            toml_path,
143            contents,
144            start_offset,
145            end_offset,
146        })
147    }
148
149    /// Returns the toml path provided at construction time.
150    pub fn toml_path(&self) -> &Utf8Path {
151        &self.toml_path
152    }
153
154    /// Returns the contents of the file on disk as read at construction time.
155    pub fn contents(&self) -> &str {
156        &self.contents
157    }
158
159    /// Returns the start and end offsets of the part of the file treated as generated.
160    pub fn generated_offsets(&self) -> (usize, usize) {
161        (self.start_offset, self.end_offset)
162    }
163
164    /// Returns the part of the file that is treated as generated.
165    ///
166    /// This part of the file will be replaced on write.
167    pub fn generated_contents(&self) -> &str {
168        &self.contents[self.start_offset..self.end_offset]
169    }
170
171    /// Returns true if the contents on disk are different from the provided TOML output.
172    pub fn is_changed(&self, toml: &str) -> bool {
173        self.generated_contents() != toml
174    }
175
176    /// Computes the diff between the contents on disk and the provided TOML output.
177    ///
178    /// This returns a `diffy::Patch`, which can be formatted through methods provided by `diffy`.
179    /// `diffy` is re-exported at the top level of this crate.
180    ///
181    /// # Examples
182    ///
183    /// TODO
184    pub fn diff_toml<'a>(&'a self, toml: &'a str) -> Patch<'a, str> {
185        diffy::create_patch(self.generated_contents(), toml)
186    }
187
188    /// Writes out the provided TOML to the generated section of the file. The rest of the file is
189    /// left unmodified.
190    ///
191    /// `self` is consumed because the contents of the file are now assumed to be invalid.
192    ///
193    /// Returns true if the contents were different and the file was written out, false if the
194    /// contents were the same and the file was *not* written out, and an error if there was an
195    /// issue while writing the file out.
196    pub fn write_to_file(self, toml: &str) -> Result<bool, CargoTomlError> {
197        if !self.is_changed(toml) {
198            // Don't write out the file if it hasn't changed to avoid bumping mtimes.
199            return Ok(false);
200        }
201
202        let try_block = || {
203            let atomic_file = AtomicFile::new(&self.toml_path, OverwriteBehavior::AllowOverwrite);
204            atomic_file.write(|f| self.write(toml, f))
205        };
206
207        match (try_block)() {
208            Ok(()) => Ok(true),
209            Err(atomicwrites::Error::Internal(error)) | Err(atomicwrites::Error::User(error)) => {
210                Err(CargoTomlError::Io {
211                    toml_path: self.toml_path,
212                    error,
213                })
214            }
215        }
216    }
217
218    /// Writes out the full contents, including the provided TOML, to the given writer.
219    pub fn write(&self, toml: &str, mut out: impl io::Write) -> io::Result<()> {
220        write!(out, "{}", &self.contents[..self.start_offset])?;
221        write!(out, "{}", toml)?;
222        write!(out, "{}", &self.contents[self.end_offset..])
223    }
224
225    /// Writes out the full contents, including the provided TOML, to the given `fmt::Write`
226    /// instance.
227    ///
228    /// `std::io::Write` expects bytes to be written to it, so using it with a `&mut String` is
229    /// inconvenient. This alternative is more convenient, and also works for `fmt::Formatter`
230    /// instances.
231    pub fn write_to_fmt(&self, toml: &str, mut out: impl fmt::Write) -> fmt::Result {
232        // No alternative to copy-pasting :(
233        write!(out, "{}", &self.contents[..self.start_offset])?;
234        write!(out, "{}", toml)?;
235        write!(out, "{}", &self.contents[self.end_offset..])
236    }
237}
238
239/// An error that can occur while reading or writing a `Cargo.toml` file.
240#[derive(Debug)]
241#[non_exhaustive]
242pub enum CargoTomlError {
243    /// The contents of the `Cargo.toml` file could not be read or written.
244    Io {
245        /// The path that was attempted to be read.
246        toml_path: Utf8PathBuf,
247
248        /// The error that occurred.
249        error: io::Error,
250    },
251
252    /// The `Cargo.toml` was successfully read but `### BEGIN HAKARI SECTION` and
253    /// `### END HAKARI SECTION` couldn't be found.
254    GeneratedSectionNotFound {
255        /// The path that was read.
256        toml_path: Utf8PathBuf,
257    },
258}
259
260impl fmt::Display for CargoTomlError {
261    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
262        match self {
263            CargoTomlError::Io { toml_path, .. } => {
264                write!(f, "error while reading path '{}'", toml_path)
265            }
266            CargoTomlError::GeneratedSectionNotFound { toml_path, .. } => {
267                write!(
268                    f,
269                    "in '{}', unable to find\n\
270                ### BEGIN HAKARI SECTION\n\
271                ...\n\
272                ### END HAKARI SECTION",
273                    toml_path
274                )
275            }
276        }
277    }
278}
279
280impl error::Error for CargoTomlError {
281    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
282        match self {
283            CargoTomlError::Io { error, .. } => Some(error),
284            CargoTomlError::GeneratedSectionNotFound { .. } => None,
285        }
286    }
287}