paceflow 0.2.0

Local-first CLI that turns AI coding session history and git metadata into engineering analytics.
Documentation
use anyhow::{Result, ensure};
use std::path::{Path, PathBuf};

pub const CURSOR_STATE_PATH_ENV: &str = "PACEFLOW_CURSOR_STATE_PATH";
pub const CURSOR_HISTORY_PATH_ENV: &str = "PACEFLOW_CURSOR_HISTORY_PATH";

pub fn cursor_state_path() -> Result<Option<PathBuf>> {
    resolve_state_path_from(None, &default_cursor_user_roots())
}

pub fn cursor_history_path() -> Result<Option<PathBuf>> {
    resolve_history_path_from(None, &default_cursor_user_roots())
}

fn resolve_state_path_from(
    override_path: Option<PathBuf>,
    user_roots: &[PathBuf],
) -> Result<Option<PathBuf>> {
    resolve_path_from(
        override_path.or_else(|| env_override(CURSOR_STATE_PATH_ENV)),
        user_roots,
        |root| root.join("globalStorage").join("state.vscdb"),
        CURSOR_STATE_PATH_ENV,
        "Cursor state.vscdb",
        false,
    )
}

fn resolve_history_path_from(
    override_path: Option<PathBuf>,
    user_roots: &[PathBuf],
) -> Result<Option<PathBuf>> {
    resolve_path_from(
        override_path.or_else(|| env_override(CURSOR_HISTORY_PATH_ENV)),
        user_roots,
        |root| root.join("History"),
        CURSOR_HISTORY_PATH_ENV,
        "Cursor History directory",
        true,
    )
}

fn resolve_path_from<F>(
    override_path: Option<PathBuf>,
    user_roots: &[PathBuf],
    build_candidate: F,
    env_var: &str,
    label: &str,
    expect_dir: bool,
) -> Result<Option<PathBuf>>
where
    F: Fn(&Path) -> PathBuf,
{
    if let Some(path) = override_path {
        validate_override(&path, env_var, label, expect_dir)?;
        return Ok(Some(path));
    }

    for root in user_roots {
        let candidate = build_candidate(root);
        if if expect_dir {
            candidate.is_dir()
        } else {
            candidate.is_file()
        } {
            return Ok(Some(candidate));
        }
    }

    Ok(None)
}

fn validate_override(path: &Path, env_var: &str, label: &str, expect_dir: bool) -> Result<()> {
    if expect_dir {
        ensure!(
            path.is_dir(),
            "{} from {} is not a directory: {}",
            label,
            env_var,
            path.display()
        );
    } else {
        ensure!(
            path.is_file(),
            "{} from {} is not a file: {}",
            label,
            env_var,
            path.display()
        );
    }

    Ok(())
}

fn env_override(env_var: &str) -> Option<PathBuf> {
    std::env::var_os(env_var)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

fn default_cursor_user_roots() -> Vec<PathBuf> {
    build_cursor_user_roots(dirs::config_dir(), dirs::home_dir())
}

fn build_cursor_user_roots(config_dir: Option<PathBuf>, home_dir: Option<PathBuf>) -> Vec<PathBuf> {
    let mut roots = Vec::new();

    if let Some(config_dir) = config_dir {
        push_unique(&mut roots, config_dir.join("Cursor").join("User"));
    }

    if let Some(home_dir) = home_dir {
        push_unique(
            &mut roots,
            home_dir
                .join("Library")
                .join("Application Support")
                .join("Cursor")
                .join("User"),
        );
        push_unique(
            &mut roots,
            home_dir.join(".config").join("Cursor").join("User"),
        );
        push_unique(
            &mut roots,
            home_dir
                .join("AppData")
                .join("Roaming")
                .join("Cursor")
                .join("User"),
        );
    }

    roots
}

fn push_unique(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
    if !paths.iter().any(|existing| existing == &candidate) {
        paths.push(candidate);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use anyhow::Result;
    use tempfile::tempdir;

    #[test]
    fn state_override_path_wins() -> Result<()> {
        let tempdir = tempdir()?;
        let state_path = tempdir.path().join("state.vscdb");
        std::fs::write(&state_path, "")?;

        let resolved = resolve_state_path_from(Some(state_path.clone()), &[])?;
        assert_eq!(resolved, Some(state_path));

        Ok(())
    }

    #[test]
    fn history_override_path_wins() -> Result<()> {
        let tempdir = tempdir()?;
        let history_path = tempdir.path().join("History");
        std::fs::create_dir_all(&history_path)?;

        let resolved = resolve_history_path_from(Some(history_path.clone()), &[])?;
        assert_eq!(resolved, Some(history_path));

        Ok(())
    }

    #[test]
    fn autodiscovery_prefers_existing_user_root() -> Result<()> {
        let tempdir = tempdir()?;
        let config_dir = tempdir.path().join("config");
        let user_root = config_dir.join("Cursor").join("User");
        let state_path = user_root.join("globalStorage").join("state.vscdb");
        let history_path = user_root.join("History");
        std::fs::create_dir_all(state_path.parent().expect("state parent"))?;
        std::fs::create_dir_all(&history_path)?;
        std::fs::write(&state_path, "")?;

        let roots = build_cursor_user_roots(Some(config_dir), None);
        assert_eq!(resolve_state_path_from(None, &roots)?, Some(state_path));
        assert_eq!(resolve_history_path_from(None, &roots)?, Some(history_path));

        Ok(())
    }

    #[test]
    fn windows_roaming_candidate_is_generated() {
        let roots = build_cursor_user_roots(
            Some(PathBuf::from(r"C:\Users\alice\AppData\Roaming")),
            Some(PathBuf::from(r"C:\Users\alice")),
        );

        assert_eq!(
            roots[0],
            PathBuf::from(r"C:\Users\alice\AppData\Roaming")
                .join("Cursor")
                .join("User")
        );
    }
}