oc-session 0.1.0

Global OpenCode session browser and resume tool
use crate::config::Config;
use ignore::WalkBuilder;
use std::{collections::BTreeSet, path::PathBuf};

pub fn discover(cfg: &Config) -> Vec<PathBuf> {
    let mut paths = BTreeSet::new();
    for dir in known_dirs() {
        add_glob(&mut paths, dir);
    }
    for dir in &cfg.paths.include {
        add_glob(&mut paths, expand_home(dir));
    }
    paths
        .into_iter()
        .filter(|path| !cfg.paths.exclude.iter().any(|x| path.starts_with(x)))
        .collect()
}

pub fn scan(include: &[PathBuf]) -> Vec<PathBuf> {
    let Some(home) = dirs::home_dir() else {
        return Vec::new();
    };
    let mut paths = BTreeSet::new();
    scan_root(&mut paths, home, Some(5));
    for path in include {
        scan_root(&mut paths, expand_home(path), None);
    }
    paths.into_iter().collect()
}

fn scan_root(paths: &mut BTreeSet<PathBuf>, root: PathBuf, max_depth: Option<usize>) {
    if root.is_file() {
        if is_db(&root) {
            paths.insert(root);
        }
        return;
    }
    let mut walk = WalkBuilder::new(root);
    walk.hidden(false).ignore(false).git_ignore(false);
    if let Some(depth) = max_depth {
        walk.max_depth(Some(depth));
    }
    for entry in walk.build().filter_map(Result::ok) {
        let path = entry.path();
        if is_db(path) {
            paths.insert(path.to_path_buf());
        }
    }
}

fn known_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    if let Some(dir) = dirs::data_dir() {
        dirs.push(dir.join("opencode"));
    }
    if let Some(home) = dirs::home_dir() {
        dirs.push(home.join(".local/share/opencode"));
        if cfg!(target_os = "macos") {
            dirs.push(home.join("Library/Application Support/opencode"));
            dirs.push(home.join("Library/Mobile Documents"));
            dirs.push(home.join("Library/CloudStorage"));
        }
        if cfg!(target_os = "windows") {
            dirs.push(home.join("AppData/Local/opencode"));
            dirs.push(home.join("AppData/Roaming/opencode"));
            dirs.push(home.join("OneDrive"));
        }
        if cfg!(target_os = "linux") {
            dirs.push(home.join(".var/app"));
        }
    }
    dirs
}

fn add_glob(paths: &mut BTreeSet<PathBuf>, dir: PathBuf) {
    if dir.is_file() && is_db(&dir) {
        paths.insert(dir);
        return;
    }
    let Ok(items) = std::fs::read_dir(&dir) else {
        return;
    };
    for item in items.filter_map(Result::ok) {
        let path = item.path();
        if path.is_file() && is_db(&path) {
            paths.insert(path);
        }
    }
}

fn is_db(path: &std::path::Path) -> bool {
    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
        return false;
    };
    name.starts_with("opencode") && name.ends_with(".db")
}

fn expand_home(path: &std::path::Path) -> PathBuf {
    let Some(text) = path.to_str() else {
        return path.to_path_buf();
    };
    if text == "~" {
        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
    }
    let Some(rest) = text.strip_prefix("~/") else {
        return path.to_path_buf();
    };
    dirs::home_dir()
        .map(|home| home.join(rest))
        .unwrap_or_else(|| path.to_path_buf())
}

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

    #[test]
    fn expands_quoted_home_paths() {
        let Some(home) = dirs::home_dir() else {
            return;
        };

        assert_eq!(
            expand_home(std::path::Path::new("~/work")),
            home.join("work")
        );
    }
}