Conversation
gbrennon
commented
Mar 8, 2026
- chore(deps): add dirs crate for XDG config path resolution
- feat(infrastructure): resolve data files from ~/.config/bitpill/
- test(infrastructure): wire harness and fix stale dormant tests
- test(infrastructure): add AppInitializer integration tests
There was a problem hiding this comment.
Pull request overview
This PR moves BitPill’s persistent JSON data files (medications, dose records, settings) to an XDG/platform config directory by resolving paths via a new AppPaths helper, and bootstraps the directory/files on startup. It also adds/rewires infrastructure integration tests with a proper tests/infrastructure.rs harness so nested infra tests compile and run.
Changes:
- Add
dirsdependency and introduceinfrastructure::config::{AppPaths, AppInitializer}for path resolution + first-run bootstrapping. - Update
infrastructure::Container::new()to resolve and initialize config files, then construct JSON repositories from those paths. - Add an infrastructure test harness and update/add infra integration tests around JSON repositories and initialization.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
Cargo.toml |
Adds dirs dependency for config directory resolution. |
Cargo.lock |
Locks dirs and transitive deps. |
justfile |
Adds an install recipe for global installation. |
src/infrastructure/mod.rs |
Exposes new config module. |
src/infrastructure/container.rs |
Resolves XDG config paths and runs initialization before wiring repositories/services. |
src/infrastructure/config/mod.rs |
Declares config submodules. |
src/infrastructure/config/app_paths.rs |
Implements path resolution (dirs + env overrides) and test helpers. |
src/infrastructure/config/app_initializer.rs |
Bootstraps config dir + default JSON files; merges default settings keys. |
tests/infrastructure.rs |
New harness to compile/run tests/infrastructure/*.rs. |
tests/infrastructure/app_initializer_tests.rs |
New integration tests for initializer behavior. |
tests/infrastructure/container_settings_tests.rs |
Updates container settings-service test to use Arc::ptr_eq. |
tests/infrastructure/json_med_repo_integration.rs |
Updates integration test to use new container access patterns. |
tests/infrastructure/json_settings_repo_integration.rs |
Updates integration test to use settings_service field directly. |
tests/infrastructure/json_settings_repository_tests.rs |
Uses tempfile for hermetic filesystem tests. |
tests/infrastructure/integration_mark_taken.rs |
Updates integration tests for dose record persistence + mark-taken behavior. |
tests/infrastructure/integration_mark_taken_handler.rs |
Updates handler integration test wiring for updated mark-taken service deps. |
| /// 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` |
There was a problem hiding this comment.
The default-path doc comment is Unix-specific (~/.config/bitpill/…), but dirs::config_dir() returns OS-specific config locations (e.g., AppData on Windows) and the fallback is a relative .config. Update the comment to reflect platform-specific resolution and the actual fallback behavior so it stays accurate.
| use std::fs; | ||
|
|
||
| use bitpill::infrastructure::config::{app_initializer::AppInitializer, app_paths::AppPaths}; | ||
| use serde_json::Value; | ||
| use tempfile::{TempDir, tempdir}; |
There was a problem hiding this comment.
Import grouping is out of order (internal bitpill::… comes before external crates like serde_json/tempfile). To match the repo’s Rust import conventions, group as: std imports, blank line, external crate imports, blank line, then internal/crate imports.
| #[test] | ||
| fn get_settings_service_returns_same_reference() { | ||
| fn settings_service_arc_is_same_instance() { | ||
| let c = bitpill::infrastructure::container::Container::new(); |
There was a problem hiding this comment.
This test constructs Container::new(), which now runs AppInitializer and may create/write real user config files under the OS config directory. To keep tests hermetic and avoid modifying developer/CI machines, use Container::new_with_paths(...) with a tempdir() (or another isolated path) for this test.
| let mut container = Container::new(); | ||
| container.mark_dose_taken_service = Arc::new(MarkDoseTakenService::new( | ||
| fake_dose_repo, | ||
| fake_med_repo, | ||
| )) as Arc<dyn MarkDoseTakenPort>; |
There was a problem hiding this comment.
Creating a default Container::new() here will run AppInitializer and can touch the real OS config directory (creating data files) during test runs. Use Container::new_with_paths(...) with tempdir() for an isolated container, then override mark_dose_taken_service as needed.
| let req = MarkDoseTakenRequest::new(med_res.id.clone(), taken_at); | ||
| let res = container | ||
| .mark_dose_taken_service | ||
| .execute(req) | ||
| .expect("marking dose as taken should succeed"); | ||
|
|
||
| // Read the saved record back and assert it is taken | ||
| let record_id = DoseRecordId::from(uuid::Uuid::parse_str(&res.id).unwrap()); | ||
| let saved = fake_repo | ||
| .find_by_id(&record_id) | ||
| .expect("repo call should succeed") | ||
| .expect("record should exist"); | ||
|
|
||
| assert!( | ||
| saved.is_taken(), | ||
| "record saved by MarkDoseTakenService must be taken" | ||
| ); | ||
| assert_eq!(saved.taken_at(), Some(taken_at)); | ||
| assert!(!res.record_id.is_empty()); |
There was a problem hiding this comment.
This test only asserts that a non-empty record_id is returned, but the behavior under test is that a taken record is created and persisted. Strengthen the test to verify observable persistence (e.g., read dose_records.json and assert an entry exists for res.record_id with the expected taken_at).
| 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)?; |
There was a problem hiding this comment.
initialize() only calls create_dir_all() for paths.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::write will fail. Consider creating parent directories for each resolved file path (and erroring early if a path exists but is not a regular file).
| if let (Some(user_map), Value::Object(default_map)) = | ||
| (existing.as_object_mut(), defaults) | ||
| { |
There was a problem hiding this comment.
merge_default_settings() silently does nothing when settings.json exists but is not a JSON object (existing.as_object_mut() returns None), leaving the app without required default keys. Consider returning an InvalidData error (or rewriting to defaults) when the existing settings are not an object so corruption/misformat is handled explicitly.
| 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 { |