gate4agent-shell-history 0.4.6

Bounded native filesystem authority for gate4agent history
Documentation
use crate::{
    CandidateLocator, DiscoveredCandidate, NativeHistoryDiscoveryIssue,
    NativeHistoryDiscoveryIssueKind, NativeHistoryLimits, NativeHistoryRoot,
};
use gate4agent_adapters::HistorySourceLayout;
use std::cmp::Reverse;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;

pub(crate) struct DiscoveryResult {
    pub(crate) candidates: Vec<DiscoveredCandidate>,
    pub(crate) issues: Vec<NativeHistoryDiscoveryIssue>,
}

pub(crate) fn discover_root(
    root: &NativeHistoryRoot,
    root_slot: usize,
    limits: NativeHistoryLimits,
    requested_limit: usize,
    report_candidate_overflow: bool,
) -> DiscoveryResult {
    let mut result = DiscoveryResult {
        candidates: Vec::new(),
        issues: Vec::new(),
    };
    let Ok(metadata) = fs::symlink_metadata(&root.path) else {
        return result;
    };
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        result.issues.push(issue(
            root,
            root_slot,
            NativeHistoryDiscoveryIssueKind::Inaccessible,
        ));
        return result;
    }
    let Ok(canonical_root) = fs::canonicalize(&root.path) else {
        result.issues.push(issue(
            root,
            root_slot,
            NativeHistoryDiscoveryIssueKind::Inaccessible,
        ));
        return result;
    };
    let scan_root = if root.layout == HistorySourceLayout::SessionJsonWithSiblingMessageJson {
        canonical_root.join("session")
    } else {
        canonical_root.clone()
    };
    if !scan_root.is_dir() {
        return result;
    }

    let mut stack = vec![(scan_root, 0usize)];
    let candidate_limit = requested_limit.min(limits.max_candidates).max(1);
    let mut entries_seen = 0usize;
    let mut limit_reported = false;
    let mut candidate_limit_reported = false;
    while let Some((directory, depth)) = stack.pop() {
        let Ok(entries) = fs::read_dir(&directory) else {
            continue;
        };
        for entry in entries.flatten() {
            entries_seen = entries_seen.saturating_add(1);
            if entries_seen > limits.max_walk_entries {
                if !limit_reported {
                    result.issues.push(issue(
                        root,
                        root_slot,
                        NativeHistoryDiscoveryIssueKind::EntryLimitReached,
                    ));
                    limit_reported = true;
                }
                stack.clear();
                break;
            }
            let Ok(file_type) = entry.file_type() else {
                continue;
            };
            if file_type.is_symlink() {
                continue;
            }
            let path = entry.path();
            if file_type.is_dir() {
                if depth < limits.max_walk_depth
                    && should_descend(root.adapter_id.as_str(), &path)
                {
                    stack.push((path, depth + 1));
                }
                continue;
            }
            if !file_type.is_file() || !matches_file(root.adapter_id.as_str(), &path) {
                continue;
            }
            let Ok(primary) = fs::canonicalize(&path) else {
                continue;
            };
            if !primary.starts_with(&canonical_root) {
                continue;
            }
            let Some(session_id_hint) = session_id_hint(root.adapter_id.as_str(), &primary) else {
                continue;
            };
            let modified_at_unix_ms = entry
                .metadata()
                .ok()
                .and_then(|metadata| metadata.modified().ok())
                .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
                .and_then(|duration| u64::try_from(duration.as_millis()).ok());
            result.candidates.push(DiscoveredCandidate {
                locator: CandidateLocator::File {
                    root: canonical_root.clone(),
                    primary,
                    layout: root.layout,
                },
                session_id_hint,
                modified_at_unix_ms,
            });
            if report_candidate_overflow
                && result.candidates.len() > candidate_limit
                && !candidate_limit_reported
            {
                result.issues.push(issue(
                    root,
                    root_slot,
                    NativeHistoryDiscoveryIssueKind::CandidateLimitReached,
                ));
                candidate_limit_reported = true;
            }
            if result.candidates.len() > candidate_limit.saturating_mul(2) {
                retain_newest(&mut result.candidates, candidate_limit);
            }
        }
    }
    retain_newest(&mut result.candidates, candidate_limit);
    result
}

pub(crate) fn dedupe_and_sort(candidates: &mut Vec<DiscoveredCandidate>) {
    candidates.sort_by(|left, right| {
        Reverse(left.modified_at_unix_ms)
            .cmp(&Reverse(right.modified_at_unix_ms))
            .then_with(|| locator_sort_key(&left.locator).cmp(&locator_sort_key(&right.locator)))
    });
    let mut seen = HashSet::new();
    candidates.retain(|candidate| seen.insert(locator_sort_key(&candidate.locator)));
}

fn locator_sort_key(locator: &CandidateLocator) -> String {
    match locator {
        CandidateLocator::File { primary, .. } => primary.to_string_lossy().into_owned(),
    }
}

fn retain_newest(candidates: &mut Vec<DiscoveredCandidate>, limit: usize) {
    candidates.sort_by(|left, right| {
        Reverse(left.modified_at_unix_ms)
            .cmp(&Reverse(right.modified_at_unix_ms))
            .then_with(|| locator_sort_key(&left.locator).cmp(&locator_sort_key(&right.locator)))
    });
    candidates.truncate(limit);
}

fn matches_file(adapter_id: &str, path: &Path) -> bool {
    let name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("");
    let extension = path
        .extension()
        .and_then(|extension| extension.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    match adapter_id {
        "grok" => name == "summary.json",
        "rovo" => name == "metadata.json",
        "kimi" => {
            name == "state.json"
                && path
                    .parent()
                    .and_then(Path::file_name)
                    .and_then(|name| name.to_str())
                    .is_some_and(|name| name.starts_with("session_"))
        }
        "cursor" => extension == "jsonl" && has_component(path, "agent-transcripts"),
        "openclaw" => extension == "jsonl" && has_component(path, "sessions"),
        "claude-code" => extension == "jsonl" && !has_component(path, "subagents"),
        "codex" | "pi" | "omp" => extension == "jsonl",
        _ => false,
    }
}

fn should_descend(adapter_id: &str, path: &Path) -> bool {
    let name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("");
    if adapter_id == "claude-code" && name == "subagents" {
        return false;
    }
    true
}

fn session_id_hint(adapter_id: &str, path: &Path) -> Option<String> {
    let hint = match adapter_id {
        "grok" | "rovo" | "kimi" => path.parent()?.file_name()?.to_str()?.to_owned(),
        _ => path.file_stem()?.to_str()?.to_owned(),
    };
    let hint = hint.trim();
    (!hint.is_empty()).then(|| hint.to_owned())
}

fn has_component(path: &Path, expected: &str) -> bool {
    path.components()
        .any(|component| component.as_os_str() == expected)
}

fn issue(
    root: &NativeHistoryRoot,
    root_slot: usize,
    kind: NativeHistoryDiscoveryIssueKind,
) -> NativeHistoryDiscoveryIssue {
    NativeHistoryDiscoveryIssue {
        root_slot,
        adapter_id: root.adapter_id.clone(),
        kind,
    }
}