ownserp 1.3.0

OwnSERP, an MCP server for the Serper.dev search API, over stdio and Streamable HTTP
use anyhow::{Context, bail};
use ownserp_core::{Overrides, Settings, default_data_dir};

use crate::cli::{AuditAction, AuditArgs};
use crate::output::emit;

pub(crate) fn run(args: AuditArgs) -> anyhow::Result<()> {
    match args.action {
        AuditAction::Verify { ledger_file } => {
            let path = match ledger_file {
                Some(path) => path,
                None => {
                    let settings =
                        Settings::load(Overrides::default()).context("resolving settings")?;
                    settings.ledger_dir().join("calls.jsonl")
                }
            };
            if !path.exists() {
                if path_is_under_the_default_data_dir(&path) {
                    emit(&format!("no ledger yet at {}", path.display()))?;
                    return Ok(());
                }
                bail!("no ledger at {}", path.display());
            }
            let rows = ownserp_core::verify(&path).context("verifying the ledger")?;
            emit(&format!("ledger verified: {rows} rows, hash chain intact"))?;
            Ok(())
        }
        AuditAction::Rotate { ledger_file } => {
            let path = match ledger_file {
                Some(path) => path,
                None => {
                    let settings =
                        Settings::load(Overrides::default()).context("resolving settings")?;
                    settings.ledger_dir().join("calls.jsonl")
                }
            };
            let (rows, archive) = ownserp_core::rotate(&path).context("rotating the ledger")?;
            emit(&format!("verified {rows} rows, hash chain intact"))?;
            emit(&format!("archived to {}", archive.display()))?;
            emit(&format!(
                "the next start writes a fresh chain at {}",
                path.display()
            ))?;
            Ok(())
        }
    }
}

fn path_is_under_the_default_data_dir(path: &std::path::Path) -> bool {
    path.starts_with(default_data_dir())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::{AuditAction, AuditArgs};
    use ownserp_core::{CallRecord, Ledger};

    fn temp_dir(label: &str) -> std::path::PathBuf {
        let dir =
            std::env::temp_dir().join(format!("ownserp-audit-{}-{label}", std::process::id()));
        std::fs::create_dir_all(&dir).expect("temp dir");
        dir
    }

    fn record() -> CallRecord {
        CallRecord {
            tool: "serper_search".to_owned(),
            endpoint: "/search".to_owned(),
            args_hash: "0".repeat(64),
            credits_estimated: 1,
            credits_actual: None,
            batch_multiplier: 1,
            duration_ms: 2,
            status: "ok".to_owned(),
            response_bytes: 10,
            tokens: 2,
            tokens_saved: 1,
            cache_hit: false,
        }
    }

    #[test]
    fn verify_then_rotate_moves_the_file_and_leaves_the_chain_valid() {
        let dir = temp_dir("rotate");
        let path = dir.join("calls.jsonl");
        let _ = std::fs::remove_file(&path);
        {
            let ledger = Ledger::open(&path).expect("ledger");
            ledger.append(record()).expect("append");
        }
        run(AuditArgs {
            action: AuditAction::Verify {
                ledger_file: Some(path.clone()),
            },
        })
        .expect("verify");
        run(AuditArgs {
            action: AuditAction::Rotate {
                ledger_file: Some(path.clone()),
            },
        })
        .expect("rotate");
        assert!(!path.exists(), "live ledger is archived");
        let archived = std::fs::read_dir(&dir)
            .expect("dir")
            .filter_map(Result::ok)
            .any(|entry| entry.file_name() != "calls.jsonl");
        assert!(archived, "an archive must exist");
        std::fs::remove_dir_all(&dir).expect("cleanup");
    }

    #[test]
    fn a_file_under_the_data_directory_counts_as_the_default() {
        assert!(path_is_under_the_default_data_dir(
            &ownserp_core::default_data_dir().join("ledger/calls.jsonl")
        ));
        assert!(!path_is_under_the_default_data_dir(std::path::Path::new(
            "/tmp/calls.jsonl"
        )));
    }

    #[test]
    fn verifying_a_missing_named_ledger_is_an_error() {
        let path = temp_dir("missing-verify").join("calls.jsonl");
        let outcome = run(AuditArgs {
            action: AuditAction::Verify {
                ledger_file: Some(path),
            },
        });
        assert!(outcome.is_err());
    }

    #[test]
    fn rotating_a_missing_ledger_reports_an_error() {
        let path = temp_dir("missing").join("calls.jsonl");
        let outcome = run(AuditArgs {
            action: AuditAction::Rotate {
                ledger_file: Some(path),
            },
        });
        assert!(outcome.is_err());
    }
}