tui_breath 0.4.0

Terminal breathing guide built with Rust + Ratatui. 6 patterns, breath hold, workout mode, smooth animations, JSON session tracking.
use anyhow::Result;
use std::fs;
use std::path::PathBuf;

use super::schema::{
    BreathHoldAttemptRecord, BreathHoldData, IndexEntry, SessionRecord, SessionStatus,
};
use crate::engine::hold::BreathHoldAttempt;
use crate::engine::session::SessionOutcome;
use crate::engine::SessionManager;

pub struct Store {
    data_dir: PathBuf,
}

impl Store {
    pub fn new() -> Result<Self> {
        let data_dir = dirs::data_local_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("tui_breath");
        Self::new_in(data_dir)
    }

    pub(crate) fn new_in(data_dir: PathBuf) -> Result<Self> {
        fs::create_dir_all(&data_dir)?;
        fs::create_dir_all(data_dir.join("sessions"))?;

        Ok(Self { data_dir })
    }

    pub fn sessions_dir(&self) -> PathBuf {
        self.data_dir.join("sessions")
    }

    pub fn index_path(&self) -> PathBuf {
        self.data_dir.join("index.json")
    }

    pub fn save_session(&self, manager: &SessionManager) -> Result<()> {
        let sessions_dir = self.sessions_dir();
        fs::create_dir_all(&sessions_dir)?;

        let record = session_manager_to_record(manager);
        let json = serde_json::to_string_pretty(&record)?;
        let path = sessions_dir.join(format!("{}.json", record.session_id));
        fs::write(path, json)?;

        self.update_index(&record)?;

        Ok(())
    }

    pub fn forget_session(&self, session_id: &str) -> Result<()> {
        let path = self.sessions_dir().join(format!("{session_id}.json"));
        match fs::remove_file(path) {
            Ok(()) => {}
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => return Err(err.into()),
        }

        let mut entries = self.load_index()?;
        entries.retain(|entry| entry.session_id != session_id);

        let json = serde_json::to_string_pretty(&entries)?;
        fs::write(self.index_path(), json)?;

        Ok(())
    }

    pub fn load_index(&self) -> Result<Vec<IndexEntry>> {
        let index_path = self.index_path();
        if !index_path.exists() {
            return Ok(Vec::new());
        }

        let json = fs::read_to_string(index_path)?;
        let entries: Vec<IndexEntry> = serde_json::from_str(&json)?;
        Ok(entries)
    }

    fn update_index(&self, record: &SessionRecord) -> Result<()> {
        let mut entries = self.load_index()?;
        entries.retain(|entry| entry.session_id != record.session_id);

        let entry = IndexEntry {
            session_id: record.session_id.clone(),
            start_time: record.start_time,
            status: format!("{:?}", record.status).to_lowercase(),
            pattern_id: record.parameters.settings.pattern_id.clone(),
            duration_target: record.parameters.duration_target,
            cycles_completed: record.parameters.settings.iterations,
            completion_pct: calculate_completion_pct(record),
            best_breath_hold_seconds: record
                .breath_hold
                .as_ref()
                .and_then(|hold| hold.best_seconds),
            breath_hold_attempt_count: record
                .breath_hold
                .as_ref()
                .map(|hold| hold.attempt_count)
                .unwrap_or(0),
        };
        entries.push(entry);

        entries.sort_by(|a, b| b.start_time.cmp(&a.start_time));

        let json = serde_json::to_string_pretty(&entries)?;
        let path = self.index_path();
        fs::write(path, json)?;

        Ok(())
    }
}

