hakari/cli_ops/
initialize.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::cli_ops::workspace_ops::{WorkspaceOp, WorkspaceOps};
5use camino::{Utf8Path, Utf8PathBuf};
6use guppy::graph::PackageGraph;
7use include_dir::{include_dir, Dir, DirEntry};
8use std::{borrow::Cow, convert::TryInto, error, fmt, io};
9
10const CRATE_TEMPLATE_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/crate");
11const CONFIG_TEMPLATE: &str = include_str!("../../templates/hakari.toml-in");
12
13/// Manages initialization of a workspace-hack package.
14#[derive(Clone, Debug)]
15pub struct HakariInit<'g, 'a> {
16    package_graph: &'g PackageGraph,
17    package_name: &'a str,
18    crate_path: &'a Utf8Path,
19    config: Option<(&'a Utf8Path, &'a str)>,
20    cargo_toml_comment: &'a str,
21}
22
23impl<'g, 'a> HakariInit<'g, 'a> {
24    /// Creates a new `HakariInit` with the given options. Writes out a stub config to the path if
25    /// specified.
26    ///
27    /// `crate_path` and `config_path` are relative to the root of the workspace.
28    pub fn new(
29        package_graph: &'g PackageGraph,
30        package_name: &'a str,
31        crate_path: &'a Utf8Path,
32    ) -> Result<Self, InitError> {
33        let workspace = package_graph.workspace();
34        let workspace_root = workspace.root();
35
36        // The package name can't already be present in the package graph.
37        if let Ok(existing) = workspace.member_by_name(package_name) {
38            return Err(InitError::PackageNameExists {
39                package_name: package_name.to_owned(),
40                workspace_path: existing
41                    .source()
42                    .workspace_path()
43                    .expect("package returned by workspace")
44                    .to_owned(),
45            });
46        }
47
48        let abs_path = workspace_root.join(crate_path);
49        if !abs_path.starts_with(workspace.root()) {
50            return Err(InitError::WorkspacePathNotInRoot {
51                abs_path,
52                workspace_root: workspace.root().to_owned(),
53            });
54        }
55
56        // The workspace path can't already exist (don't follow symlinks for this because even a
57        // broken symlink is an error).
58        match std::fs::symlink_metadata(&abs_path) {
59            Ok(_) => {
60                // The path exists.
61                return Err(InitError::WorkspacePathExists { abs_path });
62            }
63            #[cfg_attr(guppy_nightly, expect(non_exhaustive_omitted_patterns))]
64            Err(err) => match err.kind() {
65                io::ErrorKind::NotFound => {}
66                _ => {
67                    return Err(InitError::Io {
68                        path: abs_path,
69                        error: err,
70                    });
71                }
72            },
73        }
74
75        // TODO: check package name validity.
76
77        Ok(Self {
78            package_graph,
79            package_name,
80            crate_path,
81            config: None,
82            cargo_toml_comment: "",
83        })
84    }
85
86    /// Specifies a path, relative to the workspace root, where a stub configuration file should be
87    /// written out. Also accepts a comment (in TOML format) to put at the top of the file.
88    ///
89    /// If this method is not called, no configuration path will be written out.
90    pub fn set_config(
91        &mut self,
92        path: &'a Utf8Path,
93        comment: &'a str,
94    ) -> Result<&mut Self, InitError> {
95        // The config path can't be present already.
96        let abs_path = self.package_graph.workspace().root().join(path);
97        if abs_path.exists() {
98            return Err(InitError::ConfigPathExists { abs_path });
99        }
100
101        self.config = Some((path, comment));
102        Ok(self)
103    }
104
105    /// Specifies a comment, in TOML format, to add to the top of the workspace-hack package's
106    /// `Cargo.toml`.
107    pub fn set_cargo_toml_comment(&mut self, comment: &'a str) -> &mut Self {
108        self.cargo_toml_comment = comment;
109        self
110    }
111
112    /// Returns the workspace operations corresponding to this initialization.
113    pub fn make_ops(&self) -> WorkspaceOps<'g, 'a> {
114        WorkspaceOps::new(
115            self.package_graph,
116            std::iter::once(self.make_new_crate_op()),
117        )
118    }
119
120    // ---
121    // Helper methods
122    // ---
123
124    fn make_new_crate_op(&self) -> WorkspaceOp<'g, 'a> {
125        let files = CRATE_TEMPLATE_DIR
126            .find("**/*")
127            .expect("pattern **/* is valid")
128            .flat_map(|entry| {
129                match entry {
130                    DirEntry::File(file) => {
131                        let path: &Utf8Path = file
132                            .path()
133                            .try_into()
134                            .expect("embedded path is valid UTF-8");
135                        // .toml-in files need a bit of processing.
136                        if path.extension() == Some("toml-in") {
137                            let contents = file
138                                .contents_utf8()
139                                .expect("embedded .toml-in is valid UTF-8");
140                            let contents = contents.replace("%PACKAGE_NAME%", self.package_name);
141                            let contents =
142                                contents.replace("%CARGO_TOML_COMMENT%\n", self.cargo_toml_comment);
143                            Some((
144                                Cow::Owned(path.with_extension("toml")),
145                                Cow::Owned(contents.into_bytes()),
146                            ))
147                        } else {
148                            Some((Cow::Borrowed(path), Cow::Borrowed(file.contents())))
149                        }
150                    }
151                    DirEntry::Dir(_) => None,
152                }
153            })
154            .collect();
155
156        let root_files = self
157            .config
158            .into_iter()
159            .map(|(path, comment)| {
160                let contents = CONFIG_TEMPLATE.replace("%PACKAGE_NAME%", self.package_name);
161                let contents = contents.replace("%CONFIG_COMMENT%\n", comment);
162                (Cow::Borrowed(path), Cow::Owned(contents.into_bytes()))
163            })
164            .collect();
165
166        WorkspaceOp::NewCrate {
167            crate_path: self.crate_path,
168            files,
169            root_files,
170        }
171    }
172}
173
174/// An error that occurred while attempting to initialize `hakari`.
175#[derive(Debug)]
176#[non_exhaustive]
177pub enum InitError {
178    /// The configuration path already exists.
179    ConfigPathExists {
180        /// The absolute path of the configuration file.
181        abs_path: Utf8PathBuf,
182    },
183
184    /// The provided package name already exists.
185    PackageNameExists {
186        /// The package name that exists.
187        package_name: String,
188
189        /// The path at which it exists, relative to the root.
190        workspace_path: Utf8PathBuf,
191    },
192
193    /// The provided path is not within the workspace root.
194    WorkspacePathNotInRoot {
195        /// The absolute workspace path.
196        abs_path: Utf8PathBuf,
197
198        /// The workspace root.
199        workspace_root: Utf8PathBuf,
200    },
201
202    /// The provided workspace directory is non-empty.
203    WorkspacePathExists {
204        /// The absolute workspace path.
205        abs_path: Utf8PathBuf,
206    },
207
208    /// An IO error occurred while working with the given path.
209    Io {
210        /// The path.
211        path: Utf8PathBuf,
212
213        /// The error.
214        error: io::Error,
215    },
216}
217
218impl fmt::Display for InitError {
219    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
220        match self {
221            InitError::ConfigPathExists { abs_path } => {
222                write!(f, "config already exists at path {}", abs_path)
223            }
224            InitError::PackageNameExists {
225                package_name,
226                workspace_path,
227            } => {
228                write!(
229                    f,
230                    "package name {} already exists at path {}",
231                    package_name, workspace_path
232                )
233            }
234            InitError::WorkspacePathNotInRoot {
235                abs_path,
236                workspace_root,
237            } => {
238                write!(
239                    f,
240                    "path {} is not within workspace {}",
241                    abs_path, workspace_root
242                )
243            }
244            InitError::WorkspacePathExists { abs_path } => {
245                write!(f, "workspace path {} already exists", abs_path)
246            }
247            InitError::Io { path, .. } => {
248                write!(f, "IO error while accessing {}", path)
249            }
250        }
251    }
252}
253
254impl error::Error for InitError {
255    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
256        match self {
257            InitError::Io { error, .. } => Some(error),
258            InitError::ConfigPathExists { .. }
259            | InitError::PackageNameExists { .. }
260            | InitError::WorkspacePathNotInRoot { .. }
261            | InitError::WorkspacePathExists { .. } => None,
262        }
263    }
264}