Adds a pijul_config::Config struct that uses the figment crate to merge global and local configuration files.
Notable changes:
pijul_config::Config struct that merges user configuration from global/local config filespijul_config::Shared struct containing all of the configuration options that can be used in both global and local config filespijul_config::{global::Global, local::Local} structs that contain global/local-specific options along with the shared options (these are what get serialized/deserialized on disk)pijul_config::Remote - it it seemed to be completely unused and a duplicate of pijul_config::RemoteConfig (now renamed to pijul_config::Remote to replace it)pijul_repository to pijul_configFuture work:
pijul_config::shell_cmd() should be moved to pijul_remote--config flag)pijul_config crate (currently subcommands have various hacks that can be removed)Since this is already quite a large change, migration of the codebase to this refactored crate will happen in a different change.
Z4PPQZUGHT5F5VFFBQBIW2J3OLHP4SF33QMT6POFCX6JS7L6G7GQC SXEYMYF7P4RZMZ46WPL4IZUTSQ2ATBWYZX7QNVMS3SGOYXYOHAGQC L4JXJHWXYNCL4QGJXNKKTOKKTAXKKXBJUUY7HFZGEUZ5A2V5H34QC SEWGHUHQEEBJR7UPG3PSU7DSM376R43QEYENAZK325W46DCFMXKAC BZSC7VMYSFRXDHDDAMCDR6X67FN5VWIBOSE76BQLX7OCVOJFUA3AC SLJ3OHD4F6GJGZ3SV2D7DMR3PXYHPSI64X77KZ3RJ24EGEX6ZNQAC CCLLB7OIFNFYJZTG3UCI7536TOCWSCSXR67VELSB466R24WLJSDAC KWAGWB73AMLJFK2Z7SBKHHKKHFRX7AQKXCWDN2MBX72RYCNMB36QC VL7ZYKHBPKLNY5SA5QBW56SJ7LBBCKCGV5UAYLVF75KY6PPBOD4AC 5BB266P6HPUGYEVR7QNNOA62EFPYPUYJ3UMLE5J3LLYMSUWXANIQC I24UEJQLCH2SOXA4UHIYWTRDCHSOPU7AFTRUOTX7HZIAV4AZKYEQC TFPETWTVADLG2DL7WERHJPGMJVOY4WOKCRWB3NZ3YOOQ4CVAUHBAC EEBKW7VTILH6AGGV57ZIJ3DJGYHDSYBWGU3C7Q4WWAKSVNUGIYMQC ZSFJT4SFIAS7WBODRZOFKKG4SVYBC5PC6XY75WYN7CCQ3SMV7IUQC CB7UPUQFOUH6M32KXQZL25EV4IH3XSK56RG3OYNRZBBFEABSNCXQC FVU3Y2U3R7B6SBA5GJ227NR2JQMMFMDREMW63QODA2EUXU3754ZQC FVQYZQFL7WHSC3UUPJ4IWTP7SKTDQ4K6K5HY4EDK3JKXG3CQNZEAC 4OJWMSOWWNT5N4W4FDMKBZB5UARCLGV3SRZVKGR4EFAYFUMUHM7AC 6FRPUHWKBAWIYN6B6YDFQG2SFWZ6MBBYOYXFUN6DRZ4HPDSKFANQC H4AU6QRPRDRFW3V7NN5CJ6DHLEUBYGNLRZ5GYV6ULBGRMOPCJQXQC DOEG3V7UAVYBKLIPHSNWWONYDORKNJEZL2LD4EWGGUDB2SK6BOFQC QCPIBC6MDPFE42KWELKZQ3ORNEOPSBXR7C7H6Z3ZT62RNVBFN73QC EJ7TFFOWLM5EXYX57NJZZX3NLPBLLMRX7CGJYC75DJZ5LYXOQPJAC LZOGKBJXRQJKXHYNNENJFGNLP5SHIXGSV6HDB7UVOP7FSA5EUNCQC RVAH6PXA7H7NUDTF7Q52I7EXGXVJVMGI2LTNN6L3MVEDEMAXVH4AC X642QQQTS4X2DENIZT7PGJN2M2FYVFMGGANXSZHJ7LBP6442Z6IAC 7UU3TV5W23QA7LLRBSBXEYPRMIVXPW4FNENEEE7ZEJYXDLXHVX4AC HRNMY2PGRTO7DCLJJ3R3HUOBFYQLGYE4SXPNZ4CP6IJKFHKMXXMAC 2TWREKSRQN3QBIIJQ6Q4T3RVBTKSYA5N6W4YBRMRWD22WWS3DGQAC 2MKP7CB7FKQUNEAV3YPEJ7FNFW75VGGQIYPQRI54BFXGCUOQESPAC HJVWPKWVSL5ZXALZOT4BOQUWWNGH62OU6YLSZQQEIOB37QQGHK6AC LTI3LT2GJHQMH2G2RYVSKR4IZJY24L6O2KIZTRNKLZPJMOKTD56AC 67GIAQEUQG3KUD7YTYNUWK33BKWPFVNT4YPQMZ3RCALOZ2STDLRQC OUEZV7ELFLRUF5LPVVBPLUSYUD77UZ7CVZAEB6G35CNISGQV6YQAC use std::path::PathBuf;use serde_derive::{Deserialize, Serialize};#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Template {pub message: Option<PathBuf>,pub description: Option<PathBuf>,}
use std::collections::HashMap;use serde_derive::{Deserialize, Serialize};#[derive(Clone, Debug, Serialize, Deserialize)]#[serde(untagged)]pub enum RemoteConfig {Ssh {name: String,ssh: String,},Http {name: String,http: String,#[serde(default)]headers: HashMap<String, RemoteHttpHeader>,},}impl RemoteConfig {pub fn name(&self) -> &str {match self {RemoteConfig::Ssh { name, .. } => name,RemoteConfig::Http { name, .. } => name,}}pub fn url(&self) -> &str {match self {RemoteConfig::Ssh { ssh, .. } => ssh,RemoteConfig::Http { http, .. } => http,}}pub fn db_uses_name(&self) -> bool {match self {RemoteConfig::Ssh { .. } => false,RemoteConfig::Http { .. } => true,}}}#[derive(Clone, Debug, Serialize, Deserialize)]#[serde(untagged)]pub enum RemoteHttpHeader {String(String),Shell(Shell),}#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Shell {pub shell: String,}
use std::fs::File;use std::io::{Read, Write};use std::path::{Path, PathBuf};use crate::remote::RemoteConfig;use crate::{REPOSITORY_CONFIG_FILE, Shared};use serde_derive::{Deserialize, Serialize};#[derive(Clone, Debug, Serialize, Deserialize, Default)]pub struct Local {#[serde(skip)]source_file: Option<PathBuf>,pub default_remote: Option<String>,#[serde(default, skip_serializing_if = "Vec::is_empty")]pub extra_dependencies: Vec<String>,#[serde(default, skip_serializing_if = "Vec::is_empty")]pub remotes: Vec<RemoteConfig>,#[serde(flatten)]pub shared_config: Shared,}impl Local {pub fn config_file(repository_path: &Path) -> PathBuf {repository_path.join(libpijul::DOT_DIR).join(REPOSITORY_CONFIG_FILE)}pub fn read_contents(config_path: &Path) -> Result<String, anyhow::Error> {let mut config_file = File::open(config_path)?;let mut file_contents = String::new();config_file.read_to_string(&mut file_contents)?;Ok(file_contents)}pub fn parse_contents(config_path: &Path, toml_data: &str) -> Result<Self, anyhow::Error> {let mut config: Self = toml::from_str(&toml_data)?;// Store the location of the original configuration file, so it can later be written to// The `source_file` field is annotated with `#[serde(skip)]` and should be always be None unless set manuallyassert!(config.source_file.is_none());config.source_file = Some(config_path.to_path_buf());Ok(config)}pub fn write(&self) -> Result<(), anyhow::Error> {let mut config_file = File::create(self.source_file.clone().unwrap())?;let file_contents = toml::to_string_pretty(self)?;config_file.write_all(file_contents.as_bytes())?;Ok(())}}
#[derive(Debug, Default, Serialize, Deserialize)]pub struct Global {#[serde(default)]pub author: Author,
pub const REPOSITORY_CONFIG_FILE: &str = "config";pub const GLOBAL_CONFIG_FILE: &str = ".pijulconfig";pub const CONFIG_DIR: &str = "pijul";pub const CONFIG_FILE: &str = "config.toml";#[derive(Clone, Debug, Default, Serialize, Deserialize)]pub struct Shared {
pub template: Option<Templates>,pub ignore_kinds: Option<HashMap<String, Vec<String>>>,}#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]pub struct Author {// Older versions called this 'name', but 'username' is more descriptive#[serde(alias = "name", default, skip_serializing_if = "String::is_empty")]pub username: String,#[serde(alias = "full_name", default, skip_serializing_if = "String::is_empty")]pub display_name: String,#[serde(default, skip_serializing_if = "String::is_empty")]pub email: String,#[serde(default, skip_serializing_if = "String::is_empty")]pub origin: String,// This has been moved to identity::Config, but we should still be able to read the values#[serde(default, skip_serializing)]pub key_path: Option<PathBuf>,}impl Default for Author {fn default() -> Self {Self {username: String::new(),email: String::new(),display_name: whoami::realname(),origin: String::new(),key_path: None,}}}#[derive(Debug, Serialize, Deserialize)]pub enum Choice {#[serde(rename = "auto")]Auto,#[serde(rename = "always")]Always,#[serde(rename = "never")]Never,}impl Default for Choice {fn default() -> Self {Self::Auto}}#[derive(Debug, Serialize, Deserialize)]pub struct Templates {pub message: Option<PathBuf>,pub description: Option<PathBuf>,}pub const GLOBAL_CONFIG_DIR: &str = ".pijulconfig";const CONFIG_DIR: &str = "pijul";pub fn global_config_dir() -> Option<PathBuf> {if let Ok(path) = std::env::var("PIJUL_CONFIG_DIR") {let dir = std::path::PathBuf::from(path);Some(dir)} else if let Some(mut dir) = dirs_next::config_dir() {dir.push(CONFIG_DIR);Some(dir)} else {None}}fn try_load_file(path: impl Into<PathBuf> + AsRef<Path>) -> Option<(io::Result<File>, PathBuf)> {let pp = path.as_ref();match File::open(pp) {Ok(v) => Some((Ok(v), path.into())),Err(e) if e.kind() == io::ErrorKind::NotFound => None,Err(e) => Some((Err(e), path.into())),}
pub template: Option<Template>,#[serde(default)]pub hooks: Hooks,
impl Global {pub fn load() -> Result<(Global, Option<u64>), anyhow::Error> {let res = None.or_else(|| {let mut path = global_config_dir()?;path.push("config.toml");try_load_file(path)}).or_else(|| {// Read from `$HOME/.config/pijul` dirlet mut path = dirs_next::home_dir()?;path.push(".config");path.push(CONFIG_DIR);path.push("config.toml");try_load_file(path)}).or_else(|| {// Read from `$HOME/.pijulconfig`let mut path = dirs_next::home_dir()?;path.push(GLOBAL_CONFIG_DIR);try_load_file(path)});let Some((file, path)) = res else {return Ok((Global::default(), None));};let mut file = file.map_err(|e| {anyhow!("Could not open configuration file at {}", path.display()).context(e)})?;
let mut buf = String::new();file.read_to_string(&mut buf).map_err(|e| {anyhow!("Could not read configuration file at {}", path.display()).context(e)})?;debug!("buf = {:?}", buf);let global: Global = toml::from_str(&buf).map_err(|e| {anyhow!("Could not parse configuration file at {}", path.display()).context(e)})?;let metadata = file.metadata()?;let file_age = metadata.modified()?.duration_since(std::time::SystemTime::UNIX_EPOCH)?.as_secs();
#[derive(Debug, Default, Deserialize)]pub struct Config {// Store a copy of the original files, so that they can be modified independently#[serde(skip)]global_config: Option<Global>,#[serde(skip)]local_config: Option<Local>,
#[derive(Debug, Serialize, Deserialize)]#[serde(untagged)]pub enum RemoteConfig {Ssh {name: String,ssh: String,},Http {name: String,http: String,#[serde(default)]headers: HashMap<String, RemoteHttpHeader>,},}
impl Config {pub fn load(repository_path: &Path) -> Result<Self, anyhow::Error> {let global_config_path = Global::config_file().unwrap();let local_config_path = Local::config_file(&repository_path);
impl RemoteConfig {pub fn name(&self) -> &str {match self {RemoteConfig::Ssh { name, .. } => name,RemoteConfig::Http { name, .. } => name,}}
let global_config_contents = Global::read_contents(&global_config_path)?;let local_config_contents = Local::read_contents(&local_config_path)?;// Validate that the configuration sources are correctlet global_config = Global::parse_contents(&global_config_path, &global_config_contents)?;let local_config = Local::parse_contents(&local_config_path, &local_config_contents)?;
pub fn url(&self) -> &str {match self {RemoteConfig::Ssh { ssh, .. } => ssh,RemoteConfig::Http { http, .. } => http,}
// Merge the two configuration values, using the raw TOML string instead of the deserialized structs.// Figment uses a dictionary to store which fields are set, and using an already-deserialized// struct will guarantee that each layer will override the previous one.//// For example, if the optional `unrecord_changes` field is set as 1 globally but not set locally:// - Using deserialized structs (incorrect behaviour):// - Global config is set to Some(1)// - Local config is set to None - no value was found, so serde inserted the default// - The local config technically has a value set, so the final (incorrect) value is None// - Using strings (correct behaviour):// - Global config is set to Some(1)// - Local config is unset// - The final (correct) value is Some(1)let mut config: Self = Figment::new().merge(Toml::string(&global_config_contents)).merge(Toml::string(&local_config_contents)).extract()?;// These fields are annotated with #[serde(skip)] and therefore should be Noneassert!(config.global_config.is_none());assert!(config.local_config.is_none());// Store the original configuration sources so they can be modified laterconfig.global_config = Some(global_config);config.local_config = Some(local_config);Ok(config)
#[derive(Debug, Serialize, Deserialize)]#[serde(untagged)]pub enum RemoteHttpHeader {String(String),Shell(Shell),}
pub fn local(&self) -> Option<Local> {self.local_config.clone()}
#[derive(Debug, Serialize, Deserialize)]pub struct Shell {pub shell: String,}
/// Choose the right dialoguer theme based on user's configpub fn theme(&self) -> Box<dyn theme::Theme + Send + Sync> {let color_choice = self.colors.unwrap_or_default();
#[derive(Debug, Serialize, Deserialize, Default)]pub struct Hooks {#[serde(default)]pub record: Vec<HookEntry>,
match color_choice {Choice::Auto | Choice::Always => Box::new(theme::ColorfulTheme::default()),Choice::Never => Box::new(theme::SimpleTheme),}}
#[derive(Debug, Serialize, Deserialize)]pub struct HookEntry(toml::Value);#[derive(Debug, Serialize, Deserialize)]struct RawHook {command: String,args: Vec<String>,
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]pub enum Choice {#[default]#[serde(rename = "auto")]Auto,#[serde(rename = "always")]Always,#[serde(rename = "never")]Never,
}impl HookEntry {pub fn run(&self, path: PathBuf) -> Result<(), anyhow::Error> {let (proc, s) = match &self.0 {toml::Value::String(s) => {if s.is_empty() {return Ok(());}(if cfg!(target_os = "windows") {std::process::Command::new("cmd").current_dir(path).args(&["/C", s]).output().expect("failed to execute process")} else {std::process::Command::new(std::env::var("SHELL").unwrap_or("sh".to_string()),).current_dir(path).arg("-c").arg(s).output().expect("failed to execute process")},s.clone(),)}v => {let hook = v.clone().try_into::<RawHook>()?;(std::process::Command::new(&hook.command).current_dir(path).args(&hook.args).output().expect("failed to execute process"),hook.command,)}};if !proc.status.success() {let mut stderr = std::io::stderr();writeln!(stderr, "Hook {:?} exited with code {:?}", s, proc.status)?;std::process::exit(proc.status.code().unwrap_or(1))}Ok(())}}#[derive(Debug, Serialize, Deserialize)]struct Remote_ {ssh: Option<SshRemote>,local: Option<String>,url: Option<String>,}#[derive(Debug)]pub enum Remote {Ssh(SshRemote),Local { local: String },Http { url: String },None,}#[derive(Debug, Clone, Serialize, Deserialize)]pub struct SshRemote {pub addr: String,}impl<'de> serde::Deserialize<'de> for Remote {fn deserialize<D>(deserializer: D) -> Result<Remote, D::Error>whereD: serde::de::Deserializer<'de>,{let r = Remote_::deserialize(deserializer)?;if let Some(ssh) = r.ssh {Ok(Remote::Ssh(ssh))} else if let Some(local) = r.local {Ok(Remote::Local { local })} else if let Some(url) = r.url {Ok(Remote::Http { url })} else {Ok(Remote::None)}}}impl serde::Serialize for Remote {fn serialize<D>(&self, serializer: D) -> Result<D::Ok, D::Error>whereD: serde::ser::Serializer,{let r = match *self {Remote::Ssh(ref ssh) => Remote_ {ssh: Some(ssh.clone()),local: None,url: None,},Remote::Local { ref local } => Remote_ {local: Some(local.to_string()),ssh: None,url: None,},Remote::Http { ref url } => Remote_ {local: None,ssh: None,url: Some(url.to_string()),},Remote::None => Remote_ {local: None,ssh: None,url: None,},};r.serialize(serializer)}
/// Choose the right dialoguer theme based on user's configpub fn load_theme() -> Result<Box<dyn theme::Theme>, anyhow::Error> {if let Ok((config, _)) = Global::load() {let color_choice = config.colors.unwrap_or_default();match color_choice {Choice::Auto | Choice::Always => Ok(Box::new(theme::ColorfulTheme::default())),Choice::Never => Ok(Box::new(theme::SimpleTheme)),}} else {Ok(Box::new(theme::ColorfulTheme::default()))}}
use std::io::Write;use std::path::PathBuf;use serde_derive::{Deserialize, Serialize};#[derive(Clone, Debug, Serialize, Deserialize, Default)]pub struct Hooks {#[serde(default)]pub record: Vec<HookEntry>,}#[derive(Clone, Debug, Serialize, Deserialize)]pub struct HookEntry(toml::Value);#[derive(Debug, Serialize, Deserialize)]struct RawHook {command: String,args: Vec<String>,}impl HookEntry {pub fn run(&self, path: PathBuf) -> Result<(), anyhow::Error> {let (proc, s) = match &self.0 {toml::Value::String(s) => {if s.is_empty() {return Ok(());}(if cfg!(target_os = "windows") {std::process::Command::new("cmd").current_dir(path).args(&["/C", s]).output().expect("failed to execute process")} else {std::process::Command::new(std::env::var("SHELL").unwrap_or("sh".to_string()),).current_dir(path).arg("-c").arg(s).output().expect("failed to execute process")},s.clone(),)}v => {let hook = v.clone().try_into::<RawHook>()?;(std::process::Command::new(&hook.command).current_dir(path).args(&hook.args).output().expect("failed to execute process"),hook.command,)}};if !proc.status.success() {let mut stderr = std::io::stderr();writeln!(stderr, "Hook {:?} exited with code {:?}", s, proc.status)?;std::process::exit(proc.status.code().unwrap_or(1))}Ok(())}}
use crate::author::Author;use crate::{CONFIG_DIR, CONFIG_FILE, GLOBAL_CONFIG_FILE, Shared};use std::collections::HashMap;use std::fs::File;use std::io::{Read, Write};use std::path::{Path, PathBuf};use serde_derive::{Deserialize, Serialize};#[derive(Clone, Debug, Default, Serialize, Deserialize)]pub struct Global {#[serde(skip)]source_file: Option<PathBuf>,#[serde(default)]pub author: Author,#[serde(default, skip_serializing_if = "HashMap::is_empty")]pub ignore_kinds: HashMap<String, Vec<String>>,#[serde(flatten)]pub shared_config: Shared,}impl Global {/// Select which configuration file to usepub fn config_file() -> Option<PathBuf> {// 1. PIJUL_CONFIG_DIR environment variablestd::env::var("PIJUL_CONFIG_DIR").ok().map(PathBuf::from)// 2. ~/.config/pijul/config.toml.or_else(|| match dirs_next::config_dir() {Some(config_dir) => {let config_path = config_dir.join(CONFIG_DIR).join(CONFIG_FILE);match config_path.exists() {true => Some(config_path),false => None,}}None => None,})// 3. ~/.pijulconfig.or_else(|| match dirs_next::home_dir() {Some(home_dir) => {let config_path = home_dir.join(GLOBAL_CONFIG_FILE);match config_path.exists() {true => Some(config_path),false => None,}}None => None,})}pub fn read_contents(config_path: &Path) -> Result<String, anyhow::Error> {let mut config_file = File::open(config_path)?;let mut file_contents = String::new();config_file.read_to_string(&mut file_contents)?;Ok(file_contents)}pub fn parse_contents(config_path: &Path, toml_data: &str) -> Result<Self, anyhow::Error> {let mut config: Self = toml::from_str(&toml_data)?;// Store the location of the original configuration file, so it can later be written to// The `source_file` field is annotated with `#[serde(skip)]` and should be always be None unless set manuallyassert!(config.source_file.is_none());config.source_file = Some(config_path.to_path_buf());Ok(config)}pub fn write(&self) -> Result<(), anyhow::Error> {let mut config_file = File::create(self.source_file.clone().unwrap())?;let file_contents = toml::to_string_pretty(self)?;config_file.write_all(file_contents.as_bytes())?;Ok(())}}// pub fn global_config_dir() -> Option<PathBuf> {// if let Ok(path) = std::env::var("PIJUL_CONFIG_DIR") {// let dir = std::path::PathBuf::from(path);// Some(dir)// } else if let Some(mut dir) = dirs_next::config_dir() {// dir.push(CONFIG_DIR);// Some(dir)// } else {// None// }// }// impl Global {// pub fn load() -> Result<(Global, Option<u64>), anyhow::Error> {// let res = None// .or_else(|| {// let mut path = global_config_dir()?;// path.push("config.toml");// try_load_file(path)// })// .or_else(|| {// // Read from `$HOME/.config/pijul` dir// let mut path = dirs_next::home_dir()?;// path.push(".config");// path.push(CONFIG_DIR);// path.push("config.toml");// try_load_file(path)// })// .or_else(|| {// // Read from `$HOME/.pijulconfig`// let mut path = dirs_next::home_dir()?;// path.push(GLOBAL_CONFIG_DIR);// try_load_file(path)// });// let Some((file, path)) = res else {// return Ok((Global::default(), None));// };// let mut file = file.map_err(|e| {// anyhow!("Could not open configuration file at {}", path.display()).context(e)// })?;// let mut buf = String::new();// file.read_to_string(&mut buf).map_err(|e| {// anyhow!("Could not read configuration file at {}", path.display()).context(e)// })?;// debug!("buf = {:?}", buf);// let global: Global = toml::from_str(&buf).map_err(|e| {// anyhow!("Could not parse configuration file at {}", path.display()).context(e)// })?;// let metadata = file.metadata()?;// let file_age = metadata// .modified()?// .duration_since(std::time::SystemTime::UNIX_EPOCH)?// .as_secs();// Ok((global, Some(file_age)))// }// }
use std::path::PathBuf;use serde_derive::{Deserialize, Serialize};#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]pub struct Author {// Older versions called this 'name', but 'username' is more descriptive#[serde(alias = "name", default, skip_serializing_if = "String::is_empty")]pub username: String,#[serde(alias = "full_name", default, skip_serializing_if = "String::is_empty")]pub display_name: String,#[serde(default, skip_serializing_if = "String::is_empty")]pub email: String,#[serde(default, skip_serializing_if = "String::is_empty")]pub origin: String,// This has been moved to identity::Config, but we should still be able to read the values#[serde(default, skip_serializing)]pub key_path: Option<PathBuf>,}impl Default for Author {fn default() -> Self {Self {username: String::new(),email: String::new(),display_name: whoami::realname(),origin: String::new(),key_path: None,}}}