fn session_manager_to_record(manager: &SessionManager) -> SessionRecord {
    let engine = &manager.engine;
    let pattern = engine.pattern;
    let target_secs = engine.duration_target_secs as u32;

    let breaths_per_minute = if engine.total_elapsed_secs > 0.0 {
        engine.cycle_count as f64 / engine.total_elapsed_secs * 60.0
    } else {
        0.0
    };

    let phases = engine.pattern.phases;
    let first_inhale = phases
        .iter()
        .find(|phase| phase.name.starts_with("Inhale"))
        .map(|phase| phase.duration_secs / engine.tempo)
        .unwrap_or(0.0);

    let first_exhale = phases
        .iter()
        .find(|phase| phase.name.starts_with("Exhale"))
        .map(|phase| phase.duration_secs / engine.tempo)
        .unwrap_or(0.0);

    let hold_in = phases
        .iter()
        .find(|phase| {
            phase.name.contains("Hold (In)")
                || phase.name.contains("Hold In")
                || phase.name == "Hold"
        })
        .map(|phase| phase.duration_secs / engine.tempo);

    let hold_out = phases
        .iter()
        .rev()
        .find(|phase| phase.name.contains("Hold (Out)") || phase.name.contains("Hold Out"))
        .map(|phase| phase.duration_secs / engine.tempo);

    SessionRecord {
        session_id: manager.session_id.to_string(),
        start_time: manager.start_time,
        end_time: Some(chrono::Utc::now()),
        status: match manager
            .session_status()
            .unwrap_or(SessionOutcome::Completed)
        {
            SessionOutcome::Completed => SessionStatus::Completed,
            SessionOutcome::Abandoned => SessionStatus::Abandoned,
        },
        session_type: "breathing".to_string(),
        parameters: crate::storage::schema::SessionParameters {
            duration_target: target_secs,
            actual_duration_secs: engine.total_elapsed_secs,
            settings: crate::storage::schema::SessionSettings {
                rate: breaths_per_minute,
                phase_parameters: crate::storage::schema::PhaseParameters {
                    inhalation_time: first_inhale,
                    exhalation_time: first_exhale,
                    hold_in_time: hold_in,
                    hold_out_time: hold_out,
                },
                iterations: engine.cycle_count,
                pattern_id: pattern.id.to_string(),
                tempo: engine.tempo,
            },
        },
        breath_hold: breath_hold_data(&manager.hold_attempts),
        history: manager
            .events
            .iter()
            .map(|event| crate::storage::schema::HistoryEvent {
                timestamp: event.timestamp,
                event: format!("{:?}", event.event),
                details: serde_json::json!({"details": event.details}),
            })
            .collect(),
    }
}

fn breath_hold_data(attempts: &[BreathHoldAttempt]) -> Option<BreathHoldData> {
    if attempts.is_empty() {
        return None;
    }

    Some(BreathHoldData {
        best_seconds: attempts
            .iter()
            .map(|attempt| attempt.duration_secs)
            .max_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)),
        attempt_count: attempts.len() as u32,
        attempts: attempts
            .iter()
            .map(|attempt| BreathHoldAttemptRecord {
                started_at: attempt.started_at,
                ended_at: attempt.ended_at,
                duration_secs: attempt.duration_secs,
            })
            .collect(),
    })
}

fn calculate_completion_pct(record: &SessionRecord) -> f64 {
    let target = record.parameters.duration_target as f64;
    if target > 0.0 {
        (record.parameters.actual_duration_secs / target * 100.0).min(100.0)
    } else {
        0.0
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::hold::BreathHoldAttempt;
    use crate::engine::PATTERNS;
    use chrono::Utc;

    #[test]
    fn record_uses_best_hold_and_active_duration_completion() {
        let mut manager = SessionManager::new(&PATTERNS[0], 120.0, 1.0);
        manager.engine.total_elapsed_secs = 60.0;
        manager.hold_attempts.push(BreathHoldAttempt {
            started_at: Utc::now(),
            ended_at: Utc::now(),
            duration_secs: 18.5,
        });
        manager.hold_attempts.push(BreathHoldAttempt {
            started_at: Utc::now(),
            ended_at: Utc::now(),
            duration_secs: 23.1,
        });
        manager.complete();

        let record = session_manager_to_record(&manager);

        assert_eq!(record.status, SessionStatus::Completed);
        assert_eq!(record.breath_hold.as_ref().unwrap().attempt_count, 2);
        assert_eq!(
            record.breath_hold.as_ref().unwrap().best_seconds,
            Some(23.1)
        );
        assert!((calculate_completion_pct(&record) - 50.0).abs() < 0.001);
    }

    #[test]
    fn abandoned_session_status_is_preserved() {
        let mut manager = SessionManager::new(&PATTERNS[0], 60.0, 1.0);
        manager.abandon();

        let record = session_manager_to_record(&manager);
        assert_eq!(record.status, SessionStatus::Abandoned);
    }

    #[test]
    fn old_index_entries_without_hold_fields_still_deserialize() {
        let json = r#"{
            "session_id": "abc",
            "start_time": "2026-06-02T12:00:00Z",
            "status": "completed",
            "pattern_id": "box",
            "duration_target": 60,
            "cycles_completed": 4,
            "completion_pct": 100.0
        }"#;

        let entry: IndexEntry = serde_json::from_str(json).unwrap();
        assert_eq!(entry.best_breath_hold_seconds, None);
        assert_eq!(entry.breath_hold_attempt_count, 0);
    }
}