pmat 3.14.0

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
/// Enrich functions with contract metadata from SQLite (optional columns).
///
/// Handles old schema gracefully — if contract_level/contract_equation
/// columns don't exist, silently skips enrichment.
fn enrich_contract_metadata(conn: &rusqlite::Connection, functions: &mut [FunctionEntry]) {
    let query = "SELECT id, contract_level, contract_equation FROM functions WHERE contract_level IS NOT NULL ORDER BY id";
    let Ok(mut stmt) = conn.prepare(query) else {
        return; // Column doesn't exist in old schema — skip
    };
    let Ok(rows) = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i64>(0)? as usize,
            row.get::<_, Option<String>>(1)?,
            row.get::<_, Option<String>>(2)?,
        ))
    }) else {
        return;
    };
    for row in rows.flatten() {
        let (id, level, equation) = row;
        if id >= 1 && id <= functions.len() {
            functions[id - 1].quality.contract_level = level;
            functions[id - 1].quality.contract_equation = equation;
        }
    }
}

impl AgentContextIndex {
    /// Save index to directory
    #[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
    pub fn save(&self, index_path: &Path) -> Result<(), String> {

        fs::create_dir_all(index_path)
            .map_err(|e| format!("Failed to create index directory: {e}"))?;

        // Save manifest
        let manifest_json = serde_json::to_string_pretty(&self.manifest)
            .map_err(|e| format!("Failed to serialize manifest: {e}"))?;
        fs::write(index_path.join("manifest.json"), manifest_json)
            .map_err(|e| format!("Failed to write manifest: {e}"))?;

        // Phase 3 (#159): Write only SQLite + FTS5 index (no more LZ4 blob)
        // context.db lives alongside context.idx directory
        let db_path = index_path.with_extension("db");
        super::sqlite_backend::save_to_sqlite(
            &db_path,
            &self.functions,
            &self.calls,
            &self.graph_metrics,
            &self.manifest,
            &self.coverage_off_files,
        )?;

        Ok(())
    }

    /// Load index from directory.
    ///
    /// Prefers SQLite `context.db` when available (v2.0), falls back to
    /// LZ4+bincode blob `context.idx/functions.lz4` (v1.x).
    #[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
    pub fn load(index_path: &Path) -> Result<Self, String> {
        // Try SQLite path first (v2.0)
        let db_candidate = index_path.with_extension("db");
        if db_candidate.exists() {
            // Validate schema before attempting full load — stale DBs from older
            // versions may lack required tables, producing confusing warnings.
            let schema_ok = super::sqlite_backend::open_db(&db_candidate)
                .map(|conn| super::sqlite_backend::has_valid_schema(&conn))
                .unwrap_or(false);

            if schema_ok {
                match Self::load_from_sqlite(&db_candidate) {
                    Ok(index) => return Ok(index),
                    Err(e) => {
                        eprintln!("  Warning: SQLite load failed, falling back to blob: {e}");
                    }
                }
            } else {
                // Delete broken/stale DB so next save() regenerates it
                let _ = std::fs::remove_file(&db_candidate);
            }
        }

        Self::load_from_blob(index_path)
    }

    /// Load index from SQLite database (v2.0 fast path).
    ///
    /// Reads functions (without source) and graph metrics from `context.db`.
    /// Skips corpus (FTS5 handles search), call graph (queried on-demand),
    /// and source code (loaded on-demand for display or regex/literal search).
    fn load_from_sqlite(db_path: &Path) -> Result<Self, String> {
        use super::sqlite_backend::{
            load_functions_lightweight, load_graph_metrics, load_metadata, open_db,
        };

        let conn = open_db(db_path)?;
        let manifest = load_metadata(&conn)?;
        let mut functions = load_functions_lightweight(&conn)?;
        // Enrich with contract metadata (optional columns, may not exist in old schemas)
        enrich_contract_metadata(&conn, &mut functions);
        let graph_metrics = load_graph_metrics(&conn)?;
        // Call graph loaded on-demand via get_calls()/get_called_by() SQLite fallback
        let calls = HashMap::new();
        let called_by = HashMap::new();

        // Build name_index + file_index only (no corpus — FTS5 handles search)
        let indices = build_indices_without_corpus(&functions);
        let name_frequency = compute_name_frequency(&indices.name_index, functions.len());

        let project_root = PathBuf::from(&manifest.project_root);

        // Load cached coverage_off_files from SQLite metadata
        let coverage_off_files = load_coverage_off_files(&conn);

        Ok(Self {
            functions,
            name_index: indices.name_index,
            file_index: indices.file_index,
            corpus: Vec::new(),
            corpus_lower: Vec::new(),
            name_frequency,
            calls,
            called_by,
            graph_metrics,
            project_root,
            manifest,
            db_path: Some(db_path.to_path_buf()),
            coverage_off_files,
        })
    }

