cordance-cli 0.1.1

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! Context tier: `cordance_context_summary`, `cordance_context_list_sources`,
//! `cordance_context_source_info`.
//!
//! None of these tools return file body content; they return metadata only.

use std::collections::BTreeMap;

use camino::Utf8PathBuf;
use cordance_core::pack::CordancePack;
use cordance_core::source::SourceRecord;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::mcp::error::{McpToolError, McpToolResult};

/// Hard ceiling for paginated source listing (`cordance_context_list_sources`).
/// Matches the size-limits requirement in `MCP_ADVERSARIAL.md` §4.4 / §10.
pub const MAX_SOURCES_PAGE: usize = 1_000;
/// Default page size when the caller does not supply a `limit`.
pub const DEFAULT_SOURCES_PAGE: usize = 100;

#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct ContextSummaryParams {
    /// Target repo root. Defaults to the server's working directory.
    #[serde(default)]
    pub target: Option<String>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct ContextSummaryOutput {
    pub schema: String,
    pub project_name: String,
    pub project_kind: String,
    pub host_os: String,
    pub source_count: usize,
    /// `{ "engineering_doctrine_principle": 12, ... }` — count per
    /// serialised `SourceClass`.
    pub classification_breakdown: BTreeMap<String, usize>,
    pub doctrine_pin: Option<DoctrinePinSummary>,
    pub axiom_algorithm_pin: String,
    pub generated_at: String,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct DoctrinePinSummary {
    pub repo: String,
    pub commit: String,
    #[schemars(with = "String")]
    pub source_path: Utf8PathBuf,
}

#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct ListSourcesParams {
    #[serde(default)]
    pub target: Option<String>,
    /// Opaque cursor returned by a previous call's `next_cursor`. When
    /// omitted, listing starts at index 0.
    #[serde(default)]
    pub cursor: Option<String>,
    /// Page size. Capped at [`MAX_SOURCES_PAGE`]; default
    /// [`DEFAULT_SOURCES_PAGE`].
    #[serde(default)]
    pub limit: Option<usize>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct ListSourcesOutput {
    pub schema: String,
    /// Total count across all pages.
    pub total: usize,
    pub records: Vec<SourceRecordSummary>,
    /// Cursor to pass back to fetch the next page; `null` at end of stream.
    pub next_cursor: Option<String>,
}

/// Metadata-only projection of [`SourceRecord`] — never carries content.
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceRecordSummary {
    pub id: String,
    #[schemars(with = "String")]
    pub path: Utf8PathBuf,
    /// Serialised `SourceClass` (e.g. `"project_adr"`).
    pub class: String,
    pub sha256: String,
    pub size_bytes: u64,
    pub blocked: bool,
}

#[derive(Clone, Debug, Deserialize, JsonSchema)]
pub struct SourceInfoParams {
    #[serde(default)]
    pub target: Option<String>,
    /// Stable source identifier (`{class}:{path}`) returned by
    /// `cordance_context_list_sources`.
    pub id: String,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceInfoOutput {
    pub schema: String,
    pub record: SourceRecordSummary,
}

/// Build a deterministic context summary for `target`.
///
/// `target` MUST already be canonicalised by
/// [`crate::mcp::validation::validate_target`].
pub fn summary(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<ContextSummaryOutput> {
    let pack = build_pack(target, cfg)?;
    Ok(ContextSummaryOutput {
        schema: "cordance-context-summary.v1".to_string(),
        project_name: pack.project.name.clone(),
        project_kind: pack.project.kind.clone(),
        host_os: pack.project.host_os.clone(),
        source_count: pack.sources.len(),
        classification_breakdown: classification_breakdown(&pack.sources),
        doctrine_pin: pack.doctrine_pins.first().map(|pin| DoctrinePinSummary {
            repo: pin.repo.clone(),
            commit: pin.commit.clone(),
            source_path: pin.source_path.clone(),
        }),
        axiom_algorithm_pin: cfg.axiom_version(target),
        // Round-4 bughunt #1: `CordancePack.generated_at` is gone from the
        // on-disk shape so `pack.json` is byte-deterministic. The MCP wire
        // schema keeps `generated_at` as a "when did this tool respond"
        // breadcrumb — informational only, not an attestation of when the
        // underlying pack was built.
        generated_at: chrono::Utc::now().to_rfc3339(),
    })
}

/// Paginated metadata listing for the source set under `target`.
pub fn list_sources(
    target: &Utf8PathBuf,
    cfg: &Config,
    cursor: Option<&str>,
    limit: Option<usize>,
) -> McpToolResult<ListSourcesOutput> {
    let pack = build_pack(target, cfg)?;
    let start = parse_cursor(cursor)?;
    let cap = limit
        .unwrap_or(DEFAULT_SOURCES_PAGE)
        .clamp(1, MAX_SOURCES_PAGE);
    let total = pack.sources.len();

    if start >= total {
        return Ok(ListSourcesOutput {
            schema: "cordance-context-source-list.v1".to_string(),
            total,
            records: Vec::new(),
            next_cursor: None,
        });
    }

    let end = (start + cap).min(total);
    let records: Vec<SourceRecordSummary> = pack
        .sources
        .iter()
        .skip(start)
        .take(end - start)
        .map(summarise_record)
        .collect();

    let next_cursor = if end < total {
        Some(format_cursor(end))
    } else {
        None
    };

    Ok(ListSourcesOutput {
        schema: "cordance-context-source-list.v1".to_string(),
        total,
        records,
        next_cursor,
    })
}

/// Look up a single source's metadata by stable id.
pub fn source_info(
    target: &Utf8PathBuf,
    cfg: &Config,
    id: &str,
) -> McpToolResult<SourceInfoOutput> {
    let pack = build_pack(target, cfg)?;
    let record = pack
        .sources
        .iter()
        .find(|r| r.id == id)
        .ok_or_else(|| McpToolError::NotFound(format!("no source with id {id}")))?;
    Ok(SourceInfoOutput {
        schema: "cordance-context-source-info.v1".to_string(),
        record: summarise_record(record),
    })
}

fn summarise_record(r: &SourceRecord) -> SourceRecordSummary {
    SourceRecordSummary {
        id: r.id.clone(),
        path: r.path.clone(),
        class: serialise_class(r),
        sha256: r.sha256.clone(),
        size_bytes: r.size_bytes,
        blocked: r.blocked,
    }
}

fn serialise_class(r: &SourceRecord) -> String {
    // SourceClass round-trips through serde; mirror that for the wire shape
    // so the breakdown keys and `class` fields use the same snake_case form.
    serde_json::to_value(r.class)
        .ok()
        .and_then(|v| v.as_str().map(str::to_owned))
        .unwrap_or_else(|| "unclassified".to_string())
}

fn classification_breakdown(records: &[SourceRecord]) -> BTreeMap<String, usize> {
    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
    for r in records {
        let key = serialise_class(r);
        *counts.entry(key).or_insert(0) += 1;
    }
    counts
}

fn parse_cursor(cursor: Option<&str>) -> McpToolResult<usize> {
    let Some(raw) = cursor else {
        return Ok(0);
    };
    raw.parse::<usize>().map_err(|e| {
        McpToolError::InvalidArgument(format!("cursor must be a non-negative integer: {e}"))
    })
}

fn format_cursor(idx: usize) -> String {
    idx.to_string()
}

pub fn build_pack(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<CordancePack> {
    super::pack::build_pack(target, cfg)
}