use crate::config::Config;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthItem {
pub label: String,
pub state: String,
pub detail: String,
pub optional: bool,
}
pub fn check_all(config: &Config) -> Vec<HealthItem> {
vec![
model_status(config),
mic_status(),
calendar_status(),
watcher_status(config),
output_dir_status(config),
disk_space(config),
]
}
pub fn model_status(config: &Config) -> HealthItem {
let model_name = &config.transcription.model;
let model_file = config
.transcription
.model_path
.join(format!("ggml-{}.bin", model_name));
let exists = model_file.exists();
HealthItem {
label: "Speech model".into(),
state: if exists { "ready" } else { "attention" }.into(),
detail: if exists {
format!("{} is installed at {}.", model_name, model_file.display())
} else {
format!(
"{} is not installed yet. Run `minutes setup` to download it.",
model_name
)
},
optional: false,
}
}
pub fn mic_status() -> HealthItem {
let devices = crate::capture::list_input_devices();
let has_devices = !devices.is_empty();
HealthItem {
label: "Microphone & audio input".into(),
state: if has_devices { "ready" } else { "attention" }.into(),
detail: if has_devices {
format!(
"{} audio input device{} detected.",
devices.len(),
if devices.len() == 1 { "" } else { "s" }
)
} else {
"No audio input devices detected. Check hardware and system settings.".into()
},
optional: false,
}
}
pub fn calendar_status() -> HealthItem {
#[cfg(target_os = "macos")]
{
let mut cmd = std::process::Command::new("osascript");
cmd.arg("-e")
.arg(r#"tell application "Calendar" to get name of every calendar"#);
let output = crate::calendar::output_with_timeout(cmd, std::time::Duration::from_secs(10))
.map(|o| if o.status.success() { Ok(o) } else { Err(o) });
match output {
Some(Ok(_)) => HealthItem {
label: "Calendar access".into(),
state: "ready".into(),
detail: "Calendar access is available for meeting suggestions.".into(),
optional: true,
},
Some(Err(_)) => HealthItem {
label: "Calendar access".into(),
state: "attention".into(),
detail: "Calendar access is unavailable. Meeting suggestions will be hidden."
.into(),
optional: true,
},
None => HealthItem {
label: "Calendar access".into(),
state: "attention".into(),
detail: "Calendar check timed out. Meeting suggestions will be hidden.".into(),
optional: true,
},
}
}
#[cfg(not(target_os = "macos"))]
{
HealthItem {
label: "Calendar access".into(),
state: "attention".into(),
detail: "Calendar integration is macOS-only.".into(),
optional: true,
}
}
}
pub fn watcher_status(config: &Config) -> HealthItem {
let existing = config
.watch
.paths
.iter()
.filter(|path| path.exists())
.count();
let total = config.watch.paths.len();
let state = if total == 0 || existing == total {
"ready"
} else {
"attention"
};
let detail = if total == 0 {
"No watch folders configured. Voice-memo ingestion is available but not set up.".into()
} else if existing == total {
format!(
"{} watch folder{} ready.",
total,
if total == 1 { "" } else { "s" }
)
} else {
format!(
"{} of {} watch folders exist. Missing folders will prevent inbox processing.",
existing, total
)
};
HealthItem {
label: "Watcher folders".into(),
state: state.into(),
detail,
optional: true,
}
}
pub fn output_dir_status(config: &Config) -> HealthItem {
let exists = config.output_dir.exists();
HealthItem {
label: "Meeting output folder".into(),
state: if exists { "ready" } else { "attention" }.into(),
detail: if exists {
format!("Meetings are stored in {}.", config.output_dir.display())
} else {
format!(
"Output folder {} does not exist yet. Minutes will create it on demand.",
config.output_dir.display()
)
},
optional: false,
}
}
pub fn disk_space(config: &Config) -> HealthItem {
let target = if config.output_dir.exists() {
&config.output_dir
} else {
std::path::Path::new("/")
};
#[cfg(unix)]
{
let stat = nix_disk_free(target);
match stat {
Some(free_gb) if free_gb < 1.0 => HealthItem {
label: "Disk space".into(),
state: "attention".into(),
detail: format!(
"{:.1} GB free. Recordings may fail if disk fills up.",
free_gb
),
optional: false,
},
Some(free_gb) => HealthItem {
label: "Disk space".into(),
state: "ready".into(),
detail: format!("{:.1} GB free.", free_gb),
optional: false,
},
None => HealthItem {
label: "Disk space".into(),
state: "ready".into(),
detail: "Could not determine free disk space.".into(),
optional: false,
},
}
}
#[cfg(not(unix))]
{
let _ = target;
HealthItem {
label: "Disk space".into(),
state: "ready".into(),
detail: "Disk space check is not available on this platform.".into(),
optional: false,
}
}
}
#[cfg(unix)]
#[allow(clippy::unnecessary_cast)]
fn nix_disk_free(path: &std::path::Path) -> Option<f64> {
use std::ffi::CString;
let c_path = CString::new(path.to_str()?).ok()?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let ret = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
if ret == 0 {
let free_bytes = (stat.f_bavail as u64) * (stat.f_frsize as u64);
Some(free_bytes as f64 / 1_073_741_824.0)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_all_returns_items() {
let config = Config::default();
let items = check_all(&config);
assert!(items.len() >= 5, "should have at least 5 health checks");
for item in &items {
assert!(!item.label.is_empty());
assert!(
item.state == "ready" || item.state == "attention",
"state should be ready or attention, got: {}",
item.state
);
}
}
#[test]
fn test_model_status_missing() {
let mut config = Config::default();
config.transcription.model = "nonexistent-model-xyz".into();
let status = model_status(&config);
assert_eq!(status.state, "attention");
assert!(!status.optional);
}
#[test]
fn test_output_dir_missing() {
let mut config = Config::default();
config.output_dir = "/nonexistent/path/12345".into();
let status = output_dir_status(&config);
assert_eq!(status.state, "attention");
}
#[test]
fn test_watcher_no_paths() {
let mut config = Config::default();
config.watch.paths.clear();
let status = watcher_status(&config);
assert_eq!(status.state, "ready"); assert!(status.optional);
}
#[test]
fn test_disk_space_root() {
let config = Config::default();
let status = disk_space(&config);
assert!(!status.label.is_empty());
}
}