minutes-core 0.20.0

Core library for minutes — audio capture, transcription, and meeting memory
use crate::config::Config;
use crate::markdown::ContentType;
use chrono::SecondsFormat;
use serde::Serialize;
use std::cell::RefCell;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};

const TRACE_FILE: &str = "process-audio-trace.jsonl";

#[derive(Clone, Debug, Default)]
pub struct ProcessTraceContext {
    input_path: String,
    content_type: String,
    language: Option<String>,
    model_path: Option<String>,
    vad_enabled: Option<bool>,
    audio_samples: Option<usize>,
    audio_duration_secs: Option<f64>,
}

#[derive(Debug, Serialize)]
struct ProcessTraceEvent<'a> {
    ts: String,
    stage: &'a str,
    input_path: &'a str,
    content_type: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    language: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    model_path: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    vad_enabled: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    audio_samples: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    audio_duration_secs: Option<f64>,
    #[serde(flatten)]
    extra: serde_json::Value,
}

thread_local! {
    static PROCESS_TRACE_CONTEXT: RefCell<Option<ProcessTraceContext>> = const { RefCell::new(None) };
}

impl ProcessTraceContext {
    pub fn new(input_path: &Path, content_type: ContentType, config: &Config) -> Self {
        let model_path = configured_model_path(config).map(|path| path.display().to_string());
        let vad_enabled = Some(!config.transcription.vad_model.trim().is_empty());
        Self {
            input_path: input_path.display().to_string(),
            content_type: content_type_label(content_type).to_string(),
            language: config.transcription.language.clone(),
            model_path,
            vad_enabled,
            audio_samples: None,
            audio_duration_secs: None,
        }
    }
}

pub struct ProcessTraceGuard;

impl Drop for ProcessTraceGuard {
    fn drop(&mut self) {
        PROCESS_TRACE_CONTEXT.with(|current| {
            current.borrow_mut().take();
        });
    }
}

pub fn start_process_trace(
    input_path: &Path,
    content_type: ContentType,
    config: &Config,
) -> Option<ProcessTraceGuard> {
    if !enabled() {
        return None;
    }
    let context = ProcessTraceContext::new(input_path, content_type, config);
    PROCESS_TRACE_CONTEXT.with(|current| {
        *current.borrow_mut() = Some(context);
    });
    stage("process.start");
    Some(ProcessTraceGuard)
}

pub fn is_active() -> bool {
    PROCESS_TRACE_CONTEXT.with(|current| current.borrow().is_some())
}

pub fn stage(stage: &'static str) {
    stage_with_extra(stage, serde_json::Value::Null);
}

pub fn stage_with_extra(stage: &'static str, extra: serde_json::Value) {
    if !enabled() {
        return;
    }
    let context = PROCESS_TRACE_CONTEXT.with(|current| current.borrow().clone());
    let Some(context) = context else {
        return;
    };
    write_event(stage, &context, extra);
}

pub fn update_model_path(path: &Path) {
    if !enabled() {
        return;
    }
    PROCESS_TRACE_CONTEXT.with(|current| {
        if let Some(context) = current.borrow_mut().as_mut() {
            context.model_path = Some(path.display().to_string());
        }
    });
}

pub fn update_vad_enabled(is_enabled: bool) {
    if !enabled() {
        return;
    }
    PROCESS_TRACE_CONTEXT.with(|current| {
        if let Some(context) = current.borrow_mut().as_mut() {
            context.vad_enabled = Some(is_enabled);
        }
    });
}

pub fn update_audio(samples: usize) {
    if !enabled() {
        return;
    }
    PROCESS_TRACE_CONTEXT.with(|current| {
        if let Some(context) = current.borrow_mut().as_mut() {
            context.audio_samples = Some(samples);
            context.audio_duration_secs = Some(samples as f64 / 16_000.0);
        }
    });
}

fn write_event(stage: &'static str, context: &ProcessTraceContext, extra: serde_json::Value) {
    let event = ProcessTraceEvent {
        ts: chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
        stage,
        input_path: &context.input_path,
        content_type: &context.content_type,
        language: context.language.as_deref(),
        model_path: context.model_path.as_deref(),
        vad_enabled: context.vad_enabled,
        audio_samples: context.audio_samples,
        audio_duration_secs: context.audio_duration_secs,
        extra,
    };

    let Ok(line) = serde_json::to_string(&event) else {
        return;
    };
    let Some(path) = trace_path() else {
        return;
    };
    if let Some(parent) = path.parent() {
        if std::fs::create_dir_all(parent).is_err() {
            return;
        }
    }
    let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else {
        return;
    };
    if writeln!(file, "{line}").is_err() {
        return;
    }
    if file.flush().is_err() {
        return;
    }
    let _ = file.sync_all();
}

fn trace_path() -> Option<PathBuf> {
    if let Some(home) = std::env::var_os("MINUTES_HOME") {
        return Some(PathBuf::from(home).join("logs").join(TRACE_FILE));
    }
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .or_else(dirs::home_dir)
        .map(|home| home.join(".minutes").join("logs").join(TRACE_FILE))
}

fn enabled() -> bool {
    !matches!(
        std::env::var("MINUTES_TRACE").ok().as_deref(),
        Some("0" | "false" | "FALSE" | "off" | "OFF")
    )
}

fn configured_model_path(config: &Config) -> Option<PathBuf> {
    let model = config.transcription.model.trim();
    if model.is_empty() {
        return None;
    }
    let direct = PathBuf::from(model);
    if direct.is_absolute() {
        Some(direct)
    } else {
        Some(
            config
                .transcription
                .model_path
                .join(format!("ggml-{model}.bin")),
        )
    }
}

fn content_type_label(content_type: ContentType) -> &'static str {
    match content_type {
        ContentType::Meeting => "meeting",
        ContentType::Memo => "memo",
        ContentType::Dictation => "dictation",
    }
}

#[cfg(test)]
pub fn trace_file_path_for_tests() -> Option<PathBuf> {
    trace_path()
}