-
Notifications
You must be signed in to change notification settings - Fork 0
feat/xdg config dir #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e42d280
735913e
5366f66
d8c997b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,85 @@ | ||||||||||||||||||||||||||||||
| use std::fs; | ||||||||||||||||||||||||||||||
| use std::io; | ||||||||||||||||||||||||||||||
| use std::path::Path; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| use serde_json::{Map, Value}; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| use crate::infrastructure::config::app_paths::AppPaths; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const DEFAULT_MEDICATIONS: &str = "[]"; | ||||||||||||||||||||||||||||||
| const DEFAULT_DOSE_RECORDS: &str = "[]"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| fn default_settings() -> Value { | ||||||||||||||||||||||||||||||
| let mut map = Map::new(); | ||||||||||||||||||||||||||||||
| map.insert("vim_enabled".to_string(), Value::Bool(false)); | ||||||||||||||||||||||||||||||
| Value::Object(map) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Bootstrap service that ensures `~/.config/bitpill/` and its data files | ||||||||||||||||||||||||||||||
| /// exist before repositories are constructed. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Rules: | ||||||||||||||||||||||||||||||
| /// - Creates the config directory if it does not exist. | ||||||||||||||||||||||||||||||
| /// - Creates `medications.json` and `dose_records.json` with empty arrays | ||||||||||||||||||||||||||||||
| /// only when the files are absent (never overwrites). | ||||||||||||||||||||||||||||||
| /// - Creates `settings.json` with defaults when absent. | ||||||||||||||||||||||||||||||
| /// When present, additively merges any missing default keys without | ||||||||||||||||||||||||||||||
| /// touching existing user values. | ||||||||||||||||||||||||||||||
| pub struct AppInitializer; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| impl AppInitializer { | ||||||||||||||||||||||||||||||
| pub fn initialize(paths: &AppPaths) -> io::Result<()> { | ||||||||||||||||||||||||||||||
| fs::create_dir_all(paths.config_dir())?; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Self::init_data_file(paths.medications_path(), DEFAULT_MEDICATIONS)?; | ||||||||||||||||||||||||||||||
| Self::init_data_file(paths.dose_records_path(), DEFAULT_DOSE_RECORDS)?; | ||||||||||||||||||||||||||||||
| Self::init_settings_file(paths.settings_path())?; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Ok(()) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| fn init_data_file(path: &Path, default_content: &str) -> io::Result<()> { | ||||||||||||||||||||||||||||||
| if !path.exists() { | ||||||||||||||||||||||||||||||
| fs::write(path, default_content)?; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| Ok(()) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| fn init_settings_file(path: &Path) -> io::Result<()> { | ||||||||||||||||||||||||||||||
| if !path.exists() { | ||||||||||||||||||||||||||||||
| let serialized = serde_json::to_string_pretty(&default_settings()) | ||||||||||||||||||||||||||||||
| .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; | ||||||||||||||||||||||||||||||
| return fs::write(path, serialized); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Self::merge_default_settings(path) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| fn merge_default_settings(path: &Path) -> io::Result<()> { | ||||||||||||||||||||||||||||||
| let raw = fs::read_to_string(path)?; | ||||||||||||||||||||||||||||||
| let mut existing: Value = serde_json::from_str(&raw) | ||||||||||||||||||||||||||||||
| .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| let defaults = default_settings(); | ||||||||||||||||||||||||||||||
| let mut changed = false; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if let (Some(user_map), Value::Object(default_map)) = | ||||||||||||||||||||||||||||||
| (existing.as_object_mut(), defaults) | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+68
|
||||||||||||||||||||||||||||||
| if let (Some(user_map), Value::Object(default_map)) = | |
| (existing.as_object_mut(), defaults) | |
| { | |
| let user_map = match existing.as_object_mut() { | |
| Some(map) => map, | |
| None => { | |
| return Err(io::Error::new( | |
| io::ErrorKind::InvalidData, | |
| "settings.json must contain a JSON object", | |
| )); | |
| } | |
| }; | |
| if let Value::Object(default_map) = defaults { |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| use std::path::PathBuf; | ||
|
|
||
| const APP_DIR_NAME: &str = "bitpill"; | ||
| const ENV_MEDICATIONS: &str = "BITPILL_MEDICATIONS_FILE"; | ||
| const ENV_DOSE_RECORDS: &str = "BITPILL_DOSE_RECORDS_FILE"; | ||
| const ENV_SETTINGS: &str = "BITPILL_SETTINGS_FILE"; | ||
|
|
||
| /// Resolves all file system paths for BitPill's persistent data. | ||
| /// | ||
| /// Default resolution (no env overrides): | ||
| /// - config dir → `~/.config/bitpill/` | ||
| /// - medications → `~/.config/bitpill/medications.json` | ||
| /// - dose records → `~/.config/bitpill/dose_records.json` | ||
| /// - settings → `~/.config/bitpill/settings.json` | ||
|
Comment on lines
+10
to
+14
|
||
| /// | ||
| /// Each path can be overridden via its env var, which is useful for | ||
| /// integration tests and custom deployments. | ||
| pub struct AppPaths { | ||
| config_dir: PathBuf, | ||
| medications: PathBuf, | ||
| dose_records: PathBuf, | ||
| settings: PathBuf, | ||
| } | ||
|
|
||
| impl AppPaths { | ||
| /// Builds paths from env var overrides or XDG-standard defaults. | ||
| pub fn resolve() -> Self { | ||
| let config_dir = dirs::config_dir() | ||
| .unwrap_or_else(|| PathBuf::from(".config")) | ||
| .join(APP_DIR_NAME); | ||
|
|
||
| let medications = std::env::var(ENV_MEDICATIONS) | ||
| .map(PathBuf::from) | ||
| .unwrap_or_else(|_| config_dir.join("medications.json")); | ||
|
|
||
| let dose_records = std::env::var(ENV_DOSE_RECORDS) | ||
| .map(PathBuf::from) | ||
| .unwrap_or_else(|_| config_dir.join("dose_records.json")); | ||
|
|
||
| let settings = std::env::var(ENV_SETTINGS) | ||
| .map(PathBuf::from) | ||
| .unwrap_or_else(|_| config_dir.join("settings.json")); | ||
|
|
||
| Self { | ||
| config_dir, | ||
| medications, | ||
| dose_records, | ||
| settings, | ||
| } | ||
| } | ||
|
|
||
| /// Constructs paths from explicit values. Intended for tests only. | ||
| #[cfg(any(test, feature = "test-helpers"))] | ||
| pub fn with_paths( | ||
| config_dir: PathBuf, | ||
| medications: PathBuf, | ||
| dose_records: PathBuf, | ||
| settings: PathBuf, | ||
| ) -> Self { | ||
| Self { | ||
| config_dir, | ||
| medications, | ||
| dose_records, | ||
| settings, | ||
| } | ||
| } | ||
|
|
||
| pub fn config_dir(&self) -> &PathBuf { | ||
| &self.config_dir | ||
| } | ||
|
|
||
| pub fn medications_path(&self) -> &PathBuf { | ||
| &self.medications | ||
| } | ||
|
|
||
| pub fn dose_records_path(&self) -> &PathBuf { | ||
| &self.dose_records | ||
| } | ||
|
|
||
| pub fn settings_path(&self) -> &PathBuf { | ||
| &self.settings | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn resolve_returns_paths_ending_with_expected_filenames() { | ||
| let paths = AppPaths::resolve(); | ||
|
|
||
| assert!(paths.medications_path().to_str().unwrap().ends_with("medications.json")); | ||
| assert!(paths.dose_records_path().to_str().unwrap().ends_with("dose_records.json")); | ||
| assert!(paths.settings_path().to_str().unwrap().ends_with("settings.json")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn config_dir_contains_bitpill_segment() { | ||
| let paths = AppPaths::resolve(); | ||
|
|
||
| assert!(paths | ||
| .config_dir() | ||
| .components() | ||
| .any(|c| c.as_os_str() == APP_DIR_NAME)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn with_paths_stores_provided_paths() { | ||
| let paths = AppPaths::with_paths( | ||
| PathBuf::from("/tmp/cfg"), | ||
| PathBuf::from("/tmp/meds.json"), | ||
| PathBuf::from("/tmp/doses.json"), | ||
| PathBuf::from("/tmp/settings.json"), | ||
| ); | ||
|
|
||
| assert_eq!(paths.config_dir(), &PathBuf::from("/tmp/cfg")); | ||
| assert_eq!(paths.medications_path(), &PathBuf::from("/tmp/meds.json")); | ||
| assert_eq!(paths.dose_records_path(), &PathBuf::from("/tmp/doses.json")); | ||
| assert_eq!(paths.settings_path(), &PathBuf::from("/tmp/settings.json")); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| pub mod app_initializer; | ||
| pub mod app_paths; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| pub mod clock; | ||
| pub mod config; | ||
| pub mod container; | ||
| pub mod notifications; | ||
| pub mod persistence; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
initialize()only callscreate_dir_all()forpaths.config_dir(), but the data file paths can be overridden via env vars to locations outside that directory. If an override points to a file in a non-existent parent directory,fs::writewill fail. Consider creating parent directories for each resolved file path (and erroring early if a path exists but is not a regular file).