punktf_lib/profile/
mod.rs

1//! Defines profiles and ways to layer multiple of them.
2
3pub mod dotfile;
4pub mod hook;
5pub mod link;
6pub mod source;
7pub mod transform;
8pub mod variables;
9
10use std::collections::{HashMap, HashSet};
11use std::fs::File;
12use std::ops::Deref;
13use std::path::{Path, PathBuf};
14
15use color_eyre::eyre::{bail, eyre, Context};
16use color_eyre::Result;
17use serde::{Deserialize, Serialize};
18
19use crate::profile::hook::Hook;
20use crate::profile::link::Symlink;
21use crate::profile::transform::ContentTransformer;
22use crate::profile::variables::{Variables, Vars};
23use crate::profile::{dotfile::Dotfile, source::PunktfSource};
24
25/// This enum represents all available merge modes `punktf` supports. The merge
26/// mode is important when a file already exists at the target location of a
27/// [`Dotfile`](`crate::profile::dotfile::Dotfile`).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub enum MergeMode {
31	/// Overwrites the existing file.
32	Overwrite,
33
34	/// Keeps the existing file.
35	Keep,
36
37	/// Asks the user for input to decide what to do.
38	Ask,
39}
40
41impl Default for MergeMode {
42	fn default() -> Self {
43		Self::Overwrite
44	}
45}
46
47/// This struct represents the priority a
48/// [`Dotfile`](`crate::profile::dotfile::Dotfile`)
49/// can have. A bigger value means a higher priority. Dotfiles with lower priority
50/// won't be able to overwrite already deployed dotfiles with a higher one.
51#[derive(
52	Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
53)]
54#[serde(deny_unknown_fields)]
55pub struct Priority(pub u32);
56
57impl Priority {
58	/// Creates a new instance with the given `priority`.
59	pub const fn new(priority: u32) -> Self {
60		Self(priority)
61	}
62}
63
64/// A profile is a collection of dotfiles and variables, options and hooks.
65#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct Profile {
68	/// Aliases for this profile which can be used instead of the file name.
69	#[serde(skip_serializing_if = "Vec::is_empty", default)]
70	pub aliases: Vec<String>,
71
72	/// Defines the base profile. All settings from the base are merged with the
73	/// current profile. The settings from the current profile take precedence.
74	/// Dotfiles are merged on the dotfile level (not specific dotfile settings level).
75	#[serde(skip_serializing_if = "Vec::is_empty", default)]
76	pub extends: Vec<String>,
77
78	/// Variables of the profile. Each dotfile will have this environment.
79	#[serde(skip_serializing_if = "Option::is_none", default)]
80	pub variables: Option<Variables>,
81
82	/// Content transform of the profile. Each dotfile will have these applied.
83	#[serde(skip_serializing_if = "Vec::is_empty", default)]
84	pub transformers: Vec<ContentTransformer>,
85
86	/// Target root path of the deployment. Will be used as file stem for the dotfiles
87	/// when not overwritten by
88	/// [`Dotfile::overwrite_target`](`crate::profile::dotfile::Dotfile::overwrite_target`).
89	#[serde(skip_serializing_if = "Option::is_none", default)]
90	pub target: Option<PathBuf>,
91
92	/// Hook will be executed once before the deployment begins. If the hook fails
93	/// the deployment will not be continued.
94	#[serde(skip_serializing_if = "Vec::is_empty", default)]
95	pub pre_hooks: Vec<Hook>,
96
97	/// Hook will be executed once after the deployment begins.
98	#[serde(skip_serializing_if = "Vec::is_empty", default)]
99	pub post_hooks: Vec<Hook>,
100
101	/// Dotfiles which will be deployed.
102	#[serde(skip_serializing_if = "Vec::is_empty", default)]
103	pub dotfiles: Vec<Dotfile>,
104
105	/// Symlinks which will be deployed.
106	#[serde(rename = "links", skip_serializing_if = "Vec::is_empty", default)]
107	pub symlinks: Vec<Symlink>,
108}
109
110impl Profile {
111	/// Tries to load a profile from the file located at `path`.
112	///
113	/// This function will try to guess the correct deserializer by the file
114	/// extension of `path`
115	///
116	/// # Errors
117	///
118	/// An error is returned if the file does not exist or could not be read.
119	/// An error is returned if the file extension is unknown or missing.
120	pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
121		let path = path.as_ref();
122
123		/// Inner function is used to reduce monomorphizes as path here is a
124		/// concrete type and no generic one.
125		fn from_file_inner(path: &Path) -> Result<Profile> {
126			// Allowed in case no feature is present.
127			#[allow(unused_variables)]
128			let file = File::open(path)?;
129
130			let extension = path.extension().ok_or_else(|| {
131				std::io::Error::new(
132					std::io::ErrorKind::InvalidData,
133					"Failed to get file extension for profile",
134				)
135			})?;
136
137			#[cfg(feature = "profile-json")]
138			{
139				if extension.eq_ignore_ascii_case("json") {
140					return Profile::from_json_file(file);
141				}
142			}
143
144			#[cfg(feature = "profile-yaml")]
145			{
146				if extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") {
147					return Profile::from_yaml_file(file);
148				}
149			}
150
151			Err(eyre!(
152				"Found unsupported file extension for profile (extension: {:?})",
153				extension
154			))
155		}
156
157		from_file_inner(path).wrap_err(format!(
158			"Failed to process profile at path `{}`",
159			path.display()
160		))
161	}
162
163	/// Tries to load a profile from a json file.
164	#[cfg(feature = "profile-json")]
165	fn from_json_file(file: File) -> Result<Self> {
166		serde_json::from_reader(&file).map_err(|err| {
167			color_eyre::Report::msg(err).wrap_err("Failed to parse profile from json content.")
168		})
169	}
170
171	/// Tries to load a profile from a yaml file.
172	#[cfg(feature = "profile-yaml")]
173	fn from_yaml_file(file: File) -> Result<Self> {
174		serde_yaml::from_reader(file).map_err(|err| {
175			color_eyre::Report::msg(err).wrap_err("Failed to parse profile from yaml content.")
176		})
177	}
178}
179
180/// Stores variables defined on different layers.
181/// Layers are created when a profile is extended.
182#[derive(Default, Debug, Clone, PartialEq, Eq)]
183pub struct LayeredVariables {
184	/// Stores the variables together with the index, which indexed
185	/// [`LayeredProfile::profile_names`](`crate::profile::LayeredProfile::profile_names`)
186	/// to retrieve the name of the profile, the variable came from.
187	pub inner: HashMap<String, (usize, String)>,
188}
189
190impl Vars for LayeredVariables {
191	fn var<K>(&self, key: K) -> Option<&str>
192	where
193		K: AsRef<str>,
194	{
195		self.inner.get(key.as_ref()).map(|(_, value)| value.deref())
196	}
197}
198
199/// Defines a profile that appears on different layers.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct LayeredProfile {
202	/// All names of the profile which where collected from the extend chain.
203	pub profile_names: Vec<String>,
204
205	/// The target of the deployment.
206	///
207	/// This is the first value found by traversing the extend chain from the
208	/// top.
209	pub target: Option<(usize, PathBuf)>,
210
211	/// The variables collected from all profiles of the extend chain.
212	pub variables: LayeredVariables,
213
214	/// The content transformer collected from all profiles of the extend chain.
215	pub transformers: Vec<(usize, ContentTransformer)>,
216
217	/// The pre-hooks collected from all profiles of the extend chain.
218	pub pre_hooks: Vec<(usize, Hook)>,
219
220	/// The post-hooks collected from all profiles of the extend chain.
221	pub post_hooks: Vec<(usize, Hook)>,
222
223	/// The dotfiles collected from all profiles of the extend chain.
224	///
225	/// The index indexes into
226	/// [`LayeredProfile::profile_names`](`crate::profile::LayeredProfile::profile_names`)
227	/// to retrieve the name of the profile from which the dotfile came from.
228	pub dotfiles: Vec<(usize, Dotfile)>,
229
230	/// The symlinks collected from all profiles of the extend chain.
231	///
232	/// The index indexes into
233	/// [`LayeredProfile::profile_names`](`crate::profile::LayeredProfile::profile_names`)
234	/// to retrieve the name of the profile from which the link came from.
235	pub symlinks: Vec<(usize, Symlink)>,
236}
237
238impl LayeredProfile {
239	/// Creates a new builder for a layered profile.
240	pub fn build() -> LayeredProfileBuilder {
241		LayeredProfileBuilder::default()
242	}
243
244	/// Returns the target path for the profile together with the index into
245	/// [`LayeredProfile::profile_names`](`crate::profile::LayeredProfile::profile_names`).
246	pub fn target(&self) -> Option<(&str, &Path)> {
247		self.target
248			.as_ref()
249			.map(|(name_idx, path)| (self.profile_names[*name_idx].as_ref(), path.deref()))
250	}
251
252	/// Returns the target path for the profile.
253	pub fn target_path(&self) -> Option<&Path> {
254		self.target.as_ref().map(|(_, path)| path.deref())
255	}
256
257	/// Returns all collected variables for the profile.
258	pub const fn variables(&self) -> &LayeredVariables {
259		&self.variables
260	}
261
262	/// Returns all the count of collected transformers for the profile.
263	pub fn transformers_len(&self) -> usize {
264		self.transformers.len()
265	}
266
267	/// Returns all collected content transformer for the profile.
268	pub fn transformers(&self) -> impl Iterator<Item = &ContentTransformer> {
269		self.transformers.iter().map(|(_, transformer)| transformer)
270	}
271
272	/// Returns all collected pre-hooks for the profile.
273	pub fn pre_hooks(&self) -> impl Iterator<Item = &Hook> {
274		self.pre_hooks.iter().map(|(_, hook)| hook)
275	}
276
277	/// Returns all collected post-hooks for the profile.
278	pub fn post_hooks(&self) -> impl Iterator<Item = &Hook> {
279		self.post_hooks.iter().map(|(_, hook)| hook)
280	}
281
282	/// Returns all collected dotfiles for the profile.
283	pub fn dotfiles(&self) -> impl Iterator<Item = &Dotfile> {
284		self.dotfiles.iter().map(|(_, dotfile)| dotfile)
285	}
286
287	/// Returns all collected symlinks for the profile.
288	pub fn symlinks(&self) -> impl Iterator<Item = &Symlink> {
289		self.symlinks.iter().map(|(_, symlink)| symlink)
290	}
291}
292
293/// Collects different profiles from multiple layers.
294#[derive(Default, Debug, Clone, PartialEq, Eq)]
295pub struct LayeredProfileBuilder {
296	/// All names of the profile which where collected from the extend chain.
297	profile_names: Vec<String>,
298
299	/// The profiles which make up the layered profile. The first is the root
300	/// profile from which the others where imported.
301	profiles: Vec<Profile>,
302}
303
304impl LayeredProfileBuilder {
305	/// Adds a new `profile` with the given `name` to the builder.
306	pub fn add(&mut self, name: String, profile: Profile) -> &mut Self {
307		self.profiles.push(profile);
308		self.profile_names.push(name);
309
310		self
311	}
312
313	/// Consumes self and returns a new layered profile.
314	pub fn finish(self) -> LayeredProfile {
315		let target = self.profiles.iter().enumerate().find_map(|(idx, profile)| {
316			profile
317				.target
318				.as_ref()
319				.map(move |target| (idx, target.to_path_buf()))
320		});
321
322		let mut variables = LayeredVariables::default();
323
324		for (idx, vars) in self
325			.profiles
326			.iter()
327			.enumerate()
328			.filter_map(move |(idx, profile)| profile.variables.as_ref().map(|vars| (idx, vars)))
329		{
330			for (key, value) in vars.inner.iter() {
331				if !variables.inner.contains_key(key) {
332					variables
333						.inner
334						.insert(key.to_owned(), (idx, value.to_owned()));
335				}
336			}
337		}
338
339		let mut transformers = Vec::new();
340
341		for (idx, transformer) in self
342			.profiles
343			.iter()
344			.enumerate()
345			.map(|(idx, profile)| (idx, &profile.transformers))
346		{
347			for t in transformer.iter() {
348				if !transformers.iter().any(|(_, tt)| t == tt) {
349					transformers.push((idx, *t));
350				}
351			}
352		}
353
354		let pre_hooks = self
355			.profiles
356			.iter()
357			.enumerate()
358			.flat_map(|(idx, profile)| {
359				profile
360					.pre_hooks
361					.iter()
362					.cloned()
363					.map(move |hook| (idx, hook))
364			})
365			.collect();
366
367		let post_hooks = self
368			.profiles
369			.iter()
370			.enumerate()
371			.flat_map(|(idx, profile)| {
372				profile
373					.post_hooks
374					.iter()
375					.cloned()
376					.map(move |hook| (idx, hook))
377			})
378			.collect();
379
380		let mut added_dotfile_paths = HashSet::new();
381		let mut dotfiles = Vec::new();
382
383		for (idx, dfiles) in self
384			.profiles
385			.iter()
386			.enumerate()
387			.map(|(idx, profile)| (idx, &profile.dotfiles))
388		{
389			for dotfile in dfiles.iter() {
390				if !added_dotfile_paths.contains(&dotfile.path) {
391					dotfiles.push((idx, dotfile.clone()));
392					added_dotfile_paths.insert(dotfile.path.clone());
393				}
394			}
395		}
396
397		let symlinks = self
398			.profiles
399			.iter()
400			.enumerate()
401			.flat_map(|(idx, profile)| {
402				profile
403					.symlinks
404					.iter()
405					.cloned()
406					.map(move |link| (idx, link))
407			})
408			.collect();
409
410		LayeredProfile {
411			profile_names: self.profile_names,
412			target,
413			variables,
414			transformers,
415			pre_hooks,
416			post_hooks,
417			dotfiles,
418			symlinks,
419		}
420	}
421}
422
423/// A minimal struct to read the `aliases` from a profile file.
424///
425/// This is used for profile name resolution.
426#[derive(Default, Debug, Serialize, Deserialize)]
427#[serde(default)]
428struct Aliases {
429	/// Aliases of a profile.
430	///
431	/// These can be used in place of the profile name for cli and extend resolution.
432	aliases: Vec<String>,
433}
434
435/// Collects all profile names and aliases from the `profiles` directory.
436pub fn collect_profile_names(source: &PunktfSource) -> Result<HashMap<String, PathBuf>> {
437	log::info!("Collecting profile names and aliases");
438
439	/// Tries to read all alias from a given file.
440	fn get_aliases(path: &Path, extension: &str) -> Option<Aliases> {
441		let Ok(file) = File::open(path) else {
442			log::debug!("[{}] Failed to read content", path.display());
443			return None;
444		};
445
446		#[cfg(feature = "profile-json")]
447		{
448			if extension.eq_ignore_ascii_case("json") {
449				let Ok(aliases) = serde_json::from_reader(file) else {
450					log::debug!("[{}] Failed to read aliases", path.display());
451					return None;
452				};
453
454				return Some(aliases);
455			}
456		}
457
458		#[cfg(feature = "profile-yaml")]
459		{
460			if extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") {
461				let Ok(aliases) = serde_yaml::from_reader(file) else {
462					log::debug!("[{}] Failed to read aliases", path.display());
463					return None;
464				};
465
466				return Some(aliases);
467			}
468		}
469
470		None
471	}
472
473	let mut names = HashMap::new();
474
475	let dents = source.profiles().read_dir()?;
476	for dent in dents {
477		let dent = dent?;
478		let path = dent.path();
479
480		let Ok(ft) = dent.file_type() else {
481			log::debug!("[{}] Failed to get file type", path.display());
482			continue;
483		};
484
485		if !ft.is_file() {
486			log::debug!("[{}] Not a file", path.display());
487			continue;
488		}
489
490		let Some(extension) = path.extension().and_then(|e| e.to_str()) else {
491			log::debug!("[{}] Failed to get file extension", path.display());
492			continue;
493		};
494
495		let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
496			log::debug!("[{}] Failed to get file name", path.display());
497			continue;
498		};
499		// Remove extension and `.`
500		let name = &name[..(name.len() - (extension.len() + 1))];
501
502		for alias in get_aliases(&path, extension)
503			.into_iter()
504			.flat_map(|a| a.aliases.into_iter())
505		{
506			log::debug!("[{}] Adding alias {}", path.display(), alias);
507
508			if let Some(evicted) = names.insert(alias.clone(), path.clone()) {
509				bail!(
510					"[{}] The profile alias {} is already taken by {}",
511					path.display(),
512					alias,
513					evicted.display()
514				);
515			}
516		}
517
518		if let Some(evicted) = names.insert(name.to_string(), path.clone()) {
519			bail!(
520				"[{}] The profile name {} is already taken by {}",
521				path.display(),
522				name,
523				evicted.display()
524			);
525		}
526	}
527
528	log::info!("Found {} profile names and aliases", names.len());
529
530	Ok(names)
531}
532
533/// Recursively resolves a profile and it's [extend
534/// chain](`crate::profile::Profile::extends`) and adds them to the layered
535/// profile in order of occurrence.
536pub fn resolve_profile(
537	builder: &mut LayeredProfileBuilder,
538	source: &PunktfSource,
539	name: &str,
540) -> Result<()> {
541	/// Recursive resolution of all profiles needed.
542	///
543	/// Checks for cycles while resolving.
544	fn _resolve_profile_inner(
545		profiles: &HashMap<String, PathBuf>,
546		builder: &mut LayeredProfileBuilder,
547		name: &str,
548		resolved_profiles: &mut Vec<String>,
549	) -> Result<()> {
550		log::trace!("Resolving profile `{}`", name);
551
552		let path = profiles
553			.get(name)
554			.ok_or_else(|| eyre!("No profile found for name {}", name))?;
555
556		let mut profile = Profile::from_file(path)?;
557		let name = name.to_string();
558
559		if !profile.extends.is_empty() && resolved_profiles.contains(&name) {
560			// profile was already resolve and has "children" which will lead to
561			// a loop while resolving
562			return Err(eyre!(
563			"Circular dependency detected while parsing `{}` (required by: `{:?}`) (Stack: {:#?})",
564			name,
565			resolved_profiles.last(),
566			resolved_profiles
567		));
568		}
569
570		let mut extends = Vec::new();
571		std::mem::swap(&mut extends, &mut profile.extends);
572
573		builder.add(name.clone(), profile);
574
575		resolved_profiles.push(name);
576
577		for child in extends {
578			_resolve_profile_inner(profiles, builder, &child, resolved_profiles)?;
579		}
580
581		let _ = resolved_profiles
582			.pop()
583			.expect("Misaligned push/pop operation");
584
585		Ok(())
586	}
587
588	let available_profiles = collect_profile_names(source)?;
589	let mut resolved_profiles = Vec::new();
590
591	_resolve_profile_inner(&available_profiles, builder, name, &mut resolved_profiles)
592}
593
594#[cfg(test)]
595mod tests {
596	use std::collections::HashMap;
597
598	use super::*;
599	use crate::profile::hook::Hook;
600	use crate::profile::variables::Variables;
601	use crate::profile::Profile;
602	use crate::profile::{MergeMode, Priority};
603
604	#[test]
605	fn priority_order() {
606		crate::tests::setup_test_env();
607
608		assert!(Priority::default() == Priority::new(0));
609		assert!(Priority::new(0) == Priority::new(0));
610		assert!(Priority::new(2) > Priority::new(1));
611	}
612
613	#[test]
614	#[cfg(feature = "profile-json")]
615	fn profile_serde() {
616		crate::tests::setup_test_env();
617
618		let mut profile_vars = HashMap::new();
619		profile_vars.insert(String::from("RUSTC_VERSION"), String::from("XX.YY"));
620		profile_vars.insert(String::from("RUSTC_PATH"), String::from("/usr/bin/rustc"));
621
622		let mut dotfile_vars = HashMap::new();
623		dotfile_vars.insert(String::from("RUSTC_VERSION"), String::from("55.22"));
624		dotfile_vars.insert(String::from("USERNAME"), String::from("demo"));
625
626		let profile = Profile {
627			extends: Vec::new(),
628			aliases: vec![],
629			variables: Some(Variables {
630				inner: profile_vars,
631			}),
632			transformers: Vec::new(),
633			target: Some(PathBuf::from("/home/demo/.config")),
634			pre_hooks: vec![Hook::new("echo \"Foo\"")],
635			post_hooks: vec![Hook::new("profiles/test.sh")],
636			dotfiles: vec![
637				Dotfile {
638					path: PathBuf::from("init.vim.ubuntu"),
639					rename: Some(PathBuf::from("init.vim")),
640					overwrite_target: None,
641					priority: Some(Priority::new(2)),
642					variables: None,
643					transformers: Vec::new(),
644					merge: Some(MergeMode::Overwrite),
645					template: None,
646				},
647				Dotfile {
648					path: PathBuf::from(".bashrc"),
649					rename: None,
650					overwrite_target: Some(PathBuf::from("/home/demo")),
651					priority: None,
652					variables: Some(Variables {
653						inner: dotfile_vars,
654					}),
655					transformers: Vec::new(),
656					merge: Some(MergeMode::Overwrite),
657					template: Some(false),
658				},
659			],
660			symlinks: vec![],
661		};
662
663		let json = serde_json::to_string(&profile).expect("Profile to be serializeable");
664
665		let parsed: Profile = serde_json::from_str(&json).expect("Profile to be deserializable");
666
667		assert_eq!(parsed, profile);
668	}
669}