Skip to main content

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///     ```rust,ignore
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. A patch with no hunks means the
180    /// generated section is already up to date.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use hakari::{HakariCargoToml, diffy::PatchFormatter};
186    ///
187    /// // The Cargo.toml as it currently exists on disk, with the generated section
188    /// // delimited by `BEGIN_SECTION` and `END_SECTION`.
189    /// let mut contents =
190    ///     String::from("[package]\nname = \"workspace-hack\"\nversion = \"0.1.0\"\n");
191    /// contents.push_str(HakariCargoToml::BEGIN_SECTION);
192    /// contents.push_str(
193    ///     "[dependencies]\nserde = { version = \"1.0.228\", features = [\"derive\"] }\n",
194    /// );
195    /// contents.push_str(HakariCargoToml::END_SECTION);
196    /// let cargo_toml = HakariCargoToml::new_in_memory("workspace-hack/Cargo.toml", contents)?;
197    ///
198    /// // The newly generated section, typically obtained from `Hakari::to_toml_string`.
199    /// let new_toml = "\
200    /// [dependencies]
201    /// serde = { version = \"1.0.229\", features = [\"derive\"] }
202    /// ";
203    ///
204    /// let patch = cargo_toml.diff_toml(new_toml);
205    /// assert_eq!(patch.hunks().len(), 1, "one changed line produces one hunk");
206    ///
207    /// let formatted = PatchFormatter::new().fmt_patch(&patch).to_string();
208    /// assert!(formatted.contains("-serde = { version = \"1.0.228\""));
209    /// assert!(formatted.contains("+serde = { version = \"1.0.229\""));
210    ///
211    /// // Diffing against the current contents produces an empty patch.
212    /// let unchanged = cargo_toml.diff_toml(cargo_toml.generated_contents());
213    /// assert!(unchanged.hunks().is_empty());
214    /// # Ok::<(), hakari::CargoTomlError>(())
215    /// ```
216    pub fn diff_toml<'a>(&'a self, toml: &'a str) -> Patch<'a, str> {
217        diffy::create_patch(self.generated_contents(), toml)
218    }
219
220    /// Writes out the provided TOML to the generated section of the file. The rest of the file is
221    /// left unmodified.
222    ///
223    /// `self` is consumed because the contents of the file are now assumed to be invalid.
224    ///
225    /// Returns true if the contents were different and the file was written out, false if the
226    /// contents were the same and the file was *not* written out, and an error if there was an
227    /// issue while writing the file out.
228    pub fn write_to_file(self, toml: &str) -> Result<bool, CargoTomlError> {
229        if !self.is_changed(toml) {
230            // Don't write out the file if it hasn't changed to avoid bumping mtimes.
231            return Ok(false);
232        }
233
234        let try_block = || {
235            let atomic_file = AtomicFile::new(&self.toml_path, OverwriteBehavior::AllowOverwrite);
236            atomic_file.write(|f| self.write(toml, f))
237        };
238
239        match (try_block)() {
240            Ok(()) => Ok(true),
241            Err(atomicwrites::Error::Internal(error)) | Err(atomicwrites::Error::User(error)) => {
242                Err(CargoTomlError::Io {
243                    toml_path: self.toml_path,
244                    error,
245                })
246            }
247        }
248    }
249
250    /// Writes out the full contents, including the provided TOML, to the given writer.
251    pub fn write(&self, toml: &str, mut out: impl io::Write) -> io::Result<()> {
252        write!(out, "{}", &self.contents[..self.start_offset])?;
253        write!(out, "{toml}")?;
254        write!(out, "{}", &self.contents[self.end_offset..])
255    }
256
257    /// Writes out the full contents, including the provided TOML, to the given `fmt::Write`
258    /// instance.
259    ///
260    /// `std::io::Write` expects bytes to be written to it, so using it with a `&mut String` is
261    /// inconvenient. This alternative is more convenient, and also works for `fmt::Formatter`
262    /// instances.
263    pub fn write_to_fmt(&self, toml: &str, mut out: impl fmt::Write) -> fmt::Result {
264        // No alternative to copy-pasting :(
265        write!(out, "{}", &self.contents[..self.start_offset])?;
266        write!(out, "{toml}")?;
267        write!(out, "{}", &self.contents[self.end_offset..])
268    }
269}
270
271/// An error that can occur while reading or writing a `Cargo.toml` file.
272#[derive(Debug)]
273#[non_exhaustive]
274pub enum CargoTomlError {
275    /// The contents of the `Cargo.toml` file could not be read or written.
276    Io {
277        /// The path that was attempted to be read.
278        toml_path: Utf8PathBuf,
279
280        /// The error that occurred.
281        error: io::Error,
282    },
283
284    /// The `Cargo.toml` was successfully read but `### BEGIN HAKARI SECTION` and
285    /// `### END HAKARI SECTION` couldn't be found.
286    GeneratedSectionNotFound {
287        /// The path that was read.
288        toml_path: Utf8PathBuf,
289    },
290}
291
292impl fmt::Display for CargoTomlError {
293    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
294        match self {
295            CargoTomlError::Io { toml_path, .. } => {
296                write!(f, "error while reading path '{toml_path}'")
297            }
298            CargoTomlError::GeneratedSectionNotFound { toml_path, .. } => {
299                write!(
300                    f,
301                    "in '{toml_path}', unable to find\n\
302                ### BEGIN HAKARI SECTION\n\
303                ...\n\
304                ### END HAKARI SECTION"
305                )
306            }
307        }
308    }
309}
310
311impl error::Error for CargoTomlError {
312    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
313        match self {
314            CargoTomlError::Io { error, .. } => Some(error),
315            CargoTomlError::GeneratedSectionNotFound { .. } => None,
316        }
317    }
318}