    /// Load index from LZ4+bincode blob (v1.x legacy path).
    fn load_from_blob(index_path: &Path) -> Result<Self, String> {
        // Load manifest
        let manifest_str = fs::read_to_string(index_path.join("manifest.json"))
            .map_err(|e| format!("Failed to read manifest: {e}"))?;
        let manifest: IndexManifest = serde_json::from_str(&manifest_str)
            .map_err(|e| format!("Failed to parse manifest: {e}"))?;

        // Load and decompress blob
        let compressed = fs::read(index_path.join("functions.lz4"))
            .map_err(|e| format!("Failed to read functions: {e}"))?;
        let decompressed = lz4_flex::decompress_size_prepended(&compressed)
            .map_err(|e| format!("Failed to decompress functions: {e}"))?;

        // Deserialize payload (v1.3.0+ has cached indices)
        let payload: IndexPayload = bincode::deserialize(&decompressed)
            .map_err(|e| format!("Failed to parse payload: {e}"))?;

        let functions = payload.functions;
        let corpus = payload.corpus;
        let calls = payload.calls;
        let called_by = payload.called_by;

        // Check if we have cached indices (v1.3.0+) - avoids expensive PageRank recomputation
        let has_cached_indices = !payload.name_index.is_empty();

        let (name_index, file_index, graph_metrics, corpus_lower, name_frequency) =
            if has_cached_indices {
                let corpus_lower = if payload.corpus_lower.is_empty() {
                    corpus.iter().map(|d| d.to_lowercase()).collect()
                } else {
                    payload.corpus_lower
                };
                (
                    payload.name_index,
                    payload.file_index,
                    payload.graph_metrics,
                    corpus_lower,
                    payload.name_frequency,
                )
            } else {
                // Slow path: rebuild indices for legacy formats (v1.0-v1.2)
                let is_legacy =
                    manifest.version.starts_with("1.0") || manifest.version.starts_with("1.1");
                let indices = build_indices(&functions);

                let (calls_rebuilt, called_by_rebuilt) = if is_legacy && calls.is_empty() {
                    build_call_graph(&functions, &indices.name_index)
                } else {
                    (calls.clone(), called_by.clone())
                };

                let graph_metrics =
                    compute_graph_metrics(functions.len(), &calls_rebuilt, &called_by_rebuilt);
                let name_frequency = compute_name_frequency(&indices.name_index, functions.len());
                let corpus_lower: Vec<String> = corpus.iter().map(|d| d.to_lowercase()).collect();

                (
                    indices.name_index,
                    indices.file_index,
                    graph_metrics,
                    corpus_lower,
                    name_frequency,
                )
            };

        let project_root = PathBuf::from(&manifest.project_root);

        // Detect SQLite FTS5 database alongside blob
        let db_candidate = index_path.with_extension("db");
        let db_path = if db_candidate.exists() {
            Some(db_candidate)
        } else {
            None
        };

        Ok(Self {
            functions,
            name_index,
            file_index,
            corpus,
            corpus_lower,
            name_frequency,
            calls,
            called_by,
            graph_metrics,
            project_root,
            manifest,
            db_path,
            coverage_off_files: HashSet::new(), // Legacy blob path — no cached data
        })
    }
}