punktf_lib/visit/
mod.rs

1//! This module provieds a [`Visitor`] trait and a [`Walker`] which iterates
2//! over every item to be deployed in a given profile.
3//! The visitor accepts items on different functions depending on status and type.
4
5pub mod deploy;
6pub mod diff;
7
8use std::borrow::Cow;
9use std::fmt;
10use std::io;
11use std::ops::Deref;
12use std::path::{Path, PathBuf};
13
14use crate::profile::link;
15use crate::profile::LayeredProfile;
16use crate::profile::{dotfile::Dotfile, source::PunktfSource};
17
18use color_eyre::eyre::Context;
19
20use crate::template::source::Source;
21use crate::template::Template;
22
23/// Result type for this module.
24pub type Result = std::result::Result<(), Box<dyn std::error::Error>>;
25
26/// A struct to keep two paths in sync while appending relative child paths.
27#[derive(Debug, Clone)]
28struct PathLink {
29	/// Source path.
30	source: PathBuf,
31
32	/// Target path.
33	target: PathBuf,
34}
35
36impl PathLink {
37	/// Creates a new path link struct.
38	const fn new(source: PathBuf, target: PathBuf) -> Self {
39		Self { source, target }
40	}
41
42	/// Appends a child path to the path link.
43	///
44	/// The given path will be added in sync to both internal paths.
45	fn join(mut self, relative: &Path) -> Self {
46		self.source = self.source.join(relative);
47		self.target = self.target.join(relative);
48
49		self
50	}
51}
52
53/// A struct to hold all paths relevant for a [`Item`].
54#[derive(Debug, Clone)]
55struct Paths {
56	/// The root paths of the underlying [`Dotfile`](`crate::profile::dotfile::Dotfile`).
57	///
58	/// This will always be the path of the item.
59	root: PathLink,
60
61	/// The paths of the [`Item`].
62	///
63	/// If the dotfile is a directory, this contains the relevant path
64	/// to the item which is included by the root dotfile.
65	child: Option<PathLink>,
66}
67
68impl Paths {
69	/// Creates a new paths instance.
70	const fn new(root_source: PathBuf, root_target: PathBuf) -> Self {
71		Self {
72			root: PathLink::new(root_source, root_target),
73			child: None,
74		}
75	}
76
77	/// Appends a relative child path to instance.
78	fn with_child(self, rel_path: impl Into<PathBuf>) -> Self {
79		let Paths { root, child } = self;
80		let rel_path = rel_path.into();
81
82		let child = if let Some(child) = child {
83			child.join(&rel_path)
84		} else {
85			PathLink::new(rel_path.clone(), rel_path)
86		};
87
88		Self {
89			root,
90			child: Some(child),
91		}
92	}
93
94	/// Checks if this instance points to a actual
95	/// [`Dotfile`](`crate::profile::dotfile::Dotfile`).
96	pub const fn is_root(&self) -> bool {
97		self.child.is_none()
98	}
99
100	/// Checks if this instance points to a child of an
101	/// [`Dotfile`](`crate::profile::dotfile::Dotfile`).
102	pub const fn is_child(&self) -> bool {
103		self.child.is_some()
104	}
105
106	/// Retrieves the source path of the actual dotfile.
107	pub fn root_source_path(&self) -> &Path {
108		&self.root.source
109	}
110
111	/// Retrieves the target path of the actual dotfile.
112	pub fn root_target_path(&self) -> &Path {
113		&self.root.target
114	}
115
116	/// Retrieves the target path of the child.
117	///
118	/// If this is not a child instance, the root path will be returned instead.
119	pub fn child_source_path(&self) -> Cow<'_, Path> {
120		if let Some(child) = &self.child {
121			Cow::Owned(self.root_source_path().join(&child.source))
122		} else {
123			Cow::Borrowed(self.root_source_path())
124		}
125	}
126
127	/// Retrieves the source path of the child.
128	///
129	/// If this is not a child instance, the root path will be returned instead.
130	pub fn child_target_path(&self) -> Cow<'_, Path> {
131		if let Some(child) = &self.child {
132			Cow::Owned(self.root_target_path().join(&child.target))
133		} else {
134			Cow::Borrowed(self.root_target_path())
135		}
136	}
137}
138
139/// Defines what kind the item is.
140#[derive(Debug)]
141pub enum Kind<'a> {
142	/// The item stems directly from a [`Dotfile`](`crate::profile::dotfile::Dotfile`).
143	Root(&'a Dotfile),
144
145	/// The item is a child of a directory [`Dotfile`](`crate::profile::dotfile::Dotfile`).
146	Child {
147		/// The root [`Dotfile`](`crate::profile::dotfile::Dotfile`) from which
148		/// this item stems.
149		root: &'a Dotfile,
150
151		/// Absolute source path to the root dotfile.
152		root_source_path: PathBuf,
153
154		/// Absolute target path to the root dotfile.
155		root_target_path: PathBuf,
156	},
157}
158
159impl<'a> Kind<'a> {
160	/// Creates a new instance.
161	fn from_paths(paths: Paths, dotfile: &'a Dotfile) -> Self {
162		if paths.is_root() {
163			Self::Root(dotfile)
164		} else {
165			Self::Child {
166				root: dotfile,
167				root_source_path: paths.root_source_path().to_path_buf(),
168				root_target_path: paths.root_target_path().to_path_buf(),
169			}
170		}
171	}
172
173	/// Retrieves the underlying [`Dotfile`](`crate::profile::dotfile::Dotfile`).
174	pub const fn dotfile(&self) -> &Dotfile {
175		match self {
176			Self::Root(dotfile) => dotfile,
177			Self::Child { root: dotfile, .. } => dotfile,
178		}
179	}
180}
181
182/// Saves relevant information about an item to be processed.
183#[derive(Debug)]
184pub struct Item<'a> {
185	/// Relative path to the item inside the `dotfiles` directly.
186	pub relative_source_path: PathBuf,
187
188	/// Absolute source path for the item.
189	pub source_path: PathBuf,
190
191	/// Absolute target path for the item.
192	pub target_path: PathBuf,
193
194	/// Kind of the item.
195	pub kind: Kind<'a>,
196}
197
198impl<'a> Item<'a> {
199	/// Creates a new instance.
200	fn new(source: &PunktfSource, paths: Paths, dotfile: &'a Dotfile) -> Self {
201		let source_path = paths.child_source_path().into_owned();
202		let target_path = paths.child_target_path().into_owned();
203		let relative_source_path = source_path
204			.strip_prefix(&source.dotfiles)
205			.expect("Dotfile is not in the dotfile root")
206			.to_path_buf();
207		let kind = Kind::from_paths(paths, dotfile);
208
209		Self {
210			relative_source_path,
211			source_path,
212			target_path,
213			kind,
214		}
215	}
216}
217
218impl Item<'_> {
219	/// Retrieves the underlying dotfile.
220	pub const fn dotfile(&self) -> &Dotfile {
221		self.kind.dotfile()
222	}
223}
224
225/// A file to be processed.
226#[derive(Debug)]
227pub struct File<'a>(Item<'a>);
228
229impl<'a> Deref for File<'a> {
230	type Target = Item<'a>;
231
232	fn deref(&self) -> &Self::Target {
233		&self.0
234	}
235}
236
237/// A directory to be processed.
238#[derive(Debug)]
239pub struct Directory<'a>(Item<'a>);
240
241impl<'a> Deref for Directory<'a> {
242	type Target = Item<'a>;
243
244	fn deref(&self) -> &Self::Target {
245		&self.0
246	}
247}
248
249/// A symlink to be processed.
250#[derive(Debug)]
251pub struct Symlink {
252	/// Absolute source path of the link.
253	pub source_path: PathBuf,
254
255	/// Absolute target path of the link.
256	pub target_path: PathBuf,
257
258	/// Indicates if any existing symlink at the [`Symlink::target_path`] should
259	/// be replaced by this item.
260	pub replace: bool,
261}
262
263/// Holds information about a rejected item.
264#[derive(Debug)]
265pub struct Rejected<'a> {
266	/// The item which was rejected.
267	pub item: Item<'a>,
268
269	/// The reason why the item was rejected.
270	pub reason: Cow<'static, str>,
271}
272
273impl<'a> Deref for Rejected<'a> {
274	type Target = Item<'a>;
275
276	fn deref(&self) -> &Self::Target {
277		&self.item
278	}
279}
280
281/// Holds information about a errored item.
282#[derive(Debug)]
283pub struct Errored<'a> {
284	/// The item which was rejected.
285	pub item: Item<'a>,
286
287	/// The error which has occurred.
288	pub error: Option<Box<dyn std::error::Error>>,
289
290	/// The context of the error.
291	pub context: Option<Cow<'a, str>>,
292}
293
294impl<'a> Deref for Errored<'a> {
295	type Target = Item<'a>;
296
297	fn deref(&self) -> &Self::Target {
298		&self.item
299	}
300}
301
302impl fmt::Display for Errored<'_> {
303	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304		let has_context = if let Some(context) = &self.context {
305			f.write_str(context)?;
306			true
307		} else {
308			false
309		};
310
311		if let Some(err) = &self.error {
312			if has_context {
313				f.write_str(": ")?;
314			}
315			write!(f, "{err}")?;
316		}
317
318		Ok(())
319	}
320}
321
322/// Trait accepts [`Item`]s for further processing.
323///
324/// This is a kind of an iterator over all items which are included in a
325/// [`Profile`](`crate::profile::Profile`).
326pub trait Visitor {
327	/// Accepts a [`File`] item for further processing.
328	fn accept_file<'a>(
329		&mut self,
330		source: &PunktfSource,
331		profile: &LayeredProfile,
332		file: &File<'a>,
333	) -> Result;
334
335	/// Accepts a [`Directory`] item for further processing.
336	fn accept_directory<'a>(
337		&mut self,
338		source: &PunktfSource,
339		profile: &LayeredProfile,
340		directory: &Directory<'a>,
341	) -> Result;
342
343	/// Accepts a [`Symlink`] item for further processing.
344	fn accept_link(
345		&mut self,
346		source: &PunktfSource,
347		profile: &LayeredProfile,
348		symlink: &Symlink,
349	) -> Result;
350
351	/// Accepts a [`Rejected`] item for further processing.
352	///
353	/// This is called instead of [`Visitor::accept_file`],
354	/// [`Visitor::accept_directory`] or [`Visitor::accept_link`] when
355	/// the [`Item`] is rejected.
356	fn accept_rejected<'a>(
357		&mut self,
358		source: &PunktfSource,
359		profile: &LayeredProfile,
360		rejected: &Rejected<'a>,
361	) -> Result;
362
363	/// Accepts a [`Errored`] item for further processing.
364	///
365	/// This is called instead of [`Visitor::accept_file`],
366	/// [`Visitor::accept_directory`] or [`Visitor::accept_link`] when
367	/// an error is encountered for an [`Item`] .
368	fn accept_errored<'a>(
369		&mut self,
370		source: &PunktfSource,
371		profile: &LayeredProfile,
372		errored: &Errored<'a>,
373	) -> Result;
374}
375
376/// Walks over each item of a [`LayeredProfile`](`crate::profile::LayeredProfile`)
377/// and calls the appropriate functions of the given visitor.
378#[derive(Debug)]
379pub struct Walker<'a> {
380	// Filter? "--filter='name=*'"
381	// Sort by priority and eliminate duplicate lower ones
382	/// The profile to walk.
383	profile: &'a LayeredProfile,
384}
385
386impl<'a> Walker<'a> {
387	/// Creates a new instance.
388	///
389	/// The [`LayeredProfile::dotfiles`](`crate::profile::LayeredProfile::dotfiles`)
390	/// will be sorted by [`Dotfile::priority`](`crate::profile::dotfile::Dotfile::priority`)
391	/// to avoid unnecessary read/write operations during a deployment.
392	pub fn new(profile: &'a mut LayeredProfile) -> Self {
393		{
394			let dotfiles = &mut profile.dotfiles;
395			// Sorty highest to lowest by priority
396			dotfiles.sort_by_key(|(_, d)| -(d.priority.map(|p| p.0).unwrap_or(0) as i64));
397		};
398
399		Self { profile }
400	}
401
402	/// Walks the profile and calls the appropriate functions on the given [`Visitor`].
403	pub fn walk(&self, source: &PunktfSource, visitor: &mut impl Visitor) -> Result {
404		for dotfile in self.profile.dotfiles() {
405			self.walk_dotfile(source, visitor, dotfile)?;
406		}
407
408		for link in self.profile.symlinks() {
409			self.walk_link(source, visitor, link)?;
410		}
411
412		Ok(())
413	}
414
415	/// Walks each item of a [`Dotfile`](`crate::profile::dotfile::Dotfile`).
416	fn walk_dotfile(
417		&self,
418		source: &PunktfSource,
419		visitor: &mut impl Visitor,
420		dotfile: &Dotfile,
421	) -> Result {
422		let source_path = match self.resolve_source_path(source, dotfile) {
423			Ok(p) => p,
424			Err(err) => {
425				let paths = Paths::new(dotfile.path.clone(), dotfile.path.clone());
426
427				return self.walk_errored(
428					source,
429					visitor,
430					paths,
431					dotfile,
432					Some(err),
433					Some("Failed to resolve source path of dotfile"),
434				);
435			}
436		};
437
438		let target_path = match self.resolve_target_path(dotfile, source_path.is_dir()) {
439			Ok(p) => p,
440			Err(err) => {
441				let paths = Paths::new(dotfile.path.clone(), dotfile.path.clone());
442
443				return self.walk_errored(
444					source,
445					visitor,
446					paths,
447					dotfile,
448					Some(err),
449					Some("Failed to resolve target path of dotfile"),
450				);
451			}
452		};
453
454		let paths = Paths::new(source_path, target_path);
455
456		if !paths.child_source_path().exists() {
457			let context = format!(
458				"Dotfile at {} does not exist",
459				paths.child_source_path().display()
460			);
461
462			return self.walk_errored(
463				source,
464				visitor,
465				paths,
466				dotfile,
467				None::<io::Error>,
468				Some(context),
469			);
470		};
471
472		self.walk_path(source, visitor, paths, dotfile)
473	}
474
475	/// Walks a specific path of a [`Dotfile`](`crate::profile::dotfile::Dotfile`).
476	///
477	/// This either calls [`Walker::walk_file`] or [`Walker::walk_directory`].
478	fn walk_path(
479		&self,
480		source: &PunktfSource,
481		visitor: &mut impl Visitor,
482		paths: Paths,
483		dotfile: &Dotfile,
484	) -> Result {
485		let source_path = paths.child_source_path();
486
487		if !self.accept(&source_path) {
488			return self.walk_rejected(source, visitor, paths, dotfile);
489		}
490
491		// For now dont follow symlinks (`metadata()` would get the metadata of the target of a
492		// link).
493		let metadata = match source_path.symlink_metadata() {
494			Ok(metadata) => metadata,
495			Err(err) => {
496				return self.walk_errored(
497					source,
498					visitor,
499					paths,
500					dotfile,
501					Some(err),
502					Some("Failed to resolve metadata"),
503				);
504			}
505		};
506
507		if metadata.is_file() {
508			self.walk_file(source, visitor, paths, dotfile)
509		} else if metadata.is_dir() {
510			self.walk_directory(source, visitor, paths, dotfile)
511		} else {
512			let err = io::Error::new(io::ErrorKind::Unsupported, "Invalid file type");
513
514			self.walk_errored(source, visitor, paths, dotfile, Some(err), None::<&str>)
515		}
516	}
517
518	/// Calls [`Visitor::accept_file`].
519	fn walk_file(
520		&self,
521		source: &PunktfSource,
522		visitor: &mut impl Visitor,
523		paths: Paths,
524		dotfile: &Dotfile,
525	) -> Result {
526		let file = File(Item::new(source, paths, dotfile));
527
528		visitor.accept_file(source, self.profile, &file)
529	}
530
531	/// Calls [`Visitor::accept_directory`].
532	///
533	/// After that it walks all child items of it.
534	fn walk_directory(
535		&self,
536		source: &PunktfSource,
537		visitor: &mut impl Visitor,
538		paths: Paths,
539		dotfile: &Dotfile,
540	) -> Result {
541		let source_path = paths.child_source_path();
542
543		let directory = Directory(Item::new(source, paths.clone(), dotfile));
544
545		visitor.accept_directory(source, self.profile, &directory)?;
546
547		let read_dir = match std::fs::read_dir(source_path) {
548			Ok(path) => path,
549			Err(err) => {
550				return self.walk_errored(
551					source,
552					visitor,
553					paths,
554					dotfile,
555					Some(err),
556					Some("Failed to read directory"),
557				);
558			}
559		};
560
561		for dent in read_dir {
562			let dent = match dent {
563				Ok(dent) => dent,
564				Err(err) => {
565					return self.walk_errored(
566						source,
567						visitor,
568						paths,
569						dotfile,
570						Some(err),
571						Some("Failed to read directory"),
572					);
573				}
574			};
575
576			self.walk_path(
577				source,
578				visitor,
579				paths.clone().with_child(dent.file_name()),
580				dotfile,
581			)?;
582		}
583
584		Ok(())
585	}
586
587	/// Calls [`Visitor::accept_link`].
588	fn walk_link(
589		&self,
590		source: &PunktfSource,
591		visitor: &mut impl Visitor,
592		link: &link::Symlink,
593	) -> Result {
594		// DO NOT CANONICOLIZE THE PATHS AS THIS WOULD FOLLOW LINKS
595		// TODO: Better error handling
596		let link = Symlink {
597			source_path: self.resolve_path(&link.source_path)?,
598			target_path: self.resolve_path(&link.target_path)?,
599			replace: link.replace,
600		};
601
602		visitor.accept_link(source, self.profile, &link)
603	}
604
605	/// Calls [`Visitor::accept_rejected`].
606	fn walk_rejected(
607		&self,
608		source: &PunktfSource,
609		visitor: &mut impl Visitor,
610		paths: Paths,
611		dotfile: &Dotfile,
612	) -> Result {
613		let rejected = Rejected {
614			item: Item::new(source, paths, dotfile),
615			reason: Cow::Borrowed("Rejected by filter"),
616		};
617
618		visitor.accept_rejected(source, self.profile, &rejected)
619	}
620
621	/// Calls [`Visitor::accept_errored`].
622	fn walk_errored(
623		&self,
624		source: &PunktfSource,
625		visitor: &mut impl Visitor,
626		paths: Paths,
627		dotfile: &Dotfile,
628		error: Option<impl std::error::Error + 'static>,
629		context: Option<impl Into<Cow<'a, str>>>,
630	) -> Result {
631		let errored = Errored {
632			item: Item::new(source, paths, dotfile),
633			error: error.map(|e| e.into()),
634			context: context.map(|c| c.into()),
635		};
636
637		visitor.accept_errored(source, self.profile, &errored)
638	}
639
640	/// Applies final transformations for paths from [`Walker::resolve_source_path`]
641	/// and [`Walker::resolve_target_path`].
642	fn resolve_path(&self, path: &Path) -> io::Result<PathBuf> {
643		let Some(path_str) = path.to_str() else {
644			return Err(io::Error::new(
645				io::ErrorKind::InvalidInput,
646				"File path includes non UTF-8 characters",
647			));
648		};
649
650		shellexpand::full(path_str)
651			.map(|resolved| PathBuf::from(resolved.as_ref()))
652			.map_err(|err| io::Error::new(io::ErrorKind::Other, err))
653	}
654
655	/// Resolves the dotfile to a absolute source path.
656	fn resolve_source_path(&self, source: &PunktfSource, dotfile: &Dotfile) -> io::Result<PathBuf> {
657		self.resolve_path(&source.dotfiles.join(&dotfile.path))
658	}
659
660	/// Resolves the dotfile to a absolute target path.
661	///
662	/// Some special logic is applied for directories.
663	fn resolve_target_path(&self, dotfile: &Dotfile, is_dir: bool) -> io::Result<PathBuf> {
664		let path = if is_dir && dotfile.rename.is_none() && dotfile.overwrite_target.is_none() {
665			self.profile
666				.target_path()
667				.expect("No target path set")
668				.to_path_buf()
669		} else {
670			dotfile
671				.overwrite_target
672				.as_deref()
673				.unwrap_or_else(|| self.profile.target_path().expect("No target path set"))
674				.join(dotfile.rename.as_ref().unwrap_or(&dotfile.path))
675		};
676
677		self.resolve_path(&path)
678	}
679
680	/// TODO
681	const fn accept(&self, _path: &Path) -> bool {
682		// TODO: Apply filter
683		true
684	}
685}
686
687/// An extension trait to [`Visitor`] which adds a new function to accept
688/// template items.
689pub trait TemplateVisitor: Visitor {
690	/// Accepts a template [`File`] item for further processing.
691	///
692	/// This also provides a function to resolve the contents of the template
693	/// by calling it with the original template contents.
694	fn accept_template<'a>(
695		&mut self,
696		source: &PunktfSource,
697		profile: &LayeredProfile,
698		file: &File<'a>,
699		// Returns a function to resolve the content to make the resolving lazy
700		// for upstream visitors.
701		resolve_content: impl FnOnce(&str) -> color_eyre::Result<String>,
702	) -> Result;
703}
704
705/// An extension for a base [`Visitor`] to split up files into normal files and
706/// template files.
707///
708/// All accepted files are checked up on receiving and the either directly send
709/// out with [`Visitor::accept_file`] if they are a normal file or with
710/// [`TemplateVisitor::accept_template`] if it is a template.
711#[derive(Debug)]
712pub struct ResolvingVisitor<V>(V);
713
714impl<V> ResolvingVisitor<V>
715where
716	V: TemplateVisitor,
717{
718	/// Gets the base [`Visitor`].
719	#[allow(clippy::missing_const_for_fn)]
720	pub fn into_inner(self) -> V {
721		self.0
722	}
723}
724
725impl<V: TemplateVisitor> Visitor for ResolvingVisitor<V> {
726	fn accept_file<'a>(
727		&mut self,
728		source: &PunktfSource,
729		profile: &LayeredProfile,
730		file: &File<'a>,
731	) -> Result {
732		if file.dotfile().is_template() {
733			let resolve_fn = |content: &str| {
734				let source = Source::file(&file.source_path, content);
735				let template = Template::parse(source)
736					.with_context(|| format!("File: {}", file.source_path.display()))?;
737
738				template
739					.resolve(Some(profile.variables()), file.dotfile().variables.as_ref())
740					.with_context(|| format!("File: {}", file.source_path.display()))
741			};
742
743			self.0.accept_template(source, profile, file, resolve_fn)
744		} else {
745			self.0.accept_file(source, profile, file)
746		}
747	}
748
749	fn accept_directory<'a>(
750		&mut self,
751		source: &PunktfSource,
752		profile: &LayeredProfile,
753		directory: &Directory<'a>,
754	) -> Result {
755		self.0.accept_directory(source, profile, directory)
756	}
757
758	fn accept_link(
759		&mut self,
760		source: &PunktfSource,
761		profile: &LayeredProfile,
762		symlink: &Symlink,
763	) -> Result {
764		self.0.accept_link(source, profile, symlink)
765	}
766
767	fn accept_rejected<'a>(
768		&mut self,
769		source: &PunktfSource,
770		profile: &LayeredProfile,
771		rejected: &Rejected<'a>,
772	) -> Result {
773		self.0.accept_rejected(source, profile, rejected)
774	}
775
776	fn accept_errored<'a>(
777		&mut self,
778		source: &PunktfSource,
779		profile: &LayeredProfile,
780		errored: &Errored<'a>,
781	) -> Result {
782		self.0.accept_errored(source, profile, errored)
783	}
784}