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}