1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
// Copyright (c) The cargo-guppy Contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

use atomicwrites::{AtomicFile, OverwriteBehavior};
use camino::{Utf8Path, Utf8PathBuf};
use diffy::Patch;
use std::{error, fmt, io};

/// Support for maintaining `Cargo.toml` files that unify features in a workspace.
///
/// This struct maintains a context around a `Cargo.toml` file. It provides facilities for diffing
/// the contents of the file, and for writing out new contents.
///
/// # Structure of the Cargo.toml file
///
/// The `Cargo.toml` file is treated as partially generated. It is expected to have a section
/// marked off as, for example:
///
/// ```toml
/// [package]
/// ...
///
/// ### BEGIN HAKARI SECTION
/// [dependencies]
/// ...
///
/// [build-dependencies]
/// ...
///
/// [dev-dependencies]
/// ...
/// ### END HAKARI SECTION
/// ```
///
/// The part of the `Cargo.toml` file between the `BEGIN HAKARI SECTION` and `END HAKARI SECTION`
/// lines is managed by this struct, and changes to it may not be preserved. The part of the file
/// outside this section can be edited and its contents will be preserved.
///
/// # Setting up a new package
///
/// For Hakari to manage a package, a bit of initial prep work must be done:
///
/// 1. Add a new library package in a desired location within your workspace, for example:
///   `cargo new --lib hakari-package`.
/// 2. Copy and paste the following lines of code to the end of the package's `Cargo.toml` file. Be
///    sure to put in a trailing newline.
///
///     ```toml
///     ### BEGIN HAKARI SECTION
///
///     ### END HAKARI SECTION
///
///     ```
///
/// 3. Add an empty `build.rs` file (the exact contents don't matter, but the presence of this file
///    makes build dependencies work properly).
///
///     ```
///     fn main() {}
///     ```
#[derive(Clone, Debug)]
pub struct HakariCargoToml {
    toml_path: Utf8PathBuf,
    contents: String,
    // Start and end offsets for the section to replace.
    start_offset: usize,
    end_offset: usize,
}

impl HakariCargoToml {
    /// The string `"\n### BEGIN HAKARI SECTION\n"`. This string marks the beginning of the
    /// generated section.
    pub const BEGIN_SECTION: &'static str = "\n### BEGIN HAKARI SECTION\n";

    /// The string `"\n### END HAKARI SECTION\n"`. This string marks the end of the generated
    /// section.
    pub const END_SECTION: &'static str = "\n### END HAKARI SECTION\n";

    /// Creates a new instance of `HakariCargoToml` with the `Cargo.toml` located at the given path.
    /// Reads the contents of the file off of disk.
    ///
    /// If the path is relative, it is evaluated with respect to the current directory.
    ///
    /// Returns an error if the file couldn't be read (other than if the file wasn't found, which
    /// is a case handled by this struct).
    pub fn new(toml_path: impl Into<Utf8PathBuf>) -> Result<Self, CargoTomlError> {
        let toml_path = toml_path.into();

        let contents = match std::fs::read_to_string(&toml_path) {
            Ok(contents) => contents,
            Err(error) => return Err(CargoTomlError::Io { toml_path, error }),
        };

        Self::new_in_memory(toml_path, contents)
    }

    /// Creates a new instance of `HakariCargoToml` at the given workspace root and crate
    /// directory. Reads the contents of the file off of disk.
    ///
    /// This is a convenience method around appending `crate_dir` and `Cargo.toml` to
    /// `workspace_root`.
    ///
    /// If the path is relative, it is evaluated with respect to the current directory.
    pub fn new_relative(
        workspace_root: impl Into<Utf8PathBuf>,
        crate_dir: impl AsRef<Utf8Path>,
    ) -> Result<Self, CargoTomlError> {
        let mut toml_path = workspace_root.into();
        toml_path.push(crate_dir);
        toml_path.push("Cargo.toml");

        Self::new(toml_path)
    }

    /// Creates a new instance of `HakariCargoToml` with the given path with the given contents as
    /// read from disk.
    ///
    /// This may be useful for test scenarios.
    pub fn new_in_memory(
        toml_path: impl Into<Utf8PathBuf>,
        contents: String,
    ) -> Result<Self, CargoTomlError> {
        let toml_path = toml_path.into();

        // Look for the start and end offsets.
        let start_offset = match contents.find(Self::BEGIN_SECTION) {
            Some(offset) => {
                // Add the length of BEGIN_SECTION so that anything after that is replaced.
                offset + Self::BEGIN_SECTION.len()
            }
            None => return Err(CargoTomlError::GeneratedSectionNotFound { toml_path }),
        };

        // Start searching from 1 before the end of the BEGIN text so that we find the END text
        // even if there's nothing in between.
        let end_offset = match contents[(start_offset - 1)..].find(Self::END_SECTION) {
            Some(offset) => start_offset + offset,
            None => return Err(CargoTomlError::GeneratedSectionNotFound { toml_path }),
        };

        Ok(Self {
            toml_path,
            contents,
            start_offset,
            end_offset,
        })
    }

