use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub output_dir: PathBuf,
pub transcription: TranscriptionConfig,
pub diarization: DiarizationConfig,
pub summarization: SummarizationConfig,
pub search: SearchConfig,
pub daily_notes: DailyNotesConfig,
pub security: SecurityConfig,
pub watch: WatchConfig,
pub assistant: AssistantConfig,
pub screen_context: ScreenContextConfig,
pub call_detection: CallDetectionConfig,
pub identity: IdentityConfig,
pub vault: VaultConfig,
pub dictation: DictationConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TranscriptionConfig {
pub model: String,
pub model_path: PathBuf,
pub min_words: usize,
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiarizationConfig {
pub engine: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SummarizationConfig {
pub engine: String,
pub agent_command: String,
pub chunk_max_tokens: usize,
pub ollama_url: String,
pub ollama_model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchConfig {
pub engine: String,
pub qmd_collection: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DailyNotesConfig {
pub enabled: bool,
pub path: PathBuf,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
pub allowed_audio_dirs: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WatchConfig {
pub paths: Vec<PathBuf>,
pub extensions: Vec<String>,
pub r#type: String,
pub diarize: bool,
pub delete_source: bool,
pub settle_delay_ms: u64,
pub dictation_threshold_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScreenContextConfig {
pub enabled: bool,
pub interval_secs: u64,
pub keep_after_summary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AssistantConfig {
pub agent: String,
pub agent_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CallDetectionConfig {
pub enabled: bool,
pub poll_interval_secs: u64,
pub cooldown_minutes: u64,
pub apps: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct IdentityConfig {
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DictationConfig {
pub destination: String,
pub daily_note_log: bool,
pub cleanup_engine: String,
pub auto_paste: bool,
pub auto_paste_restore: bool,
pub silence_timeout_ms: u64,
pub max_utterance_secs: u64,
pub destination_file: String,
pub destination_command: String,
pub model: String,
pub hotkey_keycode: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VaultConfig {
pub enabled: bool,
pub path: PathBuf,
pub meetings_subdir: String,
pub strategy: String,
}
impl Default for DictationConfig {
fn default() -> Self {
Self {
destination: "clipboard".into(),
daily_note_log: true,
cleanup_engine: String::new(),
auto_paste: false,
auto_paste_restore: true,
silence_timeout_ms: 2000,
max_utterance_secs: 120,
destination_file: String::new(),
destination_command: String::new(),
model: "base".into(),
hotkey_keycode: 57, }
}
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
enabled: false,
path: PathBuf::new(),
meetings_subdir: "areas/meetings".into(),
strategy: "auto".into(),
}
}
}
impl Default for ScreenContextConfig {
fn default() -> Self {
Self {
enabled: false,
interval_secs: 30,
keep_after_summary: false,
}
}
}
impl Default for AssistantConfig {
fn default() -> Self {
Self {
agent: "claude".into(),
agent_args: vec![],
}
}
}
impl Default for CallDetectionConfig {
fn default() -> Self {
Self {
enabled: false,
poll_interval_secs: 3,
cooldown_minutes: 5,
apps: vec![
"zoom.us".into(),
"Microsoft Teams".into(),
"FaceTime".into(),
"Webex".into(),
"Slack".into(),
],
}
}
}
fn home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
}
fn minutes_dir() -> PathBuf {
home_dir().join(".minutes")
}
impl Default for Config {
fn default() -> Self {
Self {
output_dir: home_dir().join("meetings"),
transcription: TranscriptionConfig::default(),
diarization: DiarizationConfig::default(),
summarization: SummarizationConfig::default(),
search: SearchConfig::default(),
daily_notes: DailyNotesConfig::default(),
security: SecurityConfig::default(),
watch: WatchConfig::default(),
assistant: AssistantConfig::default(),
screen_context: ScreenContextConfig::default(),
call_detection: CallDetectionConfig::default(),
identity: IdentityConfig::default(),
vault: VaultConfig::default(),
dictation: DictationConfig::default(),
}
}
}
impl Default for TranscriptionConfig {
fn default() -> Self {
Self {
model: "small".into(),
model_path: minutes_dir().join("models"),
min_words: 3,
language: None,
}
}
}
impl Default for DiarizationConfig {
fn default() -> Self {
Self {
engine: "none".into(),
}
}
}
impl Default for SummarizationConfig {
fn default() -> Self {
Self {
engine: "none".into(),
agent_command: "claude".into(),
chunk_max_tokens: 4000,
ollama_url: "http://localhost:11434".into(),
ollama_model: "llama3.2".into(),
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
engine: "builtin".into(),
qmd_collection: None,
}
}
}
impl Default for DailyNotesConfig {
fn default() -> Self {
Self {
enabled: false,
path: home_dir().join("meetings").join("daily"),
}
}
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
paths: vec![minutes_dir().join("inbox")],
extensions: vec![
"m4a".into(),
"wav".into(),
"mp3".into(),
"ogg".into(),
"webm".into(),
],
r#type: "memo".into(),
diarize: false,
delete_source: false,
settle_delay_ms: 2000,
dictation_threshold_secs: 120,
}
}
}
impl Config {
pub fn config_path() -> PathBuf {
let unix_path = home_dir()
.join(".config")
.join("minutes")
.join("config.toml");
if unix_path.exists() {
return unix_path;
}
let native_path = dirs::config_dir()
.unwrap_or_else(|| home_dir().join(".config"))
.join("minutes")
.join("config.toml");
if native_path.exists() {
return native_path;
}
unix_path
}
pub fn load() -> Self {
let path = Self::config_path();
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Self {
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(path) {
Ok(contents) => match toml::from_str(&contents) {
Ok(config) => config,
Err(e) => {
tracing::warn!(
"invalid config at {}: {}. Using defaults.",
path.display(),
e
);
Self::default()
}
},
Err(e) => {
tracing::warn!(
"could not read config at {}: {}. Using defaults.",
path.display(),
e
);
Self::default()
}
}
}
pub fn save(&self) -> std::io::Result<()> {
let path = Self::config_path();
Self::save_to(self, &path)
}
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::other(format!("TOML serialize: {}", e)))?;
std::fs::write(path, contents)?;
tracing::info!(path = %path.display(), "config saved");
Ok(())
}
pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.output_dir)?;
std::fs::create_dir_all(self.output_dir.join("memos"))?;
if self.daily_notes.enabled {
std::fs::create_dir_all(&self.daily_notes.path)?;
}
std::fs::create_dir_all(minutes_dir())?;
std::fs::create_dir_all(minutes_dir().join("inbox"))?;
std::fs::create_dir_all(minutes_dir().join("inbox").join("processed"))?;
std::fs::create_dir_all(minutes_dir().join("inbox").join("failed"))?;
std::fs::create_dir_all(minutes_dir().join("logs"))?;
for dir in [&self.output_dir, &minutes_dir()] {
let marker = dir.join(".metadata_never_index");
if !marker.exists() {
std::fs::write(&marker, "").ok();
}
}
Ok(())
}
pub fn minutes_dir() -> PathBuf {
minutes_dir()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn default_config_is_valid() {
let config = Config::default();
assert_eq!(config.transcription.model, "small");
assert_eq!(config.transcription.min_words, 3);
assert_eq!(config.diarization.engine, "none");
assert_eq!(config.summarization.engine, "none");
assert_eq!(config.search.engine, "builtin");
assert!(!config.daily_notes.enabled);
assert_eq!(config.watch.settle_delay_ms, 2000);
assert!(!config.watch.extensions.is_empty());
}
#[test]
fn missing_config_file_returns_defaults() {
let config = Config::load_from(Path::new("/nonexistent/config.toml"));
assert_eq!(config.transcription.model, "small");
}
#[test]
fn partial_config_merges_with_defaults() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "large-v3"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.model, "large-v3");
assert_eq!(config.transcription.min_words, 3);
assert_eq!(config.diarization.engine, "none");
assert!(!config.daily_notes.enabled);
}
#[test]
fn default_language_is_none() {
let config = Config::default();
assert_eq!(config.transcription.language, None);
}
#[test]
fn language_can_be_set_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
language = "es"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.language, Some("es".into()));
}
#[test]
fn omitted_language_defaults_to_none() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "tiny"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.language, None);
}
#[test]
fn invalid_toml_returns_defaults() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "this is not valid toml {{{").unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.model, "small");
}
}