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}