minutes-core 0.9.4

Core library for minutes — audio capture, transcription, and meeting memory
Documentation
use crate::capture::RecordingIntent;
use crate::config::Config;
use crate::pid::CaptureMode;
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

pub fn control_dir() -> PathBuf {
    Config::minutes_dir().join("desktop-control")
}

pub fn requests_dir() -> PathBuf {
    control_dir().join("requests")
}

pub fn responses_dir() -> PathBuf {
    control_dir().join("responses")
}

pub fn desktop_app_status_path() -> PathBuf {
    control_dir().join("desktop-app.json")
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopAppStatus {
    pub pid: u32,
    pub updated_at: DateTime<Local>,
    pub platform: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartRecordingRequest {
    pub mode: CaptureMode,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub intent: Option<RecordingIntent>,
    #[serde(default)]
    pub allow_degraded: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum DesktopControlAction {
    StartRecording(StartRecordingRequest),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopControlRequest {
    pub id: String,
    pub created_at: DateTime<Local>,
    pub action: DesktopControlAction,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopControlResponse {
    pub id: String,
    pub handled_at: DateTime<Local>,
    pub accepted: bool,
    pub detail: String,
}

#[derive(Debug, Clone)]
pub struct ClaimedDesktopControlRequest {
    pub request: DesktopControlRequest,
    pub claim_path: PathBuf,
}

fn ensure_dirs() -> std::io::Result<()> {
    fs::create_dir_all(requests_dir())?;
    fs::create_dir_all(responses_dir())?;
    Ok(())
}

pub fn write_desktop_app_status(status: &DesktopAppStatus) -> std::io::Result<()> {
    ensure_dirs()?;
    let path = desktop_app_status_path();
    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, serde_json::to_vec_pretty(status)?)?;
    fs::rename(tmp, path)?;
    Ok(())
}

pub fn clear_desktop_app_status() -> std::io::Result<()> {
    let path = desktop_app_status_path();
    if path.exists() {
        fs::remove_file(path)?;
    }
    Ok(())
}

pub fn read_desktop_app_status() -> Option<DesktopAppStatus> {
    let path = desktop_app_status_path();
    fs::read_to_string(path)
        .ok()
        .and_then(|text| serde_json::from_str(&text).ok())
}

pub fn request_path(id: &str) -> PathBuf {
    requests_dir().join(format!("{}.json", id))
}

pub fn response_path(id: &str) -> PathBuf {
    responses_dir().join(format!("{}.json", id))
}

fn claimed_request_path(path: &Path, claimant: &str) -> PathBuf {
    let extension = format!("claimed-{claimant}");
    path.with_extension(extension)
}

pub fn write_request(request: &DesktopControlRequest) -> std::io::Result<()> {
    ensure_dirs()?;
    let path = request_path(&request.id);
    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, serde_json::to_vec_pretty(request)?)?;
    fs::rename(tmp, path)?;
    Ok(())
}

pub fn write_response(response: &DesktopControlResponse) -> std::io::Result<()> {
    ensure_dirs()?;
    let path = response_path(&response.id);
    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, serde_json::to_vec_pretty(response)?)?;
    fs::rename(tmp, path)?;
    Ok(())
}

pub fn remove_request(id: &str) -> std::io::Result<()> {
    let path = request_path(id);
    if path.exists() {
        fs::remove_file(path)?;
    }
    Ok(())
}

pub fn remove_response(id: &str) -> std::io::Result<()> {
    let path = response_path(id);
    if path.exists() {
        fs::remove_file(path)?;
    }
    Ok(())
}

pub fn claim_pending_requests(claimant: &str) -> Vec<ClaimedDesktopControlRequest> {
    let mut requests = Vec::new();
    let dir = requests_dir();
    if !dir.exists() {
        return requests;
    }

    for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
            continue;
        }

        let claim_path = claimed_request_path(&path, claimant);
        if fs::rename(&path, &claim_path).is_err() {
            continue;
        }

        match fs::read_to_string(&claim_path)
            .ok()
            .and_then(|text| serde_json::from_str::<DesktopControlRequest>(&text).ok())
        {
            Some(request) => requests.push(ClaimedDesktopControlRequest {
                request,
                claim_path,
            }),
            None => {
                fs::remove_file(&claim_path).ok();
            }
        }
    }

    requests.sort_by_key(|claimed| claimed.request.created_at);
    requests
}

pub fn finish_claimed_request(claim_path: &Path) -> std::io::Result<()> {
    if claim_path.exists() {
        fs::remove_file(claim_path)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;

    #[test]
    fn claim_pending_requests_is_single_claim() {
        let _guard = crate::test_home_env_lock();
        let dir = tempfile::tempdir().unwrap();
        let original_home = std::env::var_os("HOME");
        #[cfg(windows)]
        let original_userprofile = std::env::var_os("USERPROFILE");
        std::env::set_var("HOME", dir.path());
        #[cfg(windows)]
        std::env::set_var("USERPROFILE", dir.path());

        let restore_env = || {
            if let Some(home) = original_home.as_ref() {
                std::env::set_var("HOME", home);
            } else {
                std::env::remove_var("HOME");
            }
            #[cfg(windows)]
            if let Some(userprofile) = original_userprofile.as_ref() {
                std::env::set_var("USERPROFILE", userprofile);
            } else {
                std::env::remove_var("USERPROFILE");
            }
        };

        let now = Local::now();
        let first = DesktopControlRequest {
            id: "first".into(),
            created_at: now,
            action: DesktopControlAction::StartRecording(StartRecordingRequest {
                mode: CaptureMode::Meeting,
                intent: None,
                allow_degraded: false,
                title: None,
                language: None,
            }),
        };
        let second = DesktopControlRequest {
            id: "second".into(),
            created_at: now + Duration::milliseconds(1),
            action: DesktopControlAction::StartRecording(StartRecordingRequest {
                mode: CaptureMode::Meeting,
                intent: None,
                allow_degraded: false,
                title: None,
                language: None,
            }),
        };

        write_request(&first).unwrap();
        write_request(&second).unwrap();

        let claimed = claim_pending_requests("pid-1");
        assert_eq!(
            claimed
                .iter()
                .map(|item| item.request.id.as_str())
                .collect::<Vec<_>>(),
            vec!["first", "second"]
        );
        assert!(claim_pending_requests("pid-2").is_empty());

        for item in claimed {
            finish_claimed_request(&item.claim_path).unwrap();
        }

        restore_env();
    }
}