Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ actix-web = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
dirs = "6"

[dev-dependencies]
tempfile = "3"
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ tools:
# Remove build artifacts
clean:
cargo clean

# Install the CLI tool globally
install:
cargo install --path . --locked
85 changes: 85 additions & 0 deletions src/infrastructure/config/app_initializer.rs
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)?;
Comment on lines +31 to +35
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 {

Copilot uses AI. Check for mistakes.
for (key, value) in default_map {
if !user_map.contains_key(&key) {
user_map.insert(key, value);
changed = true;
}
}
}

if changed {
let serialized = serde_json::to_string_pretty(&existing)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(path, serialized)?;
}

Ok(())
}
}
122 changes: 122 additions & 0 deletions src/infrastructure/config/app_paths.rs
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
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
///
/// 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"));
}
}
2 changes: 2 additions & 0 deletions src/infrastructure/config/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod app_initializer;
pub mod app_paths;
23 changes: 15 additions & 8 deletions src/infrastructure/container.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::{env, path::PathBuf, sync::Arc};
use std::sync::Arc;

#[cfg(any(test, feature = "test-helpers"))]
use std::path::PathBuf;

use crate::{
application::{
Expand Down Expand Up @@ -27,6 +30,10 @@ use crate::{
},
infrastructure::{
clock::system_clock::SystemClock,
config::{
app_initializer::AppInitializer,
app_paths::AppPaths,
},
notifications::console_notification_adapter::ConsoleNotificationAdapter,
persistence::{
json_dose_record_repository::JsonDoseRecordRepository,
Expand Down Expand Up @@ -55,15 +62,15 @@ pub struct Container {

impl Container {
pub fn new() -> Self {
let medication_repo = Arc::new(JsonMedicationRepository::with_default_path());
let dose_record_repo = Arc::new(JsonDoseRecordRepository::with_default_path());
let paths = AppPaths::resolve();
if let Err(e) = AppInitializer::initialize(&paths) {
eprintln!("Warning: could not initialize config directory: {e}");
}
let medication_repo = Arc::new(JsonMedicationRepository::new(paths.medications_path().clone()));
let dose_record_repo = Arc::new(JsonDoseRecordRepository::new(paths.dose_records_path().clone()));
let notification = Arc::new(ConsoleNotificationAdapter);
let clock = Arc::new(SystemClock);
let settings_repo = Arc::new(JsonSettingsRepository::new(
env::var("BITPILL_SETTINGS_FILE")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("settings.json")),
));
let settings_repo = Arc::new(JsonSettingsRepository::new(paths.settings_path().clone()));

Self {
create_medication_service: Arc::new(CreateMedicationService::new(
Expand Down
1 change: 1 addition & 0 deletions src/infrastructure/mod.rs
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;
Loading