Skip to main content

dots/
world.rs

1//! See [`World`] for more info
2
3use eyre::ContextCompat as _;
4use std::{
5    fs,
6    path::{self, Path, PathBuf},
7};
8
9use itertools::Itertools as _;
10use tap::Pipe as _;
11use walkdir::WalkDir;
12
13use crate::{
14    config::Config,
15    output_path::OutputPath,
16    stdx::{self, PathExt as _},
17};
18
19use std::collections::BTreeMap;
20
21use crate::analysis::Analysis;
22use crate::config::GITHUB;
23use crate::config::Marker;
24
25use eyre::{Context as _, Error, Result, bail, eyre};
26use handlebars::Handlebars;
27use simply_colored::*;
28
29/// This structure represents inputs to the application, with all
30/// paths resolved so the core of `dots` does not need to do any IO.
31#[derive(Debug)]
32pub struct World {
33    /// Path which contains the config file
34    pub root: PathBuf,
35    /// Contents of the config file
36    pub links: Vec<Link>,
37    /// Files to create
38    pub files: Vec<File>,
39}
40
41/// Represents a URL
42#[derive(Debug)]
43pub struct Link {
44    /// Url to the contents of the link
45    pub url: String,
46    /// Content at the URL
47    pub contents: String,
48    /// Path where to write the file to in the `config` directory,
49    /// e.g. `nushell/catppuccin.nu` writes to `config/nushell/catppuccin.nu` if `config` in `Config` is `"config"`
50    pub path: PathBuf,
51    /// Expected hash of the file. This can be supplied for security purposes
52    pub sha256: Option<String>,
53    /// A marker to add, like `"--path '{config}/gitui/theme.ron'"`
54    /// This marker is not interpreted. Instead, the marker is written to the
55    /// file as-is
56    pub marker: Option<String>,
57}
58
59/// A single file to be mapped from the input (`old_location`) to the output (`new_location`)
60#[derive(Debug)]
61pub struct File {
62    /// Old location of the file
63    pub old_location: PathBuf,
64    /// Contents of the file
65    pub contents: String,
66    /// Output path
67    pub output: OutputPath,
68    /// Input directory
69    pub input: PathBuf,
70}
71
72impl World {
73    /// This function is the "core" of `dots`, it is pure and does no IO (except for logging)
74    ///
75    /// We want to keep it like this as it makes it easier to reason about and test.
76    pub fn process(self) -> Result<Analysis, Vec<Error>> {
77        let mut errors = vec![];
78
79        let links = self
80            .links
81            .into_iter()
82            .map(
83                |Link {
84                     contents,
85                     path,
86                     sha256,
87                     marker,
88                     url,
89                 }| {
90                    let actual_sha256 = sha256::digest(&contents);
91
92                    if let Some(expected_sha256) = sha256
93                        && actual_sha256 != *expected_sha256
94                    {
95                        let mismatch = format!("link       {BLUE}{url}{RESET}");
96                        let actual = format!("actual     {CYAN}{actual_sha256}{RESET}");
97                        let expected = format!("expected   {CYAN}{expected_sha256}{RESET}");
98                        bail!("hash mismatch\n  {mismatch}\n  {actual}\n  {expected}");
99                    }
100
101                    // download the link's contents to *this* path
102                    let path = self.root.join(path);
103
104                    // add the marker if necessary
105                    let marker = marker.as_ref().map_or(String::new(), |marker_args| {
106                        format!("{}{marker_args}", Marker::MARKER)
107                            .pipe(|marker| commented::comment(marker, &path))
108                            + "\n"
109                    });
110
111                    let file_contents = format!("{marker}{contents}");
112
113                    let marker = file_contents
114                        .lines()
115                        .next()
116                        .filter(|line| line.contains(Marker::MARKER))
117                        .map(|line| format!("{line}\n"))
118                        .unwrap_or_default();
119
120                    let contents = if let Some(first_line) = file_contents.lines().next()
121                        && first_line.contains(Marker::MARKER)
122                    {
123                        file_contents.lines().skip(1).collect::<Vec<_>>().join("\n")
124                    } else {
125                        file_contents.to_string()
126                    };
127
128                    let generated_notice = [
129                        format!("@generated by `{}` <{GITHUB}>", Marker::MARKER),
130                        "Do not edit by hand.".to_string(),
131                        String::new(),
132                        format!("downloaded from: {url}"),
133                    ]
134                    .into_iter()
135                    .fold(String::new(), |previous_lines, line| {
136                        format!("{previous_lines}{}\n", commented::comment(line, &path))
137                    });
138
139                    let contents = format!("{marker}{generated_notice}{contents}");
140
141                    Ok(crate::analysis::WritePath { path, contents })
142                },
143            )
144            .partition_result::<Vec<_>, Vec<_>, _, _>()
145            .pipe(|(oks, errs)| {
146                errors.extend(errs);
147                oks
148            });
149
150        let files = self
151            .files
152            .into_iter()
153            .map(
154                |File {
155                     old_location,
156                     contents,
157                     output,
158                     input,
159                 }| {
160                    let relative_location = old_location
161                        .strip_prefix(&self.root)?
162                        .strip_prefix(&input)?;
163
164                    let (file_contents, new_location) = if let Some(first_line) =
165                        contents.lines().next()
166                        && let Some(marker_start_pos) = first_line.find(Marker::MARKER)
167                        && let Some(marker_args) =
168                            first_line.get(marker_start_pos + Marker::MARKER.len()..)
169                        && let Ok(args) = marker_args.parse::<Marker>()
170                        && let Some(path) = args.path
171                    {
172                        (
173                            // remove the first line which contains the `@dotfilers`
174                            contents.lines().skip(1).collect_vec().join(","),
175                            path,
176                        )
177                    } else {
178                        (
179                            contents,
180                            output
181                                .as_ref()
182                                .join(relative_location)
183                                .pipe(OutputPath::new),
184                        )
185                    };
186
187                    let mut handlebars = Handlebars::new();
188                    handlebars
189                        .register_template_string("t1", file_contents)
190                        .with_context(|| eyre!("failed to parse template for {new_location}"))?;
191
192                    let contents = handlebars
193                        .render("t1", &BTreeMap::<u8, u8>::new())
194                        .with_context(|| eyre!("failed to render template for {new_location}"))?;
195
196                    Ok::<_, Error>(crate::analysis::WritePath {
197                        path: new_location.into_inner(),
198                        contents,
199                    })
200                },
201            )
202            .partition_result::<Vec<_>, Vec<_>, _, _>()
203            .pipe(|(oks, errs)| {
204                errors.extend(errs);
205                oks
206            });
207
208        if !errors.is_empty() {
209            return Err(errors);
210        }
211
212        Ok(Analysis {
213            writes: links.into_iter().chain(files).collect(),
214        })
215    }
216
217    /// Create the `World`
218    pub fn new(cwd: &Path) -> Result<Self, Vec<Error>> {
219        // Directory which contains the config file
220        let root = cwd
221            .pipe_ref(stdx::traverse_upwards)
222            .find(|dir| dir.join(Config::FILE_NAME).exists())
223            .with_context(|| {
224                eyre!(
225                    "failed to find directory that contains a `{}`. traversed upwards from {}",
226                    Config::FILE_NAME,
227                    cwd.show()
228                )
229            })
230            .map_err(single_err)?;
231
232        let config = root
233            .join(Config::FILE_NAME)
234            .pipe(fs::read_to_string)
235            .with_context(|| eyre!("failed to read config file {}", Config::FILE_NAME))
236            .map_err(single_err)?
237            .pipe_deref(toml::de::from_str::<Config>)
238            .context("failed to parse config file")
239            .map_err(single_err)?
240            .pipe(|mut conf| {
241                conf.root = root;
242                conf
243            });
244
245        let mut errors = vec![];
246
247        let links = config
248            .links
249            .into_iter()
250            .map(
251                |crate::config::Link {
252                     url,
253                     path,
254                     sha256,
255                     marker,
256                 }| {
257                    Ok::<_, Error>(Link {
258                        contents: ureq::get(&url).call()?.body_mut().read_to_string()?,
259                        path,
260                        sha256,
261                        marker,
262                        url,
263                    })
264                },
265            )
266            .partition_result::<Vec<_>, Vec<_>, _, _>()
267            .pipe(|(oks, errs)| {
268                errors.extend(errs);
269                oks
270            });
271
272        let files = config
273            .dirs
274            .into_iter()
275            .flat_map(|crate::config::Dir { input, output }| {
276                WalkDir::new(config.root.join(&input))
277                    .into_iter()
278                    .flatten()
279                    .filter(|dir_entry| dir_entry.file_type().is_file())
280                    .map(move |file| {
281                        // location of the `input` file
282                        let old_location = path::absolute(file.path())?;
283
284                        let contents = fs::read_to_string(&old_location).with_context(|| {
285                            eyre!("failed to read path {}", old_location.show())
286                        })?;
287
288                        Ok::<_, Error>(File {
289                            old_location,
290                            contents,
291                            output: output.clone(),
292                            input: input.clone(),
293                        })
294                    })
295            })
296            .partition_result::<Vec<_>, Vec<_>, _, _>()
297            .pipe(|(oks, errs)| {
298                errors.extend(errs);
299                oks
300            });
301
302        if !errors.is_empty() {
303            return Err(errors);
304        }
305
306        Ok(Self {
307            root: config.root,
308            links,
309            files,
310        })
311    }
312}
313
314/// Helper to return a single error from a function that returns a `Vec<Error>`
315///
316/// Useful for **unrecoverable** errors
317fn single_err(err: impl Into<Error>) -> Vec<Error> {
318    vec![err.into()]
319}