    /// Returns the toml path provided at construction time.
    pub fn toml_path(&self) -> &Utf8Path {
        &self.toml_path
    }

    /// Returns the contents of the file on disk as read at construction time.
    pub fn contents(&self) -> &str {
        &self.contents
    }

    /// Returns the start and end offsets of the part of the file treated as generated.
    pub fn generated_offsets(&self) -> (usize, usize) {
        (self.start_offset, self.end_offset)
    }

    /// Returns the part of the file that is treated as generated.
    ///
    /// This part of the file will be replaced on write.
    pub fn generated_contents(&self) -> &str {
        &self.contents[self.start_offset..self.end_offset]
    }

    /// Returns true if the contents on disk are different from the provided TOML output.
    pub fn is_changed(&self, toml: &str) -> bool {
        self.generated_contents() != toml
    }

    /// Computes the diff between the contents on disk and the provided TOML output.
    ///
    /// This returns a `diffy::Patch`, which can be formatted through methods provided by `diffy`.
    /// `diffy` is re-exported at the top level of this crate.
    ///
    /// # Examples
    ///
    /// TODO
    pub fn diff_toml<'a>(&'a self, toml: &'a str) -> Patch<'a, str> {
        diffy::create_patch(self.generated_contents(), toml)
    }

    /// Writes out the provided TOML to the generated section of the file. The rest of the file is
    /// left unmodified.
    ///
    /// `self` is consumed because the contents of the file are now assumed to be invalid.
    ///
    /// Returns true if the contents were different and the file was written out, false if the
    /// contents were the same and the file was *not* written out, and an error if there was an
    /// issue while writing the file out.
    pub fn write_to_file(self, toml: &str) -> Result<bool, CargoTomlError> {
        if !self.is_changed(toml) {
            // Don't write out the file if it hasn't changed to avoid bumping mtimes.
            return Ok(false);
        }

        let try_block = || {
            let atomic_file = AtomicFile::new(&self.toml_path, OverwriteBehavior::AllowOverwrite);
            atomic_file.write(|f| self.write(toml, f))
        };

        match (try_block)() {
            Ok(()) => Ok(true),
            Err(atomicwrites::Error::Internal(error)) | Err(atomicwrites::Error::User(error)) => {
                Err(CargoTomlError::Io {
                    toml_path: self.toml_path,
                    error,
                })
            }
        }
    }

    /// Writes out the full contents, including the provided TOML, to the given writer.
    pub fn write(&self, toml: &str, mut out: impl io::Write) -> io::Result<()> {
        write!(out, "{}", &self.contents[..self.start_offset])?;
        write!(out, "{}", toml)?;
        write!(out, "{}", &self.contents[self.end_offset..])
    }

    /// Writes out the full contents, including the provided TOML, to the given `fmt::Write`
    /// instance.
    ///
    /// `std::io::Write` expects bytes to be written to it, so using it with a `&mut String` is
    /// inconvenient. This alternative is more convenient, and also works for `fmt::Formatter`
    /// instances.
    pub fn write_to_fmt(&self, toml: &str, mut out: impl fmt::Write) -> fmt::Result {
        // No alternative to copy-pasting :(
        write!(out, "{}", &self.contents[..self.start_offset])?;
        write!(out, "{}", toml)?;
        write!(out, "{}", &self.contents[self.end_offset..])
    }
}

/// An error that can occur while reading or writing a `Cargo.toml` file.
#[derive(Debug)]
#[non_exhaustive]
pub enum CargoTomlError {
    /// The contents of the `Cargo.toml` file could not be read or written.
    Io {
        /// The path that was attempted to be read.
        toml_path: Utf8PathBuf,

        /// The error that occurred.
        error: io::Error,
    },

    /// The `Cargo.toml` was successfully read but `### BEGIN HAKARI SECTION` and
    /// `### END HAKARI SECTION` couldn't be found.
    GeneratedSectionNotFound {
        /// The path that was read.
        toml_path: Utf8PathBuf,
    },
}

impl fmt::Display for CargoTomlError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            CargoTomlError::Io { toml_path, .. } => {
                write!(f, "error while reading path '{}'", toml_path)
            }
            CargoTomlError::GeneratedSectionNotFound { toml_path, .. } => {
                write!(
                    f,
                    "in '{}', unable to find\n\
                ### BEGIN HAKARI SECTION\n\
                ...\n\
                ### END HAKARI SECTION",
                    toml_path
                )
            }
        }
    }
}

impl error::Error for CargoTomlError {
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
        match self {
            CargoTomlError::Io { error, .. } => Some(error),
            CargoTomlError::GeneratedSectionNotFound { .. } => None,
        }
    